From 8199aca40c9cf27aff3de7ba852e420985a54bf5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 12:59:45 +0200 Subject: [PATCH 001/284] fix: publish server package --- scripts/local-release.mjs | 1 + scripts/publish.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 7f0c0052d72..569a5d7ce94 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -13,6 +13,7 @@ const packages = [ { directory: "packages/protocol", name: "@earendil-works/pi-protocol" }, { directory: "packages/client", name: "@earendil-works/pi-client" }, { directory: "packages/session-backends/sqlite-node", name: "@earendil-works/pi-session-backend-sqlite-node" }, + { directory: "packages/server", name: "@earendil-works/pi-server" }, { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, ]; diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 3ad86961bb5..e680e25f973 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -11,6 +11,7 @@ const packages = [ { directory: "packages/protocol", name: "@earendil-works/pi-protocol" }, { directory: "packages/client", name: "@earendil-works/pi-client" }, { directory: "packages/session-backends/sqlite-node", name: "@earendil-works/pi-session-backend-sqlite-node" }, + { directory: "packages/server", name: "@earendil-works/pi-server" }, { directory: "packages/tui", name: "@earendil-works/pi-tui" }, { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, ]; From aa601d7ba0e64e063d2b16d850d7bf32787b49ab Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 13:22:48 +0200 Subject: [PATCH 002/284] fix(tui): correct LaTeX whitespace and matrix layouts --- packages/tui/CHANGELOG.md | 4 + packages/tui/src/latex.ts | 244 +++++++++++++++++++++++------ packages/tui/test/latex.test.ts | 111 +++++++++---- packages/tui/test/markdown.test.ts | 23 ++- 4 files changed, 306 insertions(+), 76 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 42b2163b712..22903fe2749 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed LaTeX relation, multiplication, and named-operator spacing, and correctly composed matrices with stacked fractions, operator limits, and adjacent matrices. + ## [0.84.0] - 2026-08-06 ### Added diff --git a/packages/tui/src/latex.ts b/packages/tui/src/latex.ts index 02be3b7ac33..58b6633b4b3 100644 --- a/packages/tui/src/latex.ts +++ b/packages/tui/src/latex.ts @@ -288,6 +288,90 @@ const DISPLAY_LIMIT_SYMBOLS = new Set([ "sum", ]); +const RELATION_COMMANDS = new Set([ + "Leftarrow", + "Leftrightarrow", + "Longleftarrow", + "Longleftrightarrow", + "Longrightarrow", + "Rightarrow", + "Vdash", + "Vvdash", + "approx", + "asymp", + "cong", + "dashv", + "doteq", + "downarrow", + "equiv", + "ge", + "geq", + "geqslant", + "gets", + "gg", + "hookleftarrow", + "hookrightarrow", + "iff", + "implies", + "in", + "leadsto", + "le", + "leftarrow", + "leftharpoondown", + "leftharpoonup", + "leftrightarrow", + "leftrightharpoons", + "leq", + "leqslant", + "ll", + "longleftarrow", + "longleftrightarrow", + "longmapsto", + "longrightarrow", + "mapsto", + "mid", + "models", + "ne", + "nearrow", + "neq", + "ni", + "notin", + "nvdash", + "nvDash", + "nwarrow", + "parallel", + "perp", + "prec", + "preceq", + "propto", + "rightharpoondown", + "rightharpoonup", + "rightleftharpoons", + "rightarrow", + "rightsquigarrow", + "searrow", + "sim", + "simeq", + "sqsubset", + "sqsubseteq", + "sqsupset", + "sqsupseteq", + "subset", + "subseteq", + "succ", + "succeq", + "supset", + "supseteq", + "swarrow", + "to", + "triangleleft", + "triangleright", + "twoheadleftarrow", + "twoheadrightarrow", + "uparrow", + "vdash", +]); + const NEGATED_SYMBOLS: Readonly> = { "<": "≮", ">": "≯", @@ -515,7 +599,7 @@ function replaceCharacters(value: string, replacements: Readonly line.replace(/[ \t]+/g, " ").trim()) .filter((line, index, lines) => line.length > 0 || (index > 0 && index < lines.length - 1)) @@ -562,7 +655,13 @@ interface OperatorNode { upper?: string; } -type LayoutNode = FractionNode | OperatorNode; +interface MatrixNode { + type: "matrix"; + lines: string[]; + baseline: number; +} + +type LayoutNode = FractionNode | OperatorNode | MatrixNode; interface Layout { lines: string[]; @@ -573,7 +672,8 @@ interface Layout { const LAYOUT_MARKER_START = "\u{f0000}"; const LAYOUT_MARKER_END = "\u{f0001}"; const LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}/gu; -const PROTECTED_SPACE = "\u00a0"; +const TRAILING_LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}$/u; +const PROTECTED_SPACE = "\u{f0002}"; function padLayoutLine(line: string, width: number, centered = false): string { const padding = Math.max(0, width - visibleWidth(line)); @@ -612,18 +712,25 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { for (const sourceLine of source.split("\n")) { const layouts: Layout[] = []; let position = 0; - let previousWasNode = false; + let previousNode: LayoutNode | undefined; for (const match of sourceLine.matchAll(LAYOUT_MARKER_PATTERN)) { const index = match.index; - if (index > position) { - const sliced = sourceLine.slice(position, index); - const text = (previousWasNode ? sliced.trimStart() : sliced).trimEnd(); - layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); - } const node = nodes[Number(match[1])]; if (!node) { continue; } + if (index > position) { + const sliced = sourceLine.slice(position, index); + const trimmed = (previousNode ? sliced.trimStart() : sliced).trimEnd(); + const preserveLeadingSpace = previousNode?.type === "matrix" && /^\s/.test(sliced); + const preserveTrailingSpace = node.type === "matrix" && /\s$/.test(sliced); + const text = trimmed + ? `${preserveLeadingSpace ? " " : ""}${trimmed}${preserveTrailingSpace ? " " : ""}` + : preserveLeadingSpace || preserveTrailingSpace + ? " " + : ""; + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } if (node.type === "fraction") { const numerator = renderLayout(node.numerator, nodes); const denominator = renderLayout(node.denominator, nodes); @@ -638,7 +745,7 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { width, baseline: numerator.lines.length, }); - } else { + } else if (node.type === "operator") { const contentWidth = Math.max( visibleWidth(node.operator), node.lower === undefined ? 0 : visibleWidth(node.lower), @@ -657,13 +764,21 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { width: contentWidth + 1, baseline: node.upper === undefined ? 0 : 1, }); + } else { + const width = Math.max(0, ...node.lines.map((line) => visibleWidth(line))); + layouts.push({ + lines: node.lines.map((line) => padLayoutLine(line, width)), + width, + baseline: node.baseline, + }); } position = index + match[0].length; - previousWasNode = true; + previousNode = node; } if (position < sourceLine.length) { const sliced = sourceLine.slice(position); - const text = previousWasNode ? sliced.trimStart() : sliced; + const trimmed = previousNode ? sliced.trimStart() : sliced; + const text = previousNode?.type === "matrix" && /^\s/.test(sliced) ? ` ${trimmed}` : trimmed; layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); } const lineLayout = joinLayouts(layouts); @@ -681,14 +796,16 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { class LatexParser { private readonly source: string; - private readonly layoutNodes: LayoutNode[] | undefined; + private readonly layoutNodes: LayoutNode[]; + private readonly display: boolean; private position = 0; private supported = true; private stackFractions = true; - constructor(source: string, layoutNodes?: LayoutNode[]) { + constructor(source: string, layoutNodes: LayoutNode[], display: boolean) { this.source = source; this.layoutNodes = layoutNodes; + this.display = display; } render(): string | undefined { @@ -723,6 +840,9 @@ class LatexParser { const command = this.parseCommand(); if (command === NEGATIVE_SPACE) { result = result.trimEnd(); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = result.slice(0, -NAMED_OPERATOR_END.length); + } } else { result += command; } @@ -732,7 +852,12 @@ class LatexParser { if (character === "^" || character === "_") { this.position++; result = result.trimEnd(); - result += formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + const script = formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = `${result.slice(0, -NAMED_OPERATOR_END.length)}${script}${NAMED_OPERATOR_END}`; + } else { + result += script; + } continue; } @@ -741,6 +866,12 @@ class LatexParser { continue; } + if (character === "=" || character === "<" || character === ">") { + result = `${result.trimEnd()} ${character} `; + this.position++; + continue; + } + if (character === "&") { this.position++; continue; @@ -752,6 +883,17 @@ class LatexParser { continue; } + if (character === ".") { + const marker = TRAILING_LAYOUT_MARKER_PATTERN.exec(result); + const node = marker ? this.layoutNodes[Number(marker[1])] : undefined; + if (node?.type === "matrix") { + const lastLine = node.lines.length - 1; + node.lines[lastLine] = `${node.lines[lastLine] ?? ""}${character}`; + this.position++; + continue; + } + } + result += character; this.position++; } @@ -819,14 +961,14 @@ class LatexParser { const value = this.parseRequiredArgument(false).trim(); const negated = NEGATED_SYMBOLS[value]; if (negated !== undefined) { - return negated; + return ` ${negated} `; } const characters = Array.from(value); if (characters.length === 0) { this.supported = false; return ""; } - return `${characters[0]}\u0338${characters.slice(1).join("")}`; + return ` ${characters[0]}\u0338${characters.slice(1).join("")} `; } if (LIMIT_OPERATORS.has(command)) { return this.parseOperator(command, "bracket", true, true); @@ -834,10 +976,13 @@ class LatexParser { const symbol = SYMBOLS[command]; if (symbol !== undefined) { - return DISPLAY_LIMIT_SYMBOLS.has(command) ? this.parseOperator(symbol, "script", true) : symbol; + if (DISPLAY_LIMIT_SYMBOLS.has(command)) { + return this.parseOperator(symbol, "script", true); + } + return command === "cdot" || command === "times" || RELATION_COMMANDS.has(command) ? ` ${symbol} ` : symbol; } if (NAMED_OPERATORS.has(command)) { - return ` ${command} `; + return `${NAMED_OPERATOR_START}${command}${NAMED_OPERATOR_END}`; } if (SIZE_COMMANDS.has(command)) { return ""; @@ -849,10 +994,10 @@ class LatexParser { return ""; } if (command === "frac" || command === "dfrac" || command === "tfrac") { - const shouldStack = this.layoutNodes !== undefined && this.stackFractions && command !== "tfrac"; + const shouldStack = this.display && this.stackFractions && command !== "tfrac"; const numerator = this.parseRequiredArgument(!shouldStack); const denominator = this.parseRequiredArgument(!shouldStack); - if (shouldStack && this.layoutNodes) { + if (shouldStack) { const index = this.layoutNodes.push({ type: "fraction", @@ -976,7 +1121,7 @@ class LatexParser { } } - if (this.layoutNodes && useDisplayLimits && (lower !== undefined || upper !== undefined)) { + if (this.display && useDisplayLimits && (lower !== undefined || upper !== undefined)) { const index = this.layoutNodes.push({ type: "operator", operator, lower, upper }) - 1; return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; } @@ -1157,36 +1302,39 @@ class LatexParser { return `${cell}${PROTECTED_SPACE.repeat(Math.max(0, (columnWidths[column] ?? 0) - visibleWidth(cell)))}`; }).join(" │ "), ); - if (environment === "array" || environment === "matrix" || environment === "smallmatrix") { - return rows.join("\n"); - } - const delimiters: Readonly> = { - pmatrix: ["⎛", "⎞", "⎜", "⎟", "⎝", "⎠"], - bmatrix: ["⎡", "⎤", "⎢", "⎥", "⎣", "⎦"], - Bmatrix: ["⎧", "⎫", "⎨", "⎬", "⎩", "⎭"], - vmatrix: ["│", "│", "│", "│", "│", "│"], - Vmatrix: ["║", "║", "║", "║", "║", "║"], - }; - const delimiter = delimiters[environment]; - if (!delimiter) { - this.supported = false; - return rows.join("\n"); - } - if (rows.length === 1) { - return `${delimiter[0]} ${rows[0]} ${delimiter[1]}`; - } - return rows - .map((row, index) => { + let lines: string[]; + if (environment === "array" || environment === "matrix" || environment === "smallmatrix") { + lines = rows; + } else { + const delimiters: Readonly> = { + pmatrix: ["⎛", "⎞", "⎜", "⎟", "⎝", "⎠"], + bmatrix: ["⎡", "⎤", "⎢", "⎥", "⎣", "⎦"], + Bmatrix: ["⎧", "⎫", "⎨", "⎬", "⎩", "⎭"], + vmatrix: ["│", "│", "│", "│", "│", "│"], + Vmatrix: ["║", "║", "║", "║", "║", "║"], + }; + const delimiter = delimiters[environment]; + if (!delimiter) { + this.supported = false; + return rows.join("\n"); + } + lines = rows.map((row, index) => { const left = index === 0 ? delimiter[0] : index === rows.length - 1 ? delimiter[4] : delimiter[2]; const right = index === 0 ? delimiter[1] : index === rows.length - 1 ? delimiter[5] : delimiter[3]; return `${left} ${row} ${right}`; - }) - .join("\n"); + }); + } + + if (lines.length <= 1) { + return lines[0] ?? ""; + } + const index = this.layoutNodes.push({ type: "matrix", lines, baseline: 0 }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; } private renderNested(source: string, stackFractions = true): string { - const rendered = new LatexParser(source, stackFractions ? this.layoutNodes : undefined).render(); + const rendered = new LatexParser(source, this.layoutNodes, this.display && stackFractions).render(); if (rendered === undefined) { this.supported = false; return source; @@ -1205,12 +1353,12 @@ export interface RenderLatexOptions { * Returns undefined when the expression contains unsupported or malformed syntax. */ export function renderLatex(source: string, options: RenderLatexOptions = {}): string | undefined { - const layoutNodes: LayoutNode[] | undefined = options.display ? [] : undefined; - const rendered = new LatexParser(source, layoutNodes).render(); + const layoutNodes: LayoutNode[] = []; + const rendered = new LatexParser(source, layoutNodes, options.display === true).render(); if (rendered === undefined) { return undefined; } - if (!layoutNodes || layoutNodes.length === 0) { + if (layoutNodes.length === 0) { return rendered.replaceAll(PROTECTED_SPACE, " "); } const lines = renderLayout(rendered, layoutNodes).lines; diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts index d6d65e5f2d6..3644ec42b32 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/tui/test/latex.test.ts @@ -37,7 +37,7 @@ describe("renderLatex", () => { ["G = u^2 z + y^2(4+3xy)", "G = u² z + y²(4+3xy)"], ["F_1 = uG", "F₁ = uG"], ["F_2 = y + 3xG", "F₂ = y + 3xG"], - ["x=0", "x=0"], + ["x=0", "x = 0"], ["F_2 = F_3 = 0", "F₂ = F₃ = 0"], ["xy = -3/2", "xy = -3/2"], ["x^2 z = 13/2", "x² z = 13/2"], @@ -94,7 +94,7 @@ describe("renderLatex", () => { defineCases([ [ String.raw`\det\!\left(\frac{\partial(F_1,F_2,F_3)}{\partial(x,y,z)}\right)=-2.`, - "det((∂(F₁,F₂,F₃))/(∂(x,y,z)))=-2.", + "det((∂(F₁,F₂,F₃))/(∂(x,y,z))) = -2.", ], [ String.raw`\begin{aligned} @@ -102,9 +102,9 @@ F(0,0,-\tfrac14)&=(-\tfrac14,0,0),\\ F(1,-\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0),\\ F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0). \end{aligned}`, - "F(0,0,-1/4)=(-1/4,0,0),\nF(1,-3/2,13/2)=(-1/4,0,0),\nF(-1,3/2,13/2)=(-1/4,0,0).", + "F(0,0,-1/4) = (-1/4,0,0),\nF(1,-3/2,13/2) = (-1/4,0,0),\nF(-1,3/2,13/2) = (-1/4,0,0).", ], - ["F=(F_1,F_2,F_3)", "F=(F₁,F₂,F₃)"], + ["F=(F_1,F_2,F_3)", "F = (F₁,F₂,F₃)"], ["F", "F"], ["3", "3"], ]); @@ -118,7 +118,7 @@ F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0). \frac{\partial f_2}{\partial x} & \frac{\partial f_2}{\partial y} & \frac{\partial f_2}{\partial z} \\ \frac{\partial f_3}{\partial x} & \frac{\partial f_3}{\partial y} & \frac{\partial f_3}{\partial z} \end{pmatrix}`, - "J = ⎛ (∂ f₁)/(∂ x) │ (∂ f₁)/(∂ y) │ (∂ f₁)/(∂ z) ⎞\n⎜ (∂ f₂)/(∂ x) │ (∂ f₂)/(∂ y) │ (∂ f₂)/(∂ z) ⎟\n⎝ (∂ f₃)/(∂ x) │ (∂ f₃)/(∂ y) │ (∂ f₃)/(∂ z) ⎠", + "J = ⎛ (∂ f₁)/(∂ x) │ (∂ f₁)/(∂ y) │ (∂ f₁)/(∂ z) ⎞\n ⎜ (∂ f₂)/(∂ x) │ (∂ f₂)/(∂ y) │ (∂ f₂)/(∂ z) ⎟\n ⎝ (∂ f₃)/(∂ x) │ (∂ f₃)/(∂ y) │ (∂ f₃)/(∂ z) ⎠", ], [ String.raw`\begin{aligned} @@ -166,7 +166,7 @@ f_3 = x\,(2 - 3u - t) describe("extended formulas from a renderer stress-test session", () => { defineCases([ - [String.raw`e^{i\pi}+1=0`, "e^(iπ)+1=0"], + [String.raw`e^{i\pi}+1=0`, "e^(iπ)+1 = 0"], [ String.raw`\boxed{ \mathcal{Z}(\beta) @@ -193,7 +193,7 @@ R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu} &= \frac{8\pi G}{c^4}T_{\mu\nu}. \end{aligned}`, - "∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) =0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).", + "∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) = 0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).", ], [ String.raw`f(z) @@ -208,7 +208,11 @@ R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu} 0 & -f & \lambda-g \end{pmatrix} =0.`, - "f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞\n⎜ -c │ λ-d │ -e ⎟\n⎝ 0 │ -f │ λ-g ⎠ =0.", + [ + "f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞ = 0.", + `${" ".repeat(40)}⎜ -c │ λ-d │ -e ⎟`, + `${" ".repeat(40)}⎝ 0 │ -f │ λ-g ⎠`, + ].join("\n"), ], [ String.raw`\Psi(x,t)= @@ -226,27 +230,27 @@ c_n \Psi^\ast\Psi, & 0 { @@ -315,12 +319,12 @@ c_n renderLatex(String.raw`\lvert{x}\rvert+\lVert{v}\rVert+\left.\frac{dy}{dx}\right|_{x=0}`), "|x|+‖v‖+dy/(dx)|ₓ₌₀", ); - assert.strictEqual(renderLatex(String.raw`\left\lbrace x \middle| x>0 \right\rbrace`), "{ x | x>0 }"); + assert.strictEqual(renderLatex(String.raw`\left\lbrace x \middle| x>0 \right\rbrace`), "{ x | x > 0 }"); }); it("renders named, modular, overlaid, and underlaid operators", () => { assert.strictEqual(renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`), "arg max[x∈X] f(x)"); - assert.strictEqual(renderLatex(String.raw`a\bmod n,\quad a\equiv b\pmod n`), "a mod n, a≡ b (mod n)"); + assert.strictEqual(renderLatex(String.raw`a\bmod n,\quad a\equiv b\pmod n`), "a mod n, a ≡ b (mod n)"); assert.strictEqual(renderLatex(String.raw`\overset{!}{=}+\underset{n}{x}+\stackrel{def}{=}`), "=^!+xₙ+=ᵈᵉᶠ"); }); @@ -339,18 +343,18 @@ c_n it("renders additional display environments", () => { assert.strictEqual( renderLatex(String.raw`\begin{equation}\begin{split}a&=b\\&=c\end{split}\end{equation}`), - "a=b\n=c", + "a = b\n= c", ); assert.strictEqual( renderLatex(String.raw`\begin{alignedat}{2}a&=b&\quad c&=d\\e&=f&g&=h\end{alignedat}`), - "a=b c=d\ne=f g=h", + "a = b c = d\ne = f g = h", ); }); it("uses natural case conditions and aligns matrix columns", () => { assert.strictEqual( renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`), - "⎧ a if x<0\n⎨ b if x=0\n⎩ c otherwise", + "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c otherwise", ); assert.strictEqual( renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`), @@ -358,6 +362,59 @@ c_n ); }); + it("composes matrices with fractions and adjacent matrices", () => { + assert.strictEqual( + renderLatex( + String.raw`R\left(\frac{\pi}{4}\right) += +\begin{pmatrix} +\frac{\sqrt{2}}{2} & -\frac{\sqrt{2}}{2}\\ +\frac{\sqrt{2}}{2} & \frac{\sqrt{2}}{2} +\end{pmatrix}.`, + { display: true }, + ), + " π\nR( ─ ) = ⎛ (√2)/2 │ -(√2)/2 ⎞\n 4 ⎝ (√2)/2 │ (√2)/2 ⎠.", + ); + assert.strictEqual( + renderLatex( + String.raw`\mathbf w += +R\left(\frac{\pi}{4}\right) +\begin{pmatrix}1\\0\end{pmatrix} += +\begin{pmatrix}\frac{\sqrt{2}}{2}\\\frac{\sqrt{2}}{2}\end{pmatrix}.`, + { display: true }, + ), + " π\nw = R( ─ ) ⎛ 1 ⎞ = ⎛ (√2)/2 ⎞\n 4 ⎝ 0 ⎠ ⎝ (√2)/2 ⎠.", + ); + assert.strictEqual( + renderLatex( + String.raw`A\mathbf e_1=\begin{pmatrix}\pi\\0\end{pmatrix},\qquad A\mathbf e_2=\begin{pmatrix}0\\\frac{1}{\pi}\end{pmatrix}.`, + { display: true }, + ), + "Ae₁ = ⎛ π ⎞, Ae₂ = ⎛ 0 ⎞\n ⎝ 0 ⎠ ⎝ 1/π ⎠.", + ); + assert.strictEqual( + renderLatex(String.raw`\sum_{i=0}^n x_i=\begin{pmatrix}a&b\\c&d\end{pmatrix}.`, { display: true }), + " n\n ∑ xᵢ = ⎛ a │ b ⎞\ni=0 ⎝ c │ d ⎠.", + ); + }); + + it("normalizes relation, multiplication, and named-operator spacing", () => { + for (const source of ["x=y", "x =y", "x=\ny", "x\n=\ny"]) { + assert.strictEqual(renderLatex(source), "x = y"); + } + assert.strictEqual(renderLatex("x_{i=0}"), "xᵢ₌₀"); + assert.strictEqual(renderLatex(String.raw`x\neq0`), "x ≠ 0"); + assert.strictEqual(renderLatex(String.raw`A\to B`), "A → B"); + assert.strictEqual(renderLatex(String.raw`\pi\cdot\frac{1}{\pi}`), "π · 1/π"); + assert.strictEqual(renderLatex(String.raw`\sin\theta`), "sin θ"); + assert.strictEqual(renderLatex(String.raw`\sin^2 x`), "sin² x"); + assert.strictEqual(renderLatex(String.raw`-\sin\theta`), "-sin θ"); + assert.strictEqual(renderLatex(String.raw`i\sin\theta`), "i sin θ"); + assert.strictEqual(renderLatex(String.raw`\det(A)`), "det(A)"); + }); + it("stacks operator limits in display mode", () => { assert.strictEqual(renderLatex(String.raw`\sum_{i=0}^n x_i`, { display: true }), " n\n ∑ xᵢ\ni=0"); assert.strictEqual(renderLatex(String.raw`\min_{x\in X} f(x)`, { display: true }), "min f(x)\nx∈X"); @@ -374,7 +431,7 @@ c_n it("uses the middle brace for intermediate case rows", () => { assert.strictEqual( renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`), - "⎧ a if x<0\n⎨ b if x=0\n⎩ c if x>0", + "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0", ); }); @@ -383,7 +440,7 @@ c_n renderLatex(String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, { display: true, }), - " -b±√(b²-4ac)\nx= ────────────\n 2a", + " -b±√(b²-4ac)\nx = ────────────\n 2a", ); assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1"); }); @@ -396,7 +453,7 @@ c_n ], [ String.raw`\lim_{x\to 0}\frac{\frac{\sin x}{x}-1}{\frac{e^x-1}{x}-1}=0`, - " (sin x)/x-1\nlim ─────────── =0\nx→0 (eˣ-1)/x-1", + " (sin x)/x-1\nlim ─────────── = 0\nx→0 (eˣ-1)/x-1", ], [ String.raw`\frac{1+\frac{1}{1+\frac{1}{x}}}{1-\frac{1}{1-\frac{1}{x}}}`, diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 549b3a4fd2c..d45b7716d29 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -743,6 +743,27 @@ after`, assert.deepStrictEqual(lines, ["Before", "", " 0.1 lux", "E ≈ ────────", " 100 lm/W", "", "after"]); }); + it("aligns matrix rows with the opening delimiter", () => { + const markdown = new Markdown( + String.raw`Consider the matrix + +\[ +A= +\begin{pmatrix} +\pi & 0\\ +0 & \frac{1}{\pi} +\end{pmatrix}. +\]`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Consider the matrix", "", "A = ⎛ π │ 0 ⎞", " ⎝ 0 │ 1/π ⎠."]); + }); + it("renders lower limits beneath display operators", () => { const markdown = new Markdown( String.raw`\[ @@ -755,7 +776,7 @@ after`, const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); - assert.deepStrictEqual(lines, [" (sin x)/x-1", "lim ─────────── =0", "x→0 (eˣ-1)/x-1"]); + assert.deepStrictEqual(lines, [" (sin x)/x-1", "lim ─────────── = 0", "x→0 (eˣ-1)/x-1"]); }); it("renders math inside lists and tables", () => { From d406aa3946ce5c6da4e67afc94693dcf3f6ff084 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:26:38 +0200 Subject: [PATCH 003/284] docs: reserve L1 --- packages/agent/docs/harness-v2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index f2198ae8f90..a8700372e94 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -3302,6 +3302,8 @@ I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins These packages all own `packages/agent/src/agent-loop.ts` and therefore merge strictly L1 → L2 → L3. Existing `agent-loop` and `agent` tests pass unchanged after each package. +**Reserved: L1 by @cristinaponcela.** Other agents must not pick L1 while this ownership marker remains. + - [ ] **L1 — extract assistant streaming.** Dependencies: I0. - Add `streamAssistant()` and `StreamAssistantConfig`, including explicit telemetry context; route the compatibility loop's request path through it without changing events or results. - Acceptance: focused stream tests cover settled-result narrowing (a final `pending` value is a defect), plus unchanged existing loop tests. From bde81c84405514c8b0f57c34405c152fb129c0ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 6 Aug 2026 11:50:54 +0000 Subject: [PATCH 004/284] chore: approve contributors from issue #7703 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 297f30fb422..a8e56d70994 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -339,3 +339,5 @@ skkdevcraft pr PierrunoYT pr zhichli pr + +wesleyzhangwq pr From 1532c99943b894fe1d12e15ac388c7b1887ca403 Mon Sep 17 00:00:00 2001 From: wesleyzhangwq Date: Thu, 6 Aug 2026 23:12:25 +0800 Subject: [PATCH 005/284] fix(agent): reject reset during active runs (#7717) --- packages/agent/src/agent.ts | 4 ++++ packages/agent/test/agent.test.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 6f9d6a3000b..0de7edd8302 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -331,6 +331,10 @@ export class Agent { /** Clear transcript state, runtime state, and queued messages. */ reset(): void { + if (this.activeRun) { + throw new Error("Agent is already processing. Wait for completion before resetting."); + } + this._state.messages = []; this._state.isStreaming = false; this._state.streamingMessage = undefined; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index e3fd9f00d84..672c4a6789c 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -505,6 +505,40 @@ describe("Agent", () => { expect(() => agent.abort()).not.toThrow(); }); + it("should reject reset while processing without corrupting the transcript", async () => { + const streamStarted = createDeferred(); + const releaseResponse = createDeferred(); + const agent = new Agent({ + streamFn: () => { + const stream = new MockAssistantStream(); + queueMicrotask(async () => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + streamStarted.resolve(); + await releaseResponse.promise; + stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Done") }); + }); + return stream; + }, + }); + + const promptPromise = agent.prompt("Hello"); + await streamStarted.promise; + + try { + expect(agent.state.isStreaming).toBe(true); + expect(agent.state.messages.map((message) => message.role)).toEqual(["user"]); + expect(() => agent.reset()).toThrow("Agent is already processing. Wait for completion before resetting."); + expect(agent.state.isStreaming).toBe(true); + expect(agent.state.messages.map((message) => message.role)).toEqual(["user"]); + } finally { + releaseResponse.resolve(); + await promptPromise; + } + + expect(agent.state.isStreaming).toBe(false); + expect(agent.state.messages.map((message) => message.role)).toEqual(["user", "assistant"]); + }); + it("should throw when prompt() called while streaming", async () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ From 1eb988cfe88fb0ff740ff62583d2f16359f7b6b0 Mon Sep 17 00:00:00 2001 From: muyiyr <940955635@qq.com> Date: Thu, 6 Aug 2026 23:14:48 +0800 Subject: [PATCH 006/284] feat(agent): allow blocked tool calls to terminate (#7715) Closes #5998 --- packages/agent/README.md | 8 +- packages/agent/src/agent-loop.ts | 6 +- packages/agent/src/types.ts | 6 + packages/agent/test/agent-loop.test.ts | 118 ++++++++++++++++++ packages/coding-agent/docs/extensions.md | 5 +- .../coding-agent/src/core/extensions/types.ts | 5 + .../5998-blocked-tool-terminate.test.ts | 53 ++++++++ 7 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/5998-blocked-tool-terminate.test.ts diff --git a/packages/agent/README.md b/packages/agent/README.md index d26229efa7e..879a4742916 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -119,9 +119,9 @@ In parallel mode, tool completion events follow tool completion order, but persi The mode can be set globally via `toolExecution` in the agent config, or per-tool via `executionMode` on `AgentTool`. If any tool call in a batch targets a tool with `executionMode: "sequential"`, the entire batch executes sequentially regardless of the global setting. -The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted. +The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution and attach `terminate: true` to the blocked result. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted. -Tools can also return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally. +Tools, blocked `beforeToolCall` results, and `afterToolCall` overrides can return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally. The `Agent` class accepts `shouldStopAfterTurn` in `AgentOptions`. Low-level loop callers can set the same hook in `AgentLoopConfig`: @@ -213,7 +213,7 @@ const agent = new Agent({ // Preflight each tool call after args are validated. Can block execution. beforeToolCall: async ({ toolCall, args, context }) => { if (toolCall.name === "bash") { - return { block: true, reason: "bash is disabled" }; + return { block: true, reason: "bash is disabled", terminate: true }; } }, @@ -453,7 +453,7 @@ execute: async (toolCallId, params, signal, onUpdate) => { Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`. -Return `terminate: true` from `execute()` or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results. +Return `terminate: true` from `execute()`, a blocked `beforeToolCall`, or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results. ## Proxy Usage diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 3a12506cb2c..a251fede0a9 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -634,9 +634,13 @@ async function prepareToolCall( }; } if (beforeResult?.block) { + const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked"); + if (beforeResult.terminate === true) { + result.terminate = true; + } return { kind: "immediate", - result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), + result, isError: true, }; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 52f34891a7e..5b20b21f4ea 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -61,6 +61,11 @@ export type AgentToolCall = Extract Promise; diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 8a2a7388dda..e5b2f1db866 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1250,6 +1250,124 @@ describe("agentLoop with AgentMessage", () => { expect(events.filter((event) => event.type === "turn_end")).toHaveLength(1); }); + it("should stop after a blocked tool call when beforeToolCall sets terminate=true", async () => { + const toolSchema = Type.Object({ value: Type.String() }); + let executed = false; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute() { + executed = true; + return { + content: [{ type: "text", text: "should not execute" }], + details: { value: "unexpected" }, + }; + }, + }; + const context: AgentContext = { + systemPrompt: "", + messages: [], + tools: [tool], + }; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + beforeToolCall: async () => ({ block: true, reason: "Blocked by policy", terminate: true }), + }; + + let llmCalls = 0; + const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, () => { + llmCalls++; + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + const message = + llmCalls === 1 + ? createAssistantMessage( + [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }], + "toolUse", + ) + : createAssistantMessage([{ type: "text", text: "should not run" }]); + mockStream.push({ type: "done", reason: llmCalls === 1 ? "toolUse" : "stop", message }); + }); + return mockStream; + }); + + for await (const _event of stream) { + // consume + } + + const messages = await stream.result(); + const toolResult = messages.find((message) => message.role === "toolResult"); + expect(executed).toBe(false); + expect(llmCalls).toBe(1); + expect(toolResult?.role === "toolResult" ? toolResult.isError : false).toBe(true); + expect(toolResult?.role === "toolResult" ? toolResult.content : []).toContainEqual({ + type: "text", + text: "Blocked by policy", + }); + }); + + it("should continue after a mixed batch with one terminating blocked call", async () => { + const toolSchema = Type.Object({ value: Type.String() }); + const executed: string[] = []; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + executed.push(params.value); + return { + content: [{ type: "text", text: `echoed: ${params.value}` }], + details: { value: params.value }, + }; + }, + }; + const context: AgentContext = { + systemPrompt: "", + messages: [], + tools: [tool], + }; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + toolExecution: "parallel", + beforeToolCall: async ({ args }) => { + const { value } = args as { value: string }; + return value === "first" ? { block: true, reason: "Blocked first", terminate: true } : undefined; + }, + }; + + let llmCalls = 0; + const stream = agentLoop([createUserMessage("echo both")], context, config, undefined, () => { + llmCalls++; + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + const message = + llmCalls === 1 + ? createAssistantMessage( + [ + { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } }, + { type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } }, + ], + "toolUse", + ) + : createAssistantMessage([{ type: "text", text: "done" }]); + mockStream.push({ type: "done", reason: llmCalls === 1 ? "toolUse" : "stop", message }); + }); + return mockStream; + }); + + for await (const _event of stream) { + // consume + } + + expect(executed).toEqual(["second"]); + expect(llmCalls).toBe(2); + }); + it("should continue after parallel tool calls when not all tool results terminate", async () => { const toolSchema = Type.Object({ value: Type.String() }); const tool: AgentTool = { diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 48adbba9c80..00e92a1d619 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -762,7 +762,8 @@ Behavior guarantees: - Mutations to `event.input` affect the actual tool execution - Later `tool_call` handlers see mutations made by earlier handlers - No re-validation is performed after your mutation -- Return values from `tool_call` only control blocking via `{ block: true, reason?: string }` +- Return values from `tool_call` control blocking via `{ block: true, reason?: string, terminate?: boolean }` +- `terminate` only applies to a blocked call; the agent stops early only when every finalized result in the batch is terminating ```typescript import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; @@ -778,7 +779,7 @@ pi.on("tool_call", async (event, ctx) => { event.input.command = `source ~/.profile\n${event.input.command}`; if (event.input.command.includes("rm -rf")) { - return { block: true, reason: "Dangerous command" }; + return { block: true, reason: "Dangerous command", terminate: true }; } } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index e62dfa10f67..a87322ce9e1 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1072,6 +1072,11 @@ export interface ToolCallEventResult { /** Block tool execution. To modify arguments, mutate `event.input` in place instead. */ block?: boolean; reason?: string; + /** + * Hint that the agent should stop after the current tool batch when this call is blocked. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; } /** Result from user_bash event handler */ diff --git a/packages/coding-agent/test/suite/regressions/5998-blocked-tool-terminate.test.ts b/packages/coding-agent/test/suite/regressions/5998-blocked-tool-terminate.test.ts new file mode 100644 index 00000000000..93413be83da --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5998-blocked-tool-terminate.test.ts @@ -0,0 +1,53 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, getAssistantTexts, type Harness } from "../harness.ts"; + +describe("#5998 blocked tool termination", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("lets a tool_call handler terminate the run after blocking execution", async () => { + const echoTool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo text back", + parameters: Type.Object({ text: Type.String() }), + execute: async () => { + throw new Error("tool should have been blocked"); + }, + }; + const harness = await createHarness({ + tools: [echoTool], + extensionFactories: [ + (pi) => { + pi.on("tool_call", async () => ({ + block: true, + reason: "Blocked by terminating policy", + terminate: true, + })); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }), + fauxAssistantMessage("should not run"), + ]); + + await harness.session.prompt("hi"); + + expect(harness.getPendingResponseCount()).toBe(1); + expect(getAssistantTexts(harness)).not.toContain("should not run"); + expect(harness.eventsOfType("tool_execution_end")[0]?.result).toHaveProperty("terminate", true); + expect( + harness.session.messages.find((message) => message.role === "toolResult" && message.isError), + ).toBeDefined(); + }); +}); From beeca6ab740b166a6bbcf5627a3844985215f1ef Mon Sep 17 00:00:00 2001 From: Ilya <62308020+geril07@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:16:12 +0300 Subject: [PATCH 007/284] fix(coding-agent): disable bunfig autoload in compiled binaries (#7685) Bun standalone binaries load cwd bunfig.toml preload before pi starts, so project preloads can crash even pi --version. Compile with --no-compile-autoload-bunfig. fixes #7684 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/package.json | 2 +- scripts/build-binaries.sh | 7 +++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f75f9b8865e..9c0cb0c1426 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -123,6 +123,7 @@ ### Fixed +- Fixed Bun standalone binaries crashing on startup when the cwd contains a `bunfig.toml` with `preload` by compiling with `--no-compile-autoload-bunfig` ([#7684](https://github.com/earendil-works/pi/issues/7684)). - Fixed the footer showing `(sub)` for generic OAuth/OpenID sign-ins without a known subscription; extension OAuth providers can opt in with `isSubscription`. - Fixed inherited OAuth token refreshes so stalled requests release the credential-store lock ([#7508](https://github.com/earendil-works/pi/issues/7508)). - Fixed inherited tool argument validation to preserve values that already match an `anyOf`/`oneOf` union arm before coercion, avoiding nullable unions converting `null` to another primitive value ([#7328](https://github.com/earendil-works/pi/issues/7328)). diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 7dd347d0c94..3bd3f733a5c 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -35,7 +35,7 @@ "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 702f7d97cb6..c2901978299 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -169,10 +169,13 @@ for platform in "${PLATFORMS[@]}"; do # Bun compiled executables only embed worker scripts when they are passed as # explicit build entrypoints. The runtime can still use new URL(...), but the # worker must be present in the compiled executable. + # + # Disable cwd bunfig.toml autoload so project preload scripts cannot crash the + # standalone binary before pi starts (see #7684). if [[ "$platform" == windows-* ]]; then - bun build --compile --target="$bun_target" ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe" + bun build --compile --no-compile-autoload-bunfig --target="$bun_target" ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe" else - bun build --compile --target="$bun_target" ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi" + bun build --compile --no-compile-autoload-bunfig --target="$bun_target" ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi" fi done From 9ab91fb93e6bb755051de8d000a347a903dd32ec Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Thu, 6 Aug 2026 18:17:13 +0300 Subject: [PATCH 008/284] feat(coding-agent): expose tool prompt contributions (#7671) --- packages/coding-agent/src/core/tools/bash.ts | 11 +++--- packages/coding-agent/src/core/tools/edit.ts | 20 ++++++----- packages/coding-agent/src/core/tools/find.ts | 7 +++- packages/coding-agent/src/core/tools/grep.ts | 7 +++- packages/coding-agent/src/core/tools/ls.ts | 7 +++- packages/coding-agent/src/core/tools/read.ts | 9 +++-- packages/coding-agent/src/core/tools/write.ts | 9 +++-- .../tool-system-prompt-contributions.test.ts | 36 +++++++++++++++++++ 8 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 packages/coding-agent/test/tool-system-prompt-contributions.test.ts diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 1c245cbb4b0..5bdfe2c4f89 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -42,6 +42,11 @@ const bashSchema = Type.Object({ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), }); +export const bashToolSystemPromptContribution = { + snippet: "Execute bash commands (ls, grep, find, etc.)", + guidelines: ["Inspect PI_* environment variables for current model and session details."], +} as const; + export type BashToolInput = Static; export interface BashToolDetails { @@ -325,10 +330,8 @@ export function createBashToolDefinition( name: "bash", label: "bash", description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, - promptSnippet: "Execute bash commands (ls, grep, find, etc.)", - promptGuidelines: exposeSessionEnvironment - ? ["Inspect PI_* environment variables for current model and session details."] - : undefined, + promptSnippet: bashToolSystemPromptContribution.snippet, + promptGuidelines: exposeSessionEnvironment ? [...bashToolSystemPromptContribution.guidelines] : undefined, parameters: bashSchema, async execute( _toolCallId, diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index feaa7176f8f..01fd4145391 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -52,6 +52,16 @@ const editSchema = Type.Object( {}, ); +export const editToolSystemPromptContribution = { + snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", + guidelines: [ + "Use edit for precise changes (edits[].oldText must match exactly)", + "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls", + "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.", + "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.", + ], +} as const; + export type EditToolInput = Static; type LegacyEditToolInput = EditToolInput & { oldText?: unknown; @@ -294,14 +304,8 @@ export function createEditToolDefinition( label: "edit", description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.", - promptSnippet: - "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", - promptGuidelines: [ - "Use edit for precise changes (edits[].oldText must match exactly)", - "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls", - "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.", - "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.", - ], + promptSnippet: editToolSystemPromptContribution.snippet, + promptGuidelines: [...editToolSystemPromptContribution.guidelines], parameters: editSchema, renderShell: "self", prepareArguments: prepareEditArguments, diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index ce6caf6727b..a14f327e840 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -34,6 +34,11 @@ const findSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })), }); +export const findToolSystemPromptContribution = { + snippet: "Find files by glob pattern (respects .gitignore)", + guidelines: [], +} as const; + export type FindToolInput = Static; const DEFAULT_LIMIT = 1000; @@ -124,7 +129,7 @@ export function createFindToolDefinition( name: "find", label: "find", description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, - promptSnippet: "Find files by glob pattern (respects .gitignore)", + promptSnippet: findToolSystemPromptContribution.snippet, parameters: findSchema, async execute( _toolCallId, diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index e4ed36d1b68..2274e706449 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -35,6 +35,11 @@ const grepSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })), }); +export const grepToolSystemPromptContribution = { + snippet: "Search file contents for patterns (respects .gitignore)", + guidelines: [], +} as const; + export type GrepToolInput = Static; const DEFAULT_LIMIT = 100; @@ -129,7 +134,7 @@ export function createGrepToolDefinition( name: "grep", label: "grep", description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, - promptSnippet: "Search file contents for patterns (respects .gitignore)", + promptSnippet: grepToolSystemPromptContribution.snippet, parameters: grepSchema, async execute( _toolCallId, diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 8a689e8da2b..893e13c250d 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -16,6 +16,11 @@ const lsSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })), }); +export const lsToolSystemPromptContribution = { + snippet: "List directory contents", + guidelines: [], +} as const; + export type LsToolInput = Static; const DEFAULT_LIMIT = 500; @@ -101,7 +106,7 @@ export function createLsToolDefinition( name: "ls", label: "ls", description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, - promptSnippet: "List directory contents", + promptSnippet: lsToolSystemPromptContribution.snippet, parameters: lsSchema, async execute( _toolCallId, diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 9442e98737b..130e6a2ea4c 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -23,6 +23,11 @@ const readSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })), }); +export const readToolSystemPromptContribution = { + snippet: "Read file contents", + guidelines: ["Use read to examine files instead of cat or sed."], +} as const; + export type ReadToolInput = Static; export interface ReadToolDetails { @@ -210,8 +215,8 @@ export function createReadToolDefinition( name: "read", label: "read", description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, - promptSnippet: "Read file contents", - promptGuidelines: ["Use read to examine files instead of cat or sed."], + promptSnippet: readToolSystemPromptContribution.snippet, + promptGuidelines: [...readToolSystemPromptContribution.guidelines], parameters: readSchema, async execute( _toolCallId, diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 12668e61a76..876aa6d8001 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -16,6 +16,11 @@ const writeSchema = Type.Object({ content: Type.String({ description: "Content to write to the file" }), }); +export const writeToolSystemPromptContribution = { + snippet: "Create or overwrite files", + guidelines: ["Use write only for new files or complete rewrites."], +} as const; + export type WriteToolInput = Static; /** @@ -188,8 +193,8 @@ export function createWriteToolDefinition( label: "write", description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", - promptSnippet: "Create or overwrite files", - promptGuidelines: ["Use write only for new files or complete rewrites."], + promptSnippet: writeToolSystemPromptContribution.snippet, + promptGuidelines: [...writeToolSystemPromptContribution.guidelines], parameters: writeSchema, async execute( _toolCallId, diff --git a/packages/coding-agent/test/tool-system-prompt-contributions.test.ts b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts new file mode 100644 index 00000000000..4a2927c165f --- /dev/null +++ b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { bashToolSystemPromptContribution, createBashToolDefinition } from "../src/core/tools/bash.ts"; +import { createEditToolDefinition, editToolSystemPromptContribution } from "../src/core/tools/edit.ts"; +import { createFindToolDefinition, findToolSystemPromptContribution } from "../src/core/tools/find.ts"; +import { createGrepToolDefinition, grepToolSystemPromptContribution } from "../src/core/tools/grep.ts"; +import { createLsToolDefinition, lsToolSystemPromptContribution } from "../src/core/tools/ls.ts"; +import { createReadToolDefinition, readToolSystemPromptContribution } from "../src/core/tools/read.ts"; +import { createWriteToolDefinition, writeToolSystemPromptContribution } from "../src/core/tools/write.ts"; + +const cases = [ + ["read", readToolSystemPromptContribution, createReadToolDefinition], + ["bash", bashToolSystemPromptContribution, createBashToolDefinition], + ["edit", editToolSystemPromptContribution, createEditToolDefinition], + ["write", writeToolSystemPromptContribution, createWriteToolDefinition], + ["grep", grepToolSystemPromptContribution, createGrepToolDefinition], + ["find", findToolSystemPromptContribution, createFindToolDefinition], + ["ls", lsToolSystemPromptContribution, createLsToolDefinition], +] as const; + +describe("built-in tool system prompt contributions", () => { + test.each(cases)( + "keeps the %s tool definition aligned with its contribution", + (_name, contribution, createDefinition) => { + const definition = createDefinition("/workspace"); + + expect(definition.promptSnippet).toBe(contribution.snippet); + expect(definition.promptGuidelines ?? []).toEqual(contribution.guidelines); + }, + ); + + test("keeps bash session-environment guidance conditional", () => { + const definition = createBashToolDefinition("/workspace", { exposeSessionEnvironment: false }); + + expect(definition.promptGuidelines).toBeUndefined(); + }); +}); From c03d78bdce7b154672084dcf286f4fbe2de466ac Mon Sep 17 00:00:00 2001 From: Mehmet Aras Date: Thu, 6 Aug 2026 18:18:02 +0300 Subject: [PATCH 009/284] feat(ai): add Qwen Token Plan Individual provider (#7659) --- packages/ai/README.md | 9 +- packages/ai/scripts/generate-models.ts | 51 ++++++++--- packages/ai/scripts/model-data.ts | 7 ++ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 3 + packages/ai/src/providers/all.ts | 2 + .../qwen-token-plan-individual.models.ts | 8 ++ .../providers/qwen-token-plan-individual.ts | 15 ++++ packages/ai/src/types.ts | 1 + packages/ai/test/abort.test.ts | 12 +++ packages/ai/test/context-overflow.test.ts | 12 +++ .../ai/test/cross-provider-handoff.test.ts | 15 ++++ packages/ai/test/empty.test.ts | 20 +++++ .../ai/test/generate-models-strict.test.ts | 85 +++++++++++++++++++ packages/ai/test/image-tool-result.test.ts | 12 +++ .../ai/test/model-data-validation.test.ts | 13 +++ .../openai-completions-tool-choice.test.ts | 2 +- .../ai/test/qwen-token-plan-models.test.ts | 55 ++++++++++-- packages/ai/test/stream.test.ts | 31 +++++++ packages/ai/test/tokens.test.ts | 8 ++ .../ai/test/tool-call-without-result.test.ts | 8 ++ packages/ai/test/total-tokens.test.ts | 25 ++++++ packages/ai/test/unicode-surrogate.test.ts | 16 ++++ packages/coding-agent/docs/providers.md | 9 +- .../coding-agent/src/core/model-resolver.ts | 1 + .../coding-agent/test/model-resolver.test.ts | 4 + 26 files changed, 403 insertions(+), 22 deletions(-) create mode 100644 packages/ai/src/providers/qwen-token-plan-individual.models.ts create mode 100644 packages/ai/src/providers/qwen-token-plan-individual.ts create mode 100644 packages/ai/test/generate-models-strict.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 48bee35867f..16b3be096c3 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -85,6 +85,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and - **OpenCode Go** - **Fireworks** (uses OpenAI- and Anthropic-compatible APIs) - **Kimi For Coding** (Moonshot AI subscription endpoint, uses Anthropic-compatible API) +- **Qwen Token Plan** (separate Individual and existing catalogs, with a separate China provider) - **Xiaomi MiMo** (defaults to API billing endpoint, with separate Token Plan providers for `cn`/`ams`/`sgp` regions) - **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc. @@ -438,7 +439,8 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | Hugging Face | `HF_TOKEN` | | OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` | | Kimi For Coding | `KIMI_API_KEY` | -| Qwen Token Plan | `QWEN_TOKEN_PLAN_API_KEY` | +| Qwen Token Plan (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | +| Qwen Token Plan (Individual) | `QWEN_TOKEN_PLAN_API_KEY` | | Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | | Xiaomi MiMo (API billing) | `XIAOMI_API_KEY` | | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | @@ -446,6 +448,11 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | | GitHub Copilot | `COPILOT_GITHUB_TOKEN` | +`qwen-token-plan-individual` and `qwen-token-plan` share the international endpoint and +`QWEN_TOKEN_PLAN_API_KEY`. The Individual provider exposes only the models documented for Individual +subscriptions, while the existing provider retains its broader catalog for backward compatibility. +Stored credentials remain provider-scoped, so save the key under the provider ID you register. + Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs, `AWS_BEARER_TOKEN_BEDROCK`, ECS task roles, web identity tokens); its provider-owned login flow supports bearer tokens, AWS profiles, and the existing credential chain. Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location, with a provider-owned login flow for API keys, ADC, and service-account files. ## Tools diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 754662bcbf8..90e6bbb2f4e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -20,6 +20,7 @@ import type { OpenAIResponsesCompat, } from "../src/types.ts"; import { + assertExactModelIds, createModelDataManifest, type ModelDataStructure, MODEL_DATA_MANIFEST_FILE, @@ -295,6 +296,23 @@ const QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS = new Set([ ]); // Retired preview id — models.dev may still list it after GA ships. const QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS = new Set(["qwen3.8-max-preview"]); +const QWEN_TOKEN_PLAN_PROVIDER_IDS = new Set([ + "qwen-token-plan", + "qwen-token-plan-cn", + "qwen-token-plan-individual", +]); +// QwenCloud Token Plan Individual text-model allowlist, verified 2026-08-05. +// Retired models remain excluded above even if the public catalog lags. +// https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview +const QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS = new Set([ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", +]); const KIMI_K3_MAX_TOKENS = 131072; const KIMI_K3_COST = { @@ -2146,10 +2164,11 @@ async function loadModelsDevData(): Promise[]> { } } - // Process Alibaba Cloud Model Studio Token Plan models - // Two regions (international / cn) with identical catalogs, separate - // endpoints and API keys (sk-sp- prefix). models.dev keys are - // "alibaba-token-plan[-cn]"; pi exposes them as "qwen-token-plan[-cn]". + // Process Alibaba Cloud Model Studio Token Plan models. International and + // China use separate endpoints and API keys (sk-sp- prefix). The Individual + // provider reuses the international source and endpoint with a narrower catalog. + // models.dev keys are "alibaba-token-plan[-cn]"; pi exposes them as + // "qwen-token-plan[-cn]" plus the Individual catalog view. const qwenTokenPlanCompat: OpenAICompletionsCompat = { thinkingFormat: "qwen", supportsDeveloperRole: false, @@ -2161,22 +2180,31 @@ async function loadModelsDevData(): Promise[]> { source: "alibaba-token-plan", provider: "qwen-token-plan", baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + modelIds: undefined, + }, + { + source: "alibaba-token-plan", + provider: "qwen-token-plan-individual", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + modelIds: QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS, }, { source: "alibaba-token-plan-cn", provider: "qwen-token-plan-cn", baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + modelIds: undefined, }, ] as const; - for (const { source, provider, baseUrl } of qwenTokenPlanVariants) { + for (const { source, provider, baseUrl, modelIds } of qwenTokenPlanVariants) { const providerModels = data[source]?.models; - if (!providerModels) continue; + const emittedModelIds = modelIds ? new Set() : undefined; - for (const [modelId, model] of Object.entries(providerModels)) { + for (const [modelId, model] of Object.entries(providerModels ?? {})) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; if (QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS.has(modelId)) continue; + if (modelIds && !modelIds.has(modelId)) continue; const supportsReasoningEffort = !QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS.has(modelId); models.push({ @@ -2207,8 +2235,13 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + emittedModelIds?.add(modelId); recordModelsDevReasoningOptions(provider, modelId, m); } + + if (modelIds && emittedModelIds && generatorOptions.strict) { + assertExactModelIds(provider, modelIds, emittedModelIds); + } } console.log(`Loaded ${models.length} tool-capable models from models.dev`); @@ -2313,7 +2346,6 @@ async function generateModels() { } } - // Add missing gpt models const missingOpenAiModels: Model<"openai-responses">[] = [ { @@ -2474,8 +2506,7 @@ async function generateModels() { if ( candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4") && - candidate.provider !== "qwen-token-plan" && - candidate.provider !== "qwen-token-plan-cn" + !QWEN_TOKEN_PLAN_PROVIDER_IDS.has(candidate.provider) ) { const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode"; candidate.compat = { diff --git a/packages/ai/scripts/model-data.ts b/packages/ai/scripts/model-data.ts index 4dd94000e4e..f2081817af2 100644 --- a/packages/ai/scripts/model-data.ts +++ b/packages/ai/scripts/model-data.ts @@ -39,6 +39,13 @@ function describeSetDifference(expected: readonly string[], actual: readonly str .join("; "); } +export function assertExactModelIds(label: string, expected: Iterable, actual: Iterable): void { + const expectedIds = Array.from(new Set(expected)).sort(); + const actualIds = Array.from(new Set(actual)).sort(); + if (sameStrings(expectedIds, actualIds)) return; + throw new Error(`${label} model IDs do not match (${describeSetDifference(expectedIds, actualIds)})`); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index f535f53abbd..5b952b7b510 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -80,6 +80,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { "ant-ling": "ANT_LING_API_KEY", "qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY", "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY", + "qwen-token-plan-individual": "QWEN_TOKEN_PLAN_API_KEY", openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", nvidia: "NVIDIA_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 8832e4c83d9..12952045922 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -30,6 +30,7 @@ import { OPENCODE_GO_MODELS } from "./providers/opencode-go.models.ts"; import { OPENROUTER_MODELS } from "./providers/openrouter.models.ts"; import { QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts"; import { QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts"; +import { QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./providers/qwen-token-plan-individual.models.ts"; import { TOGETHER_MODELS } from "./providers/together.models.ts"; import { VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts"; import { XAI_MODELS } from "./providers/xai.models.ts"; @@ -70,6 +71,7 @@ export const MODELS: { readonly "openrouter": typeof OPENROUTER_MODELS; readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_MODELS; readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_MODELS; + readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS; readonly "together": typeof TOGETHER_MODELS; readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_MODELS; readonly "xai": typeof XAI_MODELS; @@ -109,6 +111,7 @@ export const MODELS: { "openrouter": OPENROUTER_MODELS, "qwen-token-plan": QWEN_TOKEN_PLAN_MODELS, "qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_MODELS, + "qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS, "together": TOGETHER_MODELS, "vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS, "xai": XAI_MODELS, diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 5e62f8d0a11..8785e94aed6 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -33,6 +33,7 @@ import { openrouterProvider } from "./openrouter.ts"; import { openrouterImagesProvider } from "./openrouter-images.ts"; import { qwenTokenPlanProvider } from "./qwen-token-plan.ts"; import { qwenTokenPlanCnProvider } from "./qwen-token-plan-cn.ts"; +import { qwenTokenPlanIndividualProvider } from "./qwen-token-plan-individual.ts"; import { radiusProvider } from "./radius.ts"; import { togetherProvider } from "./together.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; @@ -116,6 +117,7 @@ export function builtinProviders(): Provider[] { openrouterProvider(), qwenTokenPlanProvider(), qwenTokenPlanCnProvider(), + qwenTokenPlanIndividualProvider(), radiusProvider(), togetherProvider(), vercelAIGatewayProvider(), diff --git a/packages/ai/src/providers/qwen-token-plan-individual.models.ts b/packages/ai/src/providers/qwen-token-plan-individual.models.ts new file mode 100644 index 00000000000..30e111a2943 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-individual.models.ts @@ -0,0 +1,8 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qwen-token-plan-individual.json" with { type: "json" }; +import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts"; + +export const QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS: ModelCatalog = + flattenModelCatalog("qwen-token-plan-individual", values); diff --git a/packages/ai/src/providers/qwen-token-plan-individual.ts b/packages/ai/src/providers/qwen-token-plan-individual.ts new file mode 100644 index 00000000000..a231d1b313d --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-individual.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./qwen-token-plan-individual.models.ts"; + +export function qwenTokenPlanIndividualProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-token-plan-individual", + name: "Qwen Token Plan Individual", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + auth: { apiKey: envApiKeyAuth("Qwen Token Plan Individual API key", ["QWEN_TOKEN_PLAN_API_KEY"]) }, + models: Object.values(QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 1cdacdbae0f..75e1d6f3cea 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -68,6 +68,7 @@ export type KnownProvider = | "cloudflare-ai-gateway" | "qwen-token-plan" | "qwen-token-plan-cn" + | "qwen-token-plan-individual" | "xiaomi" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index dedee5cc36c..767be7d04d5 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -273,6 +273,18 @@ describe("AI Providers Abort Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Abort", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Abort", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 6aefb26fa28..59a3fb6beeb 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -479,6 +479,18 @@ describe("Context overflow error handling", () => { }, 120000); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual", () => { + it("qwen3.8-max - should detect overflow via isContextOverflow", async () => { + const model = getModel("qwen-token-plan-individual", "qwen3.8-max"); + const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/input length/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN)", () => { it("qwen3.7-max - should detect overflow via isContextOverflow", async () => { const model = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 1746411c726..ada2b536f85 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -135,6 +135,21 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ // Qwen Token Plan { provider: "qwen-token-plan", model: "qwen3.7-max", label: "qwen-token-plan-qwen3.7-max" }, { provider: "qwen-token-plan-cn", model: "qwen3.7-max", label: "qwen-token-plan-cn-qwen3.7-max" }, + { + provider: "qwen-token-plan-individual", + model: "qwen3.8-max", + label: "qwen-token-plan-individual-qwen3.8-max", + }, + { + provider: "qwen-token-plan-individual", + model: "deepseek-v4-flash-0731", + label: "qwen-token-plan-individual-deepseek-v4-flash-0731", + }, + { + provider: "qwen-token-plan-individual", + model: "glm-5.2", + label: "qwen-token-plan-individual-glm-5.2", + }, ]; // Cached context structure diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 310da05088c..b45ceed6858 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -576,6 +576,26 @@ describe("AI Providers Empty Message Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Empty Messages", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Empty Messages", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/generate-models-strict.test.ts b/packages/ai/test/generate-models-strict.test.ts new file mode 100644 index 00000000000..caf75c477d9 --- /dev/null +++ b/packages/ai/test/generate-models-strict.test.ts @@ -0,0 +1,85 @@ +import { spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("strict model generation", () => { + it("fails before mutating generated data when an Individual model loses tool support", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "pi-generate-models-")); + temporaryRoots.push(fixtureRoot); + const isolatedPackageRoot = join(fixtureRoot, "package"); + mkdirSync(isolatedPackageRoot); + for (const entry of ["package.json", "scripts", "src"]) { + cpSync(join(packageRoot, entry), join(isolatedPackageRoot, entry), { recursive: true }); + } + const preloadPath = join(fixtureRoot, "mock-models-dev.mjs"); + const modelIds = [ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", + "qwen3.8-max-preview", + ]; + const sourceModels = Object.fromEntries( + modelIds.map((id) => [ + id, + { + id, + name: id, + tool_call: id !== "deepseek-v4-flash-0731", + }, + ]), + ); + const catalog = { "alibaba-token-plan": { models: sourceModels } }; + writeFileSync( + preloadPath, + `const catalog = ${JSON.stringify(catalog)};\n` + + `globalThis.fetch = async (input) => {\n` + + ` if (String(input) === "https://models.dev/api.json") {\n` + + ` return new Response(JSON.stringify(catalog), { status: 200 });\n` + + ` }\n` + + ` throw new Error(\`Unexpected fetch: \${String(input)}\`);\n` + + `};\n`, + ); + + const generatedPaths = [ + "src/models.generated.ts", + "src/providers/qwen-token-plan-individual.models.ts", + "src/providers/data/qwen-token-plan-individual.json", + "src/providers/data/.manifest.json", + ]; + const sourceBefore = generatedPaths.map((path) => readFileSync(join(packageRoot, path), "utf8")); + const isolatedBefore = generatedPaths.map((path) => readFileSync(join(isolatedPackageRoot, path), "utf8")); + + const result = spawnSync( + process.execPath, + ["--import", pathToFileURL(preloadPath).href, "scripts/generate-models.ts", "--strict"], + { + cwd: isolatedPackageRoot, + encoding: "utf8", + timeout: 10_000, + }, + ); + + expect(result.status).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "qwen-token-plan-individual model IDs do not match (missing: deepseek-v4-flash-0731)", + ); + expect(generatedPaths.map((path) => readFileSync(join(isolatedPackageRoot, path), "utf8"))).toEqual( + isolatedBefore, + ); + expect(generatedPaths.map((path) => readFileSync(join(packageRoot, path), "utf8"))).toEqual(sourceBefore); + }); +}); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 8b77e793fd4..a331f2a343f 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -406,6 +406,18 @@ describe("Tool Results with Images", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider (qwen3.8-max)", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider (qwen3.7-max)", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/model-data-validation.test.ts b/packages/ai/test/model-data-validation.test.ts index fa84fd1ff89..f0e1b9fbcfc 100644 --- a/packages/ai/test/model-data-validation.test.ts +++ b/packages/ai/test/model-data-validation.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + assertExactModelIds, createModelDataManifest, MODEL_DATA_MANIFEST_FILE, MODEL_DATA_SCHEMA_VERSION, @@ -77,6 +78,18 @@ function writeFixtureData( } describe("generated model data validation", () => { + it("rejects a missing upstream model from an exact generated allowlist", () => { + expect(() => assertExactModelIds("qwen-token-plan-individual", ["model-a", "model-b"], ["model-a"])).toThrow( + "qwen-token-plan-individual model IDs do not match (missing: model-b)", + ); + }); + + it("rejects an unexpected model from an exact generated allowlist", () => { + expect(() => assertExactModelIds("test-provider", ["model-a"], ["model-a", "model-b"])).toThrow( + "test-provider model IDs do not match (extra: model-b)", + ); + }); + it("reads and validates API-grouped model data", () => { const { dataDir, packageRoot, structure } = createFixture(); expect(readModelDataStructure(packageRoot)).toEqual(structure); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index f21b748599e..844c819c6d2 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1127,7 +1127,7 @@ describe("openai-completions tool_choice", () => { }); it("stores Qwen Token Plan reasoning replay compat in built-in metadata", () => { - const providers = ["qwen-token-plan", "qwen-token-plan-cn"] as const; + const providers = ["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const; for (const provider of providers) { const model = getModel(provider, "qwen3.7-max")!; diff --git a/packages/ai/test/qwen-token-plan-models.test.ts b/packages/ai/test/qwen-token-plan-models.test.ts index 98c31cc4a57..17705705732 100644 --- a/packages/ai/test/qwen-token-plan-models.test.ts +++ b/packages/ai/test/qwen-token-plan-models.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { getModels, streamSimple } from "../src/compat.ts"; +import { findEnvKeys } from "../src/env-api-keys.ts"; vi.mock("openai", () => { class FakeOpenAI { @@ -56,6 +57,16 @@ const TEXT_MODELS = [ "qwen3.8-max", ]; +const INDIVIDUAL_TEXT_MODELS = [ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", +]; + const IMAGE_MODELS = ["qwen-image-2.0", "qwen-image-2.0-pro", "wan2.7-image", "wan2.7-image-pro"]; const QWEN_THINKING_MODELS = [ @@ -75,17 +86,43 @@ const QWEN_THINKING_MODELS = [ "qwen3.8-max", ] as const; -const QWEN_THINKING_MODEL_CASES = (["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => - QWEN_THINKING_MODELS.map((modelId) => ({ provider, modelId })), -); +type QwenTokenPlanProvider = "qwen-token-plan" | "qwen-token-plan-cn" | "qwen-token-plan-individual"; +type QwenTokenPlanModelCase = { provider: QwenTokenPlanProvider; modelId: string }; + +const QWEN_THINKING_MODEL_CASES: QwenTokenPlanModelCase[] = [ + ...(["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => + QWEN_THINKING_MODELS.map((modelId) => ({ provider, modelId })), + ), + ...INDIVIDUAL_TEXT_MODELS.map((modelId) => ({ provider: "qwen-token-plan-individual" as const, modelId })), +]; const QWEN_REASONING_EFFORT_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1", "glm-5.2"] as const; -const QWEN_REASONING_EFFORT_MODEL_CASES = (["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => - QWEN_REASONING_EFFORT_MODELS.map((modelId) => ({ provider, modelId })), -); +const QWEN_REASONING_EFFORT_MODEL_CASES: QwenTokenPlanModelCase[] = [ + ...(["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => + QWEN_REASONING_EFFORT_MODELS.map((modelId) => ({ provider, modelId })), + ), + ...["deepseek-v4-flash-0731", "deepseek-v4-pro", "glm-5.2"].map((modelId) => ({ + provider: "qwen-token-plan-individual" as const, + modelId, + })), +]; describe("Qwen Token Plan models", () => { + it("exposes exactly the documented Individual text models", () => { + const modelIds = getModels("qwen-token-plan-individual") + .map((model) => model.id) + .sort(); + + expect(modelIds).toEqual([...INDIVIDUAL_TEXT_MODELS].sort()); + }); + + it("reuses the international Token Plan environment variable", () => { + expect(findEnvKeys("qwen-token-plan-individual", { QWEN_TOKEN_PLAN_API_KEY: "test" })).toEqual([ + "QWEN_TOKEN_PLAN_API_KEY", + ]); + }); + it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)("exposes all text models on %s", (provider) => { const modelIds = getModels(provider).map((model) => model.id); for (const expected of TEXT_MODELS) { @@ -152,7 +189,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "exposes qwen3.8 reasoning_effort levels on %s", (provider) => { const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max"); @@ -170,7 +207,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "omits retired qwen3.8-max-preview on %s", (provider) => { const modelIds = getModels(provider).map((model) => model.id); @@ -210,7 +247,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "sends qwen3.8 max reasoning_effort on %s", async (provider) => { const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max"); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index f4e5b4890aa..cbfb5560638 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1224,6 +1224,37 @@ describe("Generate E2E Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)( + "Qwen Token Plan Individual Provider (Qwen3.8-Max, international)", + () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }, + ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Provider (Qwen3.7-Max, CN region)", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); const thinkingOptions = { diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 57930f01230..95a64c3d194 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -292,6 +292,14 @@ describe("Token Statistics on Abort", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index ec59c13cd73..57945ccadb9 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -270,6 +270,14 @@ describe("Tool Call Without Result Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider", () => { + const model = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider", () => { const model = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index f6785f2234e..ae4255e10de 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -605,6 +605,31 @@ describe("totalTokens field", () => { ); }); + // ========================================================================= + // Qwen Token Plan Individual + // ========================================================================= + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual", () => { + it( + "qwen3.8-max - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + console.log(`\nQwen Token Plan Individual / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.QWEN_TOKEN_PLAN_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + // ========================================================================= // Qwen Token Plan CN // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index a2b47ba2c6b..b9113c38182 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -728,6 +728,22 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Unicode Handling", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { + await testUnpairedHighSurrogate(llm); + }); + }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Unicode Handling", () => { const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index cac9dc202d6..e86f99ce34b 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -96,7 +96,8 @@ pi | Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` | | MiniMax | `MINIMAX_API_KEY` | `minimax` | | MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` | -| Qwen Token Plan | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` | +| Qwen Token Plan (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` | +| Qwen Token Plan (Individual) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan-individual` | | Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | `qwen-token-plan-cn` | | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | `xiaomi-token-plan-cn` | @@ -121,6 +122,7 @@ Store credentials in `~/.pi/agent/auth.json`: "opencode-go": { "type": "api_key", "key": "..." }, "together": { "type": "api_key", "key": "..." }, "qwen-token-plan": { "type": "api_key", "key": "sk-sp-..." }, + "qwen-token-plan-individual": { "type": "api_key", "key": "sk-sp-..." }, "qwen-token-plan-cn": { "type": "api_key", "key": "sk-sp-..." }, "xiaomi": { "type": "api_key", "key": "..." }, "xiaomi-token-plan-cn": { "type": "api_key", "key": "..." }, @@ -129,6 +131,11 @@ Store credentials in `~/.pi/agent/auth.json`: } ``` +`qwen-token-plan-individual` uses the same international endpoint and `QWEN_TOKEN_PLAN_API_KEY` as +`qwen-token-plan`, but limits the picker to the models documented for Individual subscriptions. The existing +provider keeps its broader catalog for backward compatibility. When using `auth.json`, store the +credential under the provider you select; an environment variable is shared by both international providers. + The file is created with `0600` permissions (user read/write only). Auth file credentials take priority over environment variables. API key credentials can also include provider-scoped environment values. These values are used before process environment variables when resolving the credential key, provider/model headers, and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`. diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 7a35558e9be..eb70a573b13 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -53,6 +53,7 @@ export const defaultModelPerProvider: Record = { "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", "qwen-token-plan": "qwen3.7-max", "qwen-token-plan-cn": "qwen3.7-max", + "qwen-token-plan-individual": "qwen3.8-max", xiaomi: "mimo-v2.5-pro", "xiaomi-token-plan-cn": "mimo-v2.5-pro", "xiaomi-token-plan-ams": "mimo-v2.5-pro", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 508c949ba08..f756172d4af 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -711,6 +711,10 @@ describe("default model selection", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("zai/glm-5.1"); }); + test("qwen token plan individual default tracks current model", () => { + expect(defaultModelPerProvider["qwen-token-plan-individual"]).toBe("qwen3.8-max"); + }); + test("findInitialModel accepts explicit provider custom model ids", async () => { const registry = { getModels: () => allModels, From 055ecce080ab38a4ecf4e6f61e9773b3f0469b1b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 18:10:42 +0200 Subject: [PATCH 010/284] feat(agent): add SQLite SQL template queries --- .../session-backends/sqlite-node/CHANGELOG.md | 4 + .../session-backends/sqlite-node/src/index.ts | 7 +- .../sqlite-node/src/sqlite/branch-cache.ts | 28 ++-- .../sqlite-node/src/sqlite/index.ts | 1 + .../sqlite-node/src/sqlite/migrations.ts | 12 +- .../sqlite-node/src/sqlite/repo.ts | 7 +- .../sqlite-node/src/sqlite/search-backend.ts | 62 ++++---- .../sqlite-node/src/sqlite/sql.ts | 62 ++++++++ .../src/sqlite/storage/branch-entries.ts | 134 ++++++++---------- .../src/sqlite/storage/branch-tips.ts | 25 ++-- .../sqlite-node/src/sqlite/storage/entries.ts | 48 +++---- .../sqlite-node/src/sqlite/storage/facts.ts | 60 +++----- .../sqlite-node/src/sqlite/storage/lanes.ts | 116 ++++++--------- .../sqlite-node/src/sqlite/storage/records.ts | 88 ++++-------- .../src/sqlite/storage/session-sequences.ts | 13 +- .../src/sqlite/storage/session-stats.ts | 41 +++--- .../src/sqlite/storage/sessions.ts | 82 +++++------ .../src/sqlite/storage/writer-leases.ts | 43 +++--- .../sqlite-node/test/sql.test.ts | 39 +++++ 19 files changed, 417 insertions(+), 455 deletions(-) create mode 100644 packages/session-backends/sqlite-node/src/sqlite/sql.ts create mode 100644 packages/session-backends/sqlite-node/test/sql.test.ts diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index e18c7330ad7..3260d5a1bb9 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added the composable, parameterized `sql` template tag for SQLite queries. + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/session-backends/sqlite-node/src/index.ts b/packages/session-backends/sqlite-node/src/index.ts index 98c7fdfa47e..83552d1cc2f 100644 --- a/packages/session-backends/sqlite-node/src/index.ts +++ b/packages/session-backends/sqlite-node/src/index.ts @@ -1,5 +1,6 @@ import type { SQLInputValue } from "node:sqlite"; import { DatabaseSync } from "node:sqlite"; +import { sql } from "./sqlite/sql.ts"; import type { SqliteDatabase, SqliteDatabaseFactory, SqliteRunResult, SqliteStatement } from "./sqlite/types.ts"; function isNamedParameters(value: unknown): value is Record { @@ -65,17 +66,17 @@ class NodeSqliteDatabase implements SqliteDatabase { } transaction(fn: () => T): T { - this.db.exec("BEGIN IMMEDIATE"); + sql`BEGIN IMMEDIATE`.exec(this); try { const result = fn(); if (isAsyncResult(result)) { throw new TypeError("SQLite transaction callbacks must be synchronous"); } - this.db.exec("COMMIT"); + sql`COMMIT`.exec(this); return result; } catch (error) { try { - this.db.exec("ROLLBACK"); + sql`ROLLBACK`.exec(this); } catch { // Ignore rollback errors to rethrow original error. } diff --git a/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts b/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts index 0e5024cb8d3..eaef85f5877 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts @@ -1,5 +1,6 @@ import { SessionError } from "@earendil-works/pi-agent-core"; import { uuidv7 } from "@earendil-works/pi-ai"; +import { sql } from "./sql.ts"; import { copyBranchEntriesThroughSeq, deleteBranchEntries, @@ -7,7 +8,6 @@ import { insertBranchEntry, readBranchContainingEntry, } from "./storage/branch-entries.ts"; - import { deleteBranchTips, insertBranchTip, readBranchTipBranchId, updateBranchTip } from "./storage/branch-tips.ts"; import type { SqliteDatabase } from "./types.ts"; @@ -17,32 +17,28 @@ export function deleteBranchCache(db: SqliteDatabase, sessionId: string) { } export function rebuildBranchCache(db: SqliteDatabase, sessionId: string) { - const tips = db - .prepare( - `SELECT leaf.id - FROM entries AS leaf - WHERE leaf.session_id = ? - AND NOT EXISTS ( - SELECT 1 FROM entries AS child WHERE child.session_id = leaf.session_id AND child.parent_id = leaf.id - ) - ORDER BY leaf.seq`, - ) - .all<{ id: string }>(sessionId); + const tips = sql`SELECT leaf.id + FROM entries AS leaf + WHERE leaf.session_id = ${sessionId} + AND NOT EXISTS ( + SELECT 1 FROM entries AS child WHERE child.session_id = leaf.session_id AND child.parent_id = leaf.id + ) + ORDER BY leaf.seq`.all<{ id: string }>(db); deleteBranchCache(db, sessionId); for (const tip of tips) buildCachedBranch(db, sessionId, tip.id); } export function buildCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { - db.exec("SAVEPOINT build_branch_cache"); + sql`SAVEPOINT build_branch_cache`.exec(db); try { const branchId = uuidv7(); insertBranchEntriesForPath(db, sessionId, branchId, leafId); insertBranchTip(db, sessionId, leafId, branchId); - db.exec("RELEASE SAVEPOINT build_branch_cache"); + sql`RELEASE SAVEPOINT build_branch_cache`.exec(db); } catch (error) { try { - db.exec("ROLLBACK TO SAVEPOINT build_branch_cache"); - db.exec("RELEASE SAVEPOINT build_branch_cache"); + sql`ROLLBACK TO SAVEPOINT build_branch_cache`.exec(db); + sql`RELEASE SAVEPOINT build_branch_cache`.exec(db); } catch { // Preserve the original build failure. } diff --git a/packages/session-backends/sqlite-node/src/sqlite/index.ts b/packages/session-backends/sqlite-node/src/sqlite/index.ts index 738f086a1f9..73f6a62dd4a 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/index.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/index.ts @@ -5,6 +5,7 @@ export { type SqliteWriterLeaseOptions, } from "./repo.ts"; export * from "./search-backend.ts"; +export * from "./sql.ts"; export type { SqliteDatabase, SqliteDatabaseFactory, diff --git a/packages/session-backends/sqlite-node/src/sqlite/migrations.ts b/packages/session-backends/sqlite-node/src/sqlite/migrations.ts index a0debe3f061..e171f4a0196 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/migrations.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/migrations.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; +import { sql } from "./sql.ts"; import type { SqliteDatabase } from "./types.ts"; export interface SqliteMigration { @@ -23,28 +24,25 @@ export async function loadMigrations(): Promise { } function ensureMigrationsTable(db: SqliteDatabase): void { - db.exec(` + sql` CREATE TABLE IF NOT EXISTS migrations ( id TEXT PRIMARY KEY, applied_at TEXT NOT NULL ); -`); +`.exec(db); } export async function applyMigrations(db: SqliteDatabase): Promise { ensureMigrationsTable(db); const migrations = await loadMigrations(); - const appliedRows = db.prepare("SELECT id FROM migrations ORDER BY applied_at, id").all<{ id: string }>(); + const appliedRows = sql`SELECT id FROM migrations ORDER BY applied_at, id`.all<{ id: string }>(db); const applied = new Set(appliedRows.map((row) => row.id)); for (const migration of migrations) { if (applied.has(migration.id)) continue; db.transaction(() => { db.exec(migration.sql); - db.prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)").run( - migration.id, - new Date().toISOString(), - ); + sql`INSERT INTO migrations (id, applied_at) VALUES (${migration.id}, ${new Date().toISOString()})`.run(db); }); applied.add(migration.id); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/repo.ts b/packages/session-backends/sqlite-node/src/sqlite/repo.ts index d75c7a5abe1..8eb8ba96ca0 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/repo.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/repo.ts @@ -20,6 +20,7 @@ import { import { uuidv7 } from "@earendil-works/pi-ai"; import { appendEntryToBranchCache, buildCachedBranch, deleteBranchCache, rebuildBranchCache } from "./branch-cache.ts"; import { applyMigrations } from "./migrations.ts"; +import { sql } from "./sql.ts"; import { type CachedBranchEntryRow, queryCachedBranchRows, readCachedBranch } from "./storage/branch-entries.ts"; import { readBranchTipIds } from "./storage/branch-tips.ts"; import { @@ -169,9 +170,9 @@ function getParentPath(path: string): string { } function configureSqliteDatabase(db: SqliteDatabase): void { - db.exec("PRAGMA journal_mode=WAL"); - db.exec("PRAGMA synchronous=FULL"); - db.exec("PRAGMA busy_timeout=5000"); + sql`PRAGMA journal_mode=WAL`.exec(db); + sql`PRAGMA synchronous=FULL`.exec(db); + sql`PRAGMA busy_timeout=5000`.exec(db); } function timestampToText(timestamp: number): string { diff --git a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts index 11f309f196a..e22de3340a8 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts @@ -1,6 +1,7 @@ import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "@earendil-works/pi-agent-core"; import { getFileSystemResultOrThrow } from "@earendil-works/pi-agent-core"; import { applyMigrations } from "./migrations.ts"; +import { sql } from "./sql.ts"; import { decodeSessionMetadata, type SessionRow } from "./storage/sessions.ts"; import type { SqliteDatabase, @@ -18,9 +19,9 @@ function getParentPath(path: string): string { } function configureSqliteDatabase(db: SqliteDatabase): void { - db.exec("PRAGMA journal_mode=WAL"); - db.exec("PRAGMA synchronous=FULL"); - db.exec("PRAGMA busy_timeout=5000"); + sql`PRAGMA journal_mode=WAL`.exec(db); + sql`PRAGMA synchronous=FULL`.exec(db); + sql`PRAGMA busy_timeout=5000`.exec(db); } export interface SqliteSessionSearchOptions { @@ -30,14 +31,14 @@ export interface SqliteSessionSearchOptions { } function tableExists(db: SqliteDatabase, name: string): boolean { - return !!db - .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1") - .get<{ found: number }>(name); + return !!sql`SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ${name} LIMIT 1`.get<{ + found: number; + }>(db); } function ensureSearchSchema(db: SqliteDatabase): void { const ftsExists = tableExists(db, "session_search_fts"); - db.exec(` + sql` CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5( payload, content = 'entries', @@ -54,8 +55,8 @@ CREATE TRIGGER IF NOT EXISTS session_search_fts_au AFTER UPDATE OF payload ON en INSERT INTO session_search_fts(session_search_fts, rowid, payload) VALUES('delete', old.rowid, old.payload); INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); END; -`); - if (!ftsExists) db.exec("INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')"); +`.exec(db); + if (!ftsExists) sql`INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')`.exec(db); } /** SQLite FTS search over a co-located canonical session database. */ @@ -102,32 +103,25 @@ class SqliteSessionSearch implements SessionSearch { const db = await this.openDatabase(); try { const query = `"${text.replaceAll('"', '""')}"`; - const rows = db - .prepare( - `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name, - se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score - FROM session_search_fts - JOIN entries AS se ON se.rowid = session_search_fts.rowid - JOIN sessions AS s ON s.id = se.session_id - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL - ) - WHERE session_search_fts MATCH ? AND (? IS NULL OR s.cwd = ?) - ORDER BY score`, + const cwd = options.cwd ?? null; + const rows = sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, + name_fact.seq IS NOT NULL AS has_session_name, + name_fact.value AS session_name, + se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score + FROM session_search_fts + JOIN entries AS se ON se.rowid = session_search_fts.rowid + JOIN sessions AS s ON s.id = se.session_id + LEFT JOIN facts AS name_fact + ON name_fact.session_id = s.id + AND name_fact.kind = 'name' + AND name_fact.key IS NULL + AND name_fact.seq = ( + SELECT MAX(f.seq) + FROM facts AS f + WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL ) - .all( - query, - options.cwd ?? null, - options.cwd ?? null, - ); + WHERE session_search_fts MATCH ${query} AND (${cwd} IS NULL OR s.cwd = ${cwd}) + ORDER BY score`.all(db); const path = await this.getDatabasePath(); return rows.map((row) => ({ metadata: decodeSessionMetadata(row, path), diff --git a/packages/session-backends/sqlite-node/src/sqlite/sql.ts b/packages/session-backends/sqlite-node/src/sqlite/sql.ts new file mode 100644 index 00000000000..4eaec7f44db --- /dev/null +++ b/packages/session-backends/sqlite-node/src/sqlite/sql.ts @@ -0,0 +1,62 @@ +import type { SqliteDatabase, SqliteRunResult } from "./types.ts"; + +type SqlTemplateValue = unknown | SqlQuery; + +/** A parameterized SQLite query produced by {@link sql}. */ +export class SqlQuery { + readonly queryText: string; + readonly params: readonly unknown[]; + + constructor(queryText: string, params: readonly unknown[] = []) { + this.queryText = queryText; + this.params = params; + } + + exec(db: SqliteDatabase): void { + if (this.params.length > 0) throw new TypeError("SQLite exec queries cannot have parameters"); + db.exec(this.queryText); + } + + run(db: SqliteDatabase): SqliteRunResult { + return db.prepare(this.queryText).run(...this.params); + } + + get(db: SqliteDatabase): TRow | undefined { + return db.prepare(this.queryText).get(...this.params); + } + + all(db: SqliteDatabase): TRow[] { + return db.prepare(this.queryText).all(...this.params); + } +} + +/** Builds a parameterized query. Nested queries are inlined; other interpolations become `?` parameters. */ +export function sql(strings: TemplateStringsArray, ...values: SqlTemplateValue[]): SqlQuery { + let queryText = strings[0] ?? ""; + const params: unknown[] = []; + for (let index = 0; index < values.length; index++) { + const value = values[index]; + if (value instanceof SqlQuery) { + queryText += value.queryText; + params.push(...value.params); + } else { + queryText += "?"; + params.push(value); + } + queryText += strings[index + 1] ?? ""; + } + return new SqlQuery(queryText, params); +} + +/** Joins trusted query fragments while preserving their parameter order. */ +export function joinSqlFragments(fragments: readonly SqlQuery[], separator: string): SqlQuery { + let queryText = ""; + const params: unknown[] = []; + for (let index = 0; index < fragments.length; index++) { + if (index > 0) queryText += separator; + const fragment = fragments[index]!; + queryText += fragment.queryText; + params.push(...fragment.params); + } + return new SqlQuery(queryText, params); +} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts index 9b604976e67..caaf47fd6e7 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts @@ -1,4 +1,5 @@ import type { Entry } from "@earendil-works/pi-agent-core"; +import { joinSqlFragments, sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; /** Derived root-to-tip branch cache membership. Canonical parent links remain in entries. */ @@ -24,11 +25,11 @@ export interface CachedBranchQuery { } export function readCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { - const membership = db - .prepare( - "SELECT branch_id, entry_seq FROM branch_entries WHERE session_id = ? AND entry_id = ? ORDER BY branch_id LIMIT 1", - ) - .get<{ branch_id: string; entry_seq: number }>(sessionId, leafId); + const membership = sql`SELECT branch_id, entry_seq + FROM branch_entries + WHERE session_id = ${sessionId} AND entry_id = ${leafId} + ORDER BY branch_id + LIMIT 1`.get<{ branch_id: string; entry_seq: number }>(db); if (!membership) return undefined; return { branchId: membership.branch_id, leafSeq: membership.entry_seq }; } @@ -40,46 +41,45 @@ export function queryCachedBranchRows( query: CachedBranchQuery, ) { const oldestFirst = query.order === "oldestFirst"; - const boundaryParams: unknown[] = [sessionId, branch.branchId, branch.leafSeq]; - const stopPredicates: string[] = []; - if (query.stopAtType !== undefined) { - stopPredicates.push("stop_entry.type = ?"); - boundaryParams.push(query.stopAtType); - } - if (query.stopAtId !== undefined) { - stopPredicates.push("stop.entry_id = ?"); - boundaryParams.push(query.stopAtId); - } + const stopPredicates = []; + if (query.stopAtType !== undefined) stopPredicates.push(sql`stop_entry.type = ${query.stopAtType}`); + if (query.stopAtId !== undefined) stopPredicates.push(sql`stop.entry_id = ${query.stopAtId}`); - const boundary = stopPredicates.length - ? `WITH boundary AS ( - SELECT ${oldestFirst ? "MIN" : "MAX"}(stop.entry_seq) AS entry_seq - FROM branch_entries AS stop - JOIN entries AS stop_entry - ON stop_entry.session_id = stop.session_id AND stop_entry.id = stop.entry_id - WHERE stop.session_id = ? AND stop.branch_id = ? AND stop.entry_seq <= ? - AND (${stopPredicates.join(" OR ")}) - )` - : ""; - const range = stopPredicates.length - ? `AND b.entry_seq ${oldestFirst ? "<=" : ">="} COALESCE( - (SELECT entry_seq FROM boundary), ${oldestFirst ? branch.leafSeq : 0} - )` - : ""; - const sql = `${boundary} + const aggregate = oldestFirst ? sql`MIN` : sql`MAX`; + const comparison = oldestFirst ? sql`<=` : sql`>=`; + const direction = oldestFirst ? sql`ASC` : sql`DESC`; + const boundary = + stopPredicates.length === 0 + ? sql`` + : sql`WITH boundary AS ( + SELECT ${aggregate}(stop.entry_seq) AS entry_seq + FROM branch_entries AS stop + JOIN entries AS stop_entry + ON stop_entry.session_id = stop.session_id AND stop_entry.id = stop.entry_id + WHERE stop.session_id = ${sessionId} + AND stop.branch_id = ${branch.branchId} + AND stop.entry_seq <= ${branch.leafSeq} + AND (${joinSqlFragments(stopPredicates, " OR ")}) + )`; + const range = + stopPredicates.length === 0 + ? sql`` + : sql`AND b.entry_seq ${comparison} COALESCE( + (SELECT entry_seq FROM boundary), ${oldestFirst ? branch.leafSeq : 0} + )`; + return sql`${boundary} SELECT e.session_id, e.id, e.seq AS entry_seq, e.parent_id, e.type, e.timestamp, e.payload FROM branch_entries AS b JOIN entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id - WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq <= ? + WHERE b.session_id = ${sessionId} + AND b.branch_id = ${branch.branchId} + AND b.entry_seq <= ${branch.leafSeq} ${range} - ORDER BY b.entry_seq ${oldestFirst ? "ASC" : "DESC"}`; - - const params = [...(stopPredicates.length === 0 ? [] : boundaryParams), sessionId, branch.branchId, branch.leafSeq]; - return db.prepare(sql).all(...params); + ORDER BY b.entry_seq ${direction}`.all(db); } export function deleteBranchEntries(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run(sessionId); + sql`DELETE FROM branch_entries WHERE session_id = ${sessionId}`.run(db); } export function insertBranchEntry( @@ -91,42 +91,34 @@ export function insertBranchEntry( entryType: string, customType: string | null, ) { - db.prepare( - `INSERT INTO branch_entries + sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run(sessionId, branchId, entryId, entrySeq, entryType, customType); + VALUES (${sessionId}, ${branchId}, ${entryId}, ${entrySeq}, ${entryType}, ${customType})`.run(db); } export function insertBranchEntriesForPath(db: SqliteDatabase, sessionId: string, branchId: string, leafId: string) { - db.prepare( - `WITH RECURSIVE path(id, entry_seq, parent_id, type, custom_type) AS ( - SELECT id, seq, parent_id, type, - CASE WHEN type = 'custom' THEN json_extract(payload, '$.customType') ELSE NULL END - FROM entries - WHERE session_id = ? AND id = ? - UNION ALL - SELECT parent.id, parent.seq, parent.parent_id, parent.type, - CASE WHEN parent.type = 'custom' THEN json_extract(parent.payload, '$.customType') ELSE NULL END - FROM entries AS parent - JOIN path AS child ON child.parent_id = parent.id - WHERE parent.session_id = ? - ) - INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - SELECT ?, ?, id, entry_seq, type, custom_type FROM path`, - ).run(sessionId, leafId, sessionId, sessionId, branchId); + sql`WITH RECURSIVE path(id, entry_seq, parent_id, type, custom_type) AS ( + SELECT id, seq, parent_id, type, + CASE WHEN type = 'custom' THEN json_extract(payload, '$.customType') ELSE NULL END + FROM entries + WHERE session_id = ${sessionId} AND id = ${leafId} + UNION ALL + SELECT parent.id, parent.seq, parent.parent_id, parent.type, + CASE WHEN parent.type = 'custom' THEN json_extract(parent.payload, '$.customType') ELSE NULL END + FROM entries AS parent + JOIN path AS child ON child.parent_id = parent.id + WHERE parent.session_id = ${sessionId} + ) + INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) + SELECT ${sessionId}, ${branchId}, id, entry_seq, type, custom_type FROM path`.run(db); } export function readBranchContainingEntry(db: SqliteDatabase, sessionId: string, entryId: string) { - const row = db - .prepare( - `SELECT b.branch_id, b.entry_seq - FROM branch_entries AS b - WHERE b.session_id = ? AND b.entry_id = ? - ORDER BY b.branch_id - LIMIT 1`, - ) - .get<{ branch_id: string; entry_seq: number }>(sessionId, entryId); + const row = sql`SELECT b.branch_id, b.entry_seq + FROM branch_entries AS b + WHERE b.session_id = ${sessionId} AND b.entry_id = ${entryId} + ORDER BY b.branch_id + LIMIT 1`.get<{ branch_id: string; entry_seq: number }>(db); return row === undefined ? undefined : { branchId: row.branch_id, entrySeq: row.entry_seq }; } @@ -137,10 +129,8 @@ export function copyBranchEntriesThroughSeq( sourceBranchId: string, throughSeq: number, ) { - db.prepare( - `INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - SELECT session_id, ?, entry_id, entry_seq, entry_type, custom_type - FROM branch_entries - WHERE session_id = ? AND branch_id = ? AND entry_seq <= ?`, - ).run(targetBranchId, sessionId, sourceBranchId, throughSeq); + sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) + SELECT session_id, ${targetBranchId}, entry_id, entry_seq, entry_type, custom_type + FROM branch_entries + WHERE session_id = ${sessionId} AND branch_id = ${sourceBranchId} AND entry_seq <= ${throughSeq}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts index e4dffc48855..da593e93aad 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts @@ -1,25 +1,21 @@ +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export function readBranchTipIds(db: SqliteDatabase, sessionId: string) { - return db - .prepare("SELECT tip_id FROM branch_tips WHERE session_id = ? ORDER BY tip_id") - .all<{ tip_id: string }>(sessionId) + return sql`SELECT tip_id FROM branch_tips WHERE session_id = ${sessionId} ORDER BY tip_id` + .all<{ tip_id: string }>(db) .map((row) => row.tip_id); } export function readBranchTipBranchId(db: SqliteDatabase, sessionId: string, tipId: string) { - const tip = db - .prepare("SELECT branch_id FROM branch_tips WHERE session_id = ? AND tip_id = ?") - .get<{ branch_id: string }>(sessionId, tipId); + const tip = sql`SELECT branch_id FROM branch_tips WHERE session_id = ${sessionId} AND tip_id = ${tipId}`.get<{ + branch_id: string; + }>(db); return tip?.branch_id; } export function insertBranchTip(db: SqliteDatabase, sessionId: string, tipId: string, branchId: string) { - db.prepare("INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (?, ?, ?)").run( - sessionId, - tipId, - branchId, - ); + sql`INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (${sessionId}, ${tipId}, ${branchId})`.run(db); } export function updateBranchTip( @@ -29,12 +25,11 @@ export function updateBranchTip( oldTipId: string, newTipId: string, ) { - const result = db - .prepare("UPDATE branch_tips SET tip_id = ? WHERE session_id = ? AND branch_id = ? AND tip_id = ?") - .run(newTipId, sessionId, branchId, oldTipId); + const result = sql`UPDATE branch_tips SET tip_id = ${newTipId} + WHERE session_id = ${sessionId} AND branch_id = ${branchId} AND tip_id = ${oldTipId}`.run(db); return result.changes === 1; } export function deleteBranchTips(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run(sessionId); + sql`DELETE FROM branch_tips WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts index 76063c29e36..9086f61dd35 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts @@ -1,4 +1,5 @@ import type { Entry, EntryOrder } from "@earendil-works/pi-agent-core"; +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface EntryRow { @@ -25,22 +26,17 @@ export function entryPayload(entry: Entry): Record { return payload; } -function orderedSql(order: EntryOrder | undefined): string { - return order === "oldestFirst" ? "ASC" : "DESC"; -} - export function insertEntryRow(db: SqliteDatabase, sessionId: string, entry: NewEntryRow) { - db.prepare( - "INSERT INTO entries (session_id, id, seq, parent_id, type, timestamp, payload) VALUES (?, ?, ?, ?, ?, ?, ?)", - ).run(sessionId, entry.id, entry.seq, entry.parentId, entry.type, entry.timestamp, entry.payload); + sql`INSERT INTO entries (session_id, id, seq, parent_id, type, timestamp, payload) + VALUES (${sessionId}, ${entry.id}, ${entry.seq}, ${entry.parentId}, ${entry.type}, ${entry.timestamp}, ${entry.payload})`.run( + db, + ); } export function readEntryRow(db: SqliteDatabase, sessionId: string, entryId: string) { - return db - .prepare( - "SELECT session_id, seq, id, parent_id, type, timestamp, payload FROM entries WHERE session_id = ? AND id = ?", - ) - .get(sessionId, entryId); + return sql`SELECT session_id, seq, id, parent_id, type, timestamp, payload + FROM entries + WHERE session_id = ${sessionId} AND id = ${entryId}`.get(db); } export function readEntryRows( @@ -48,28 +44,20 @@ export function readEntryRows( sessionId: string, options: { afterSeq?: number; order?: EntryOrder } = {}, ) { - const predicates = ["session_id = ?"]; - const params: unknown[] = [sessionId]; - if (options.afterSeq !== undefined) { - predicates.push("seq > ?"); - params.push(options.afterSeq); - } - return db - .prepare( - `SELECT session_id, seq, id, parent_id, type, timestamp, payload - FROM entries - WHERE ${predicates.join(" AND ")} - ORDER BY seq ${orderedSql(options.order)}`, - ) - .all(...params); + const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; + const direction = options.order === "oldestFirst" ? sql`ASC` : sql`DESC`; + return sql`SELECT session_id, seq, id, parent_id, type, timestamp, payload + FROM entries + WHERE session_id = ${sessionId}${after} + ORDER BY seq ${direction}`.all(db); } export function idExistsInEntries(db: SqliteDatabase, sessionId: string, id: string) { - return !!db - .prepare("SELECT 1 AS found FROM entries WHERE session_id = ? AND id = ? LIMIT 1") - .get<{ found: number }>(sessionId, id); + return !!sql`SELECT 1 AS found FROM entries WHERE session_id = ${sessionId} AND id = ${id} LIMIT 1`.get<{ + found: number; + }>(db); } export function deleteEntryRows(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM entries WHERE session_id = ?").run(sessionId); + sql`DELETE FROM entries WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts index 50d056f4741..12031caddf8 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts @@ -1,3 +1,4 @@ +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface FactRow { @@ -16,58 +17,37 @@ export function appendFact( key: string | null, value: string | null, ) { - db.prepare("INSERT INTO facts (session_id, seq, kind, key, value) VALUES (?, ?, ?, ?, ?)").run( - sessionId, - seq, - kind, - key, - value, + sql`INSERT INTO facts (session_id, seq, kind, key, value) VALUES (${sessionId}, ${seq}, ${kind}, ${key}, ${value})`.run( + db, ); } export function readLatestFact(db: SqliteDatabase, sessionId: string, kind: string, key: string | null) { - return db - .prepare( - `SELECT session_id, seq, kind, key, value - FROM facts - WHERE session_id = ? AND kind = ? AND key IS ? - ORDER BY seq DESC - LIMIT 1`, - ) - .get(sessionId, kind, key); + return sql`SELECT session_id, seq, kind, key, value + FROM facts + WHERE session_id = ${sessionId} AND kind = ${kind} AND key IS ${key} + ORDER BY seq DESC + LIMIT 1`.get(db); } export function readLatestLabelFacts(db: SqliteDatabase, sessionId: string) { - return db - .prepare( - `SELECT key, value FROM ( - SELECT key, value, ROW_NUMBER() OVER (PARTITION BY key ORDER BY seq DESC) AS rank - FROM facts - WHERE session_id = ? AND kind = 'label' - ) - WHERE rank = 1 AND value IS NOT NULL - ORDER BY key`, + return sql`SELECT key, value FROM ( + SELECT key, value, ROW_NUMBER() OVER (PARTITION BY key ORDER BY seq DESC) AS rank + FROM facts + WHERE session_id = ${sessionId} AND kind = 'label' ) - .all<{ key: string; value: string }>(sessionId); + WHERE rank = 1 AND value IS NOT NULL + ORDER BY key`.all<{ key: string; value: string }>(db); } export function readFactRows(db: SqliteDatabase, sessionId: string, options: { afterSeq?: number } = {}) { - const predicates = ["session_id = ?"]; - const params: unknown[] = [sessionId]; - if (options.afterSeq !== undefined) { - predicates.push("seq > ?"); - params.push(options.afterSeq); - } - return db - .prepare( - `SELECT session_id, seq, kind, key, value - FROM facts - WHERE ${predicates.join(" AND ")} - ORDER BY seq`, - ) - .all(...params); + const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; + return sql`SELECT session_id, seq, kind, key, value + FROM facts + WHERE session_id = ${sessionId}${after} + ORDER BY seq`.all(db); } export function deleteFactRows(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM facts WHERE session_id = ?").run(sessionId); + sql`DELETE FROM facts WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts index d6f54a3a585..fc040b934f7 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts @@ -1,4 +1,5 @@ import { SessionError } from "@earendil-works/pi-agent-core"; +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface LaneRow { @@ -16,29 +17,22 @@ export interface LaneMoveRow { } export function createInitialLane(db: SqliteDatabase, sessionId: string, lane = "main", leafId: string | null = null) { - db.prepare("INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) VALUES (?, ?, ?, NULL)").run( - sessionId, - lane, - leafId, - ); + sql`INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) + VALUES (${sessionId}, ${lane}, ${leafId}, NULL)`.run(db); } export function readLanes(db: SqliteDatabase, sessionId: string) { - const rows = db - .prepare( - `SELECT - l.session_id, - l.lane, - l.leaf_id, - l.open_operation_id, - (l.leaf_id IS NULL OR EXISTS ( - SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id - )) AS leaf_exists - FROM lanes AS l - WHERE l.session_id = ? - ORDER BY l.lane`, - ) - .all(sessionId); + const rows = sql`SELECT + l.session_id, + l.lane, + l.leaf_id, + l.open_operation_id, + (l.leaf_id IS NULL OR EXISTS ( + SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id + )) AS leaf_exists + FROM lanes AS l + WHERE l.session_id = ${sessionId} + ORDER BY l.lane`.all(db); for (const row of rows) { if (row.leaf_exists === 0) { throw new SessionError("storage", `Lane ${row.lane} points at missing entry ${row.leaf_id}`); @@ -53,56 +47,47 @@ export function readLanes(db: SqliteDatabase, sessionId: string) { } export function readLane(db: SqliteDatabase, sessionId: string, lane: string) { - return db - .prepare("SELECT session_id, lane, leaf_id, open_operation_id FROM lanes WHERE session_id = ? AND lane = ?") - .get(sessionId, lane); + return sql`SELECT session_id, lane, leaf_id, open_operation_id + FROM lanes + WHERE session_id = ${sessionId} AND lane = ${lane}`.get(db); } export function readLaneHead(db: SqliteDatabase, sessionId: string, lane: string) { - const row = db - .prepare( - `SELECT - l.leaf_id, - (l.leaf_id IS NULL OR EXISTS ( - SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id - )) AS leaf_exists - FROM lanes AS l - WHERE l.session_id = ? AND l.lane = ?`, - ) - .get<{ leaf_id: string | null; leaf_exists: number }>(sessionId, lane); + const row = sql`SELECT + l.leaf_id, + (l.leaf_id IS NULL OR EXISTS ( + SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id + )) AS leaf_exists + FROM lanes AS l + WHERE l.session_id = ${sessionId} AND l.lane = ${lane}`.get<{ + leaf_id: string | null; + leaf_exists: number; + }>(db); if (!row) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); if (row.leaf_exists === 0) throw new SessionError("storage", `Entry ${row.leaf_id} not found`); return { leafId: row.leaf_id }; } export function createLane(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - db.prepare("INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) VALUES (?, ?, ?, NULL)").run( - sessionId, - lane, - leafId, - ); + sql`INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) + VALUES (${sessionId}, ${lane}, ${leafId}, NULL)`.run(db); appendLaneMove(db, sessionId, seq, lane, leafId); } export function moveLane(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - const result = db - .prepare("UPDATE lanes SET leaf_id = ? WHERE session_id = ? AND lane = ?") - .run(leafId, sessionId, lane); + const result = sql`UPDATE lanes SET leaf_id = ${leafId} WHERE session_id = ${sessionId} AND lane = ${lane}`.run(db); if (result.changes !== 1) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); appendLaneMove(db, sessionId, seq, lane, leafId); } export function setLaneLeaf(db: SqliteDatabase, sessionId: string, lane: string, leafId: string | null) { - const result = db - .prepare("UPDATE lanes SET leaf_id = ? WHERE session_id = ? AND lane = ?") - .run(leafId, sessionId, lane); + const result = sql`UPDATE lanes SET leaf_id = ${leafId} WHERE session_id = ${sessionId} AND lane = ${lane}`.run(db); if (result.changes !== 1) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); } export function startLaneOperation(db: SqliteDatabase, sessionId: string, lane: string, runId: string) { - const result = db - .prepare("UPDATE lanes SET open_operation_id = ? WHERE session_id = ? AND lane = ? AND open_operation_id IS NULL") - .run(runId, sessionId, lane); + const result = sql`UPDATE lanes SET open_operation_id = ${runId} + WHERE session_id = ${sessionId} AND lane = ${lane} AND open_operation_id IS NULL`.run(db); if (result.changes === 1) return; const current = readLane(db, sessionId, lane); if (!current) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); @@ -110,38 +95,25 @@ export function startLaneOperation(db: SqliteDatabase, sessionId: string, lane: } export function finishLaneOperation(db: SqliteDatabase, sessionId: string, lane: string, runId: string) { - db.prepare( - "UPDATE lanes SET open_operation_id = NULL WHERE session_id = ? AND lane = ? AND open_operation_id = ?", - ).run(sessionId, lane, runId); + sql`UPDATE lanes SET open_operation_id = NULL + WHERE session_id = ${sessionId} AND lane = ${lane} AND open_operation_id = ${runId}`.run(db); } export function readLaneMoveRows(db: SqliteDatabase, sessionId: string, options: { afterSeq?: number } = {}) { - const predicates = ["session_id = ?"]; - const params: unknown[] = [sessionId]; - if (options.afterSeq !== undefined) { - predicates.push("seq > ?"); - params.push(options.afterSeq); - } - return db - .prepare( - `SELECT session_id, seq, lane, leaf_id - FROM lane_moves - WHERE ${predicates.join(" AND ")} - ORDER BY seq`, - ) - .all(...params); + const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; + return sql`SELECT session_id, seq, lane, leaf_id + FROM lane_moves + WHERE session_id = ${sessionId}${after} + ORDER BY seq`.all(db); } export function deleteLaneRows(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM lane_moves WHERE session_id = ?").run(sessionId); - db.prepare("DELETE FROM lanes WHERE session_id = ?").run(sessionId); + sql`DELETE FROM lane_moves WHERE session_id = ${sessionId}`.run(db); + sql`DELETE FROM lanes WHERE session_id = ${sessionId}`.run(db); } function appendLaneMove(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - db.prepare("INSERT INTO lane_moves (session_id, seq, lane, leaf_id) VALUES (?, ?, ?, ?)").run( - sessionId, - seq, - lane, - leafId, + sql`INSERT INTO lane_moves (session_id, seq, lane, leaf_id) VALUES (${sessionId}, ${seq}, ${lane}, ${leafId})`.run( + db, ); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts index c68276877ce..1dc35722754 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts @@ -1,4 +1,5 @@ import { SessionError } from "@earendil-works/pi-agent-core"; +import { joinSqlFragments, sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface RecordRow { @@ -25,31 +26,21 @@ export interface NewRecordRow { } export function appendRecordRow(db: SqliteDatabase, sessionId: string, record: NewRecordRow) { - db.prepare( - `INSERT INTO records + sql`INSERT INTO records (session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - sessionId, - record.seq, - record.id, - record.lane, - record.runId ?? null, - record.type, - record.opKind ?? null, - record.timestamp, - record.payload, + VALUES (${sessionId}, ${record.seq}, ${record.id}, ${record.lane}, ${record.runId ?? null}, ${record.type}, ${record.opKind ?? null}, ${record.timestamp}, ${record.payload})`.run( + db, ); } export function idExistsInRecords(db: SqliteDatabase, sessionId: string, id: string) { - return !!db - .prepare("SELECT 1 AS found FROM records WHERE session_id = ? AND id = ? LIMIT 1") - .get<{ found: number }>(sessionId, id); + return !!sql`SELECT 1 AS found FROM records WHERE session_id = ${sessionId} AND id = ${id} LIMIT 1`.get<{ + found: number; + }>(db); } export function deleteRecordRows(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM records WHERE session_id = ?").run(sessionId); + sql`DELETE FROM records WHERE session_id = ${sessionId}`.run(db); } export function readRecordRows( @@ -65,39 +56,18 @@ export function readRecordRows( limit?: number; } = {}, ) { - const predicates = ["session_id = ?"]; - const params: unknown[] = [sessionId]; - if (query.lane !== undefined) { - predicates.push("lane = ?"); - params.push(query.lane); - } - if (query.type !== undefined) { - predicates.push("type = ?"); - params.push(query.type); - } - if (query.runId !== undefined) { - predicates.push("run_id = ?"); - params.push(query.runId); - } - if (query.operationKind !== undefined) { - predicates.push("op_kind = ?"); - params.push(query.operationKind); - } - if (query.afterSeq !== undefined) { - predicates.push("seq > ?"); - params.push(query.afterSeq); - } - const limit = query.limit === undefined ? "" : " LIMIT ?"; - if (query.limit !== undefined) params.push(query.limit); - const direction = query.order === "oldestFirst" ? "ASC" : "DESC"; - return db - .prepare( - `SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload - FROM records - WHERE ${predicates.join(" AND ")} - ORDER BY seq ${direction}${limit}`, - ) - .all(...params); + const predicates = [sql`session_id = ${sessionId}`]; + if (query.lane !== undefined) predicates.push(sql`lane = ${query.lane}`); + if (query.type !== undefined) predicates.push(sql`type = ${query.type}`); + if (query.runId !== undefined) predicates.push(sql`run_id = ${query.runId}`); + if (query.operationKind !== undefined) predicates.push(sql`op_kind = ${query.operationKind}`); + if (query.afterSeq !== undefined) predicates.push(sql`seq > ${query.afterSeq}`); + const direction = query.order === "oldestFirst" ? sql`ASC` : sql`DESC`; + const limit = query.limit === undefined ? sql`` : sql` LIMIT ${query.limit}`; + return sql`SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload + FROM records + WHERE ${joinSqlFragments(predicates, " AND ")} + ORDER BY seq ${direction}${limit}`.all(db); } export function readOpenOperationRows( @@ -106,19 +76,15 @@ export function readOpenOperationRows( lane: string, _options: { limit?: number } = {}, ): RecordRow[] { - const laneRow = db - .prepare("SELECT open_operation_id FROM lanes WHERE session_id = ? AND lane = ?") - .get<{ open_operation_id: string | null }>(sessionId, lane); + const laneRow = sql`SELECT open_operation_id FROM lanes WHERE session_id = ${sessionId} AND lane = ${lane}`.get<{ + open_operation_id: string | null; + }>(db); if (!laneRow?.open_operation_id) return []; - const record = db - .prepare( - `SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload - FROM records - WHERE session_id = ? - AND id = ?`, - ) - .get(sessionId, laneRow.open_operation_id); + const record = sql`SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload + FROM records + WHERE session_id = ${sessionId} + AND id = ${laneRow.open_operation_id}`.get(db); if (!record) { throw new SessionError("storage", `Lane ${lane} points at missing open operation ${laneRow.open_operation_id}`); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts index 578005c4dd9..16f7df44c3e 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts @@ -1,14 +1,15 @@ import { SessionError } from "@earendil-works/pi-agent-core"; +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export function createSequence(db: SqliteDatabase, sessionId: string, nextSeq = 1) { - db.prepare("INSERT INTO session_sequences (session_id, next_seq) VALUES (?, ?)").run(sessionId, nextSeq); + sql`INSERT INTO session_sequences (session_id, next_seq) VALUES (${sessionId}, ${nextSeq})`.run(db); } export function getNextSequence(db: SqliteDatabase, sessionId: string) { - const sequenceRow = db - .prepare("SELECT next_seq FROM session_sequences WHERE session_id = ?") - .get<{ next_seq: number }>(sessionId); + const sequenceRow = sql`SELECT next_seq FROM session_sequences WHERE session_id = ${sessionId}`.get<{ + next_seq: number; + }>(db); if (!sequenceRow) { throw new SessionError("storage", `Missing sequence row for session ${sessionId}`); } @@ -16,7 +17,7 @@ export function getNextSequence(db: SqliteDatabase, sessionId: string) { } export function setNextSequence(db: SqliteDatabase, sessionId: string, nextSeq: number) { - db.prepare("UPDATE session_sequences SET next_seq = ? WHERE session_id = ?").run(nextSeq, sessionId); + sql`UPDATE session_sequences SET next_seq = ${nextSeq} WHERE session_id = ${sessionId}`.run(db); } export function advanceSequence(db: SqliteDatabase, sessionId: string, seq: number) { @@ -24,5 +25,5 @@ export function advanceSequence(db: SqliteDatabase, sessionId: string, seq: numb } export function deleteSequence(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM session_sequences WHERE session_id = ?").run(sessionId); + sql`DELETE FROM session_sequences WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts index 370cfb9472f..85a3a458106 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts @@ -1,5 +1,6 @@ import { SessionError, type SessionStats } from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface SessionStatsRow { @@ -12,21 +13,15 @@ export interface SessionStatsRow { } export function createStats(db: SqliteDatabase, sessionId: string, messageCount = 0): void { - db.prepare( - `INSERT INTO session_stats + sql`INSERT INTO session_stats (session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total) - VALUES (?, ?, 0, 0, 0, 0)`, - ).run(sessionId, messageCount); + VALUES (${sessionId}, ${messageCount}, 0, 0, 0, 0)`.run(db); } export function readStats(db: SqliteDatabase, sessionId: string): SessionStats { - const row = db - .prepare( - `SELECT session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total - FROM session_stats - WHERE session_id = ?`, - ) - .get(sessionId); + const row = sql`SELECT session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total + FROM session_stats + WHERE session_id = ${sessionId}`.get(db); if (!row) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); return { messageCount: row.message_count, @@ -38,26 +33,22 @@ export function readStats(db: SqliteDatabase, sessionId: string): SessionStats { } export function incrementMessageCount(db: SqliteDatabase, sessionId: string): void { - const result = db - .prepare("UPDATE session_stats SET message_count = message_count + 1 WHERE session_id = ?") - .run(sessionId); + const result = sql`UPDATE session_stats SET message_count = message_count + 1 WHERE session_id = ${sessionId}`.run( + db, + ); if (result.changes !== 1) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); } export function addUsageToStats(db: SqliteDatabase, sessionId: string, usage: Usage): void { - const result = db - .prepare( - `UPDATE session_stats - SET cached_tokens = cached_tokens + ?, - uncached_tokens = uncached_tokens + ?, - total_tokens = total_tokens + ?, - cost_total = cost_total + ? - WHERE session_id = ?`, - ) - .run(usage.cacheRead, usage.input + usage.cacheWrite, usage.totalTokens, usage.cost.total, sessionId); + const result = sql`UPDATE session_stats + SET cached_tokens = cached_tokens + ${usage.cacheRead}, + uncached_tokens = uncached_tokens + ${usage.input + usage.cacheWrite}, + total_tokens = total_tokens + ${usage.totalTokens}, + cost_total = cost_total + ${usage.cost.total} + WHERE session_id = ${sessionId}`.run(db); if (result.changes !== 1) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); } export function deleteStats(db: SqliteDatabase, sessionId: string): void { - db.prepare("DELETE FROM session_stats WHERE session_id = ?").run(sessionId); + sql`DELETE FROM session_stats WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts index d963c5c89d4..e25e8a80919 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts @@ -1,4 +1,5 @@ import { assertJsonSerializable, SessionError } from "@earendil-works/pi-agent-core"; +import { sql } from "../sql.ts"; import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts"; export interface SessionRow { @@ -38,7 +39,7 @@ function parseMetadata(metadata: string | null, sessionId: string): Record(sessionId); + return !!sql`SELECT 1 AS found FROM sessions WHERE id = ${sessionId}`.get<{ found: number }>(db); } function serializeMetadata(metadata: Record | undefined): string | null { @@ -51,61 +52,50 @@ function serializeMetadata(metadata: Record | undefined): strin } export function insertSessionRow(db: SqliteDatabase, session: NewSessionRow) { - db.prepare("INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id) VALUES (?, ?, ?, ?, ?)").run( - session.id, - session.createdAt, - serializeMetadata(session.metadata), - session.cwd, - session.parentSessionId ?? null, + sql`INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id) + VALUES (${session.id}, ${session.createdAt}, ${serializeMetadata(session.metadata)}, ${session.cwd}, ${session.parentSessionId ?? null})`.run( + db, ); } export function readSessionRow(db: SqliteDatabase, sessionId: string) { - return db - .prepare( - `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name - FROM sessions AS s - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL - ) - WHERE s.id = ?`, - ) - .get(sessionId); + return sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, + name_fact.seq IS NOT NULL AS has_session_name, + name_fact.value AS session_name + FROM sessions AS s + LEFT JOIN facts AS name_fact + ON name_fact.session_id = s.id + AND name_fact.kind = 'name' + AND name_fact.key IS NULL + AND name_fact.seq = ( + SELECT MAX(f.seq) + FROM facts AS f + WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL + ) + WHERE s.id = ${sessionId}`.get(db); } export function readSessionRows(db: SqliteDatabase, options: { cwd?: string } = {}) { - const where = options.cwd === undefined ? "" : "WHERE s.cwd = ?"; - return db - .prepare( - `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name - FROM sessions AS s - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL - ) - ${where} - ORDER BY s.created_at DESC`, - ) - .all(...(options.cwd === undefined ? [] : [options.cwd])); + const where = options.cwd === undefined ? sql`` : sql`WHERE s.cwd = ${options.cwd}`; + return sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, + name_fact.seq IS NOT NULL AS has_session_name, + name_fact.value AS session_name + FROM sessions AS s + LEFT JOIN facts AS name_fact + ON name_fact.session_id = s.id + AND name_fact.kind = 'name' + AND name_fact.key IS NULL + AND name_fact.seq = ( + SELECT MAX(f.seq) + FROM facts AS f + WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL + ) + ${where} + ORDER BY s.created_at DESC`.all(db); } export function deleteSessionRow(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId); + sql`DELETE FROM sessions WHERE id = ${sessionId}`.run(db); } function parseSessionName(value: string | null, sessionId: string): string { diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts index 02cf38ec0dd..ec7fcaa916d 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts @@ -1,3 +1,4 @@ +import { sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; export interface WriterLease { @@ -19,18 +20,14 @@ export function acquireWriterLease( now: number, expiresAtMs: number, ) { - const row = db - .prepare( - `INSERT INTO writer_leases (session_id, owner_id, fence, expires_at_ms) - VALUES (?, ?, 1, ?) - ON CONFLICT(session_id) DO UPDATE SET - owner_id = excluded.owner_id, - fence = writer_leases.fence + 1, - expires_at_ms = excluded.expires_at_ms - WHERE writer_leases.expires_at_ms <= ? - RETURNING owner_id, fence, expires_at_ms`, - ) - .get(sessionId, ownerId, expiresAtMs, now); + const row = sql`INSERT INTO writer_leases (session_id, owner_id, fence, expires_at_ms) + VALUES (${sessionId}, ${ownerId}, 1, ${expiresAtMs}) + ON CONFLICT(session_id) DO UPDATE SET + owner_id = excluded.owner_id, + fence = writer_leases.fence + 1, + expires_at_ms = excluded.expires_at_ms + WHERE writer_leases.expires_at_ms <= ${now} + RETURNING owner_id, fence, expires_at_ms`.get(db); return row === undefined ? undefined : { ownerId: row.owner_id, fence: row.fence, expiresAtMs: row.expires_at_ms }; } @@ -41,25 +38,21 @@ export function renewWriterLease( now: number, expiresAtMs: number, ) { - const result = db - .prepare( - `UPDATE writer_leases - SET expires_at_ms = ? - WHERE session_id = ? AND owner_id = ? AND fence = ? AND expires_at_ms > ?`, - ) - .run(expiresAtMs, sessionId, lease.ownerId, lease.fence, now); + const result = sql`UPDATE writer_leases + SET expires_at_ms = ${expiresAtMs} + WHERE session_id = ${sessionId} + AND owner_id = ${lease.ownerId} + AND fence = ${lease.fence} + AND expires_at_ms > ${now}`.run(db); if (result.changes === 1) lease.expiresAtMs = expiresAtMs; return result.changes === 1; } export function releaseWriterLease(db: SqliteDatabase, sessionId: string, lease: WriterLease) { - db.prepare("DELETE FROM writer_leases WHERE session_id = ? AND owner_id = ? AND fence = ?").run( - sessionId, - lease.ownerId, - lease.fence, - ); + sql`DELETE FROM writer_leases + WHERE session_id = ${sessionId} AND owner_id = ${lease.ownerId} AND fence = ${lease.fence}`.run(db); } export function deleteWriterLease(db: SqliteDatabase, sessionId: string) { - db.prepare("DELETE FROM writer_leases WHERE session_id = ?").run(sessionId); + sql`DELETE FROM writer_leases WHERE session_id = ${sessionId}`.run(db); } diff --git a/packages/session-backends/sqlite-node/test/sql.test.ts b/packages/session-backends/sqlite-node/test/sql.test.ts new file mode 100644 index 00000000000..020d1c46340 --- /dev/null +++ b/packages/session-backends/sqlite-node/test/sql.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { createNodeSqliteFactory, joinSqlFragments, sql } from "../src/index.ts"; + +describe("sql", () => { + it("composes SQLite queries without renumbering parameters", async () => { + const db = await createNodeSqliteFactory().open(":memory:"); + try { + sql`CREATE TABLE entries (id TEXT PRIMARY KEY, kind TEXT NOT NULL, active INTEGER NOT NULL)`.exec(db); + sql`INSERT INTO entries (id, kind, active) VALUES (${"one"}, ${"message"}, ${1})`.run(db); + sql`INSERT INTO entries (id, kind, active) VALUES (${"two"}, ${"message"}, ${0})`.run(db); + const filters = joinSqlFragments([sql`kind = ${"message"}`, sql`active = ${1}`], " AND "); + + expect(sql`SELECT id FROM entries WHERE ${filters} LIMIT ${10}`.all<{ id: string }>(db)).toEqual([ + { id: "one" }, + ]); + } finally { + db.close(); + } + }); + + it("executes parameterized queries", async () => { + const db = await createNodeSqliteFactory().open(":memory:"); + try { + sql`CREATE TABLE values_table (id INTEGER PRIMARY KEY, value TEXT NOT NULL)`.exec(db); + sql`INSERT INTO values_table (id, value) VALUES (${1}, ${"one"})`.run(db); + sql`INSERT INTO values_table (id, value) VALUES (${2}, ${"two"})`.run(db); + + expect(sql`SELECT value FROM values_table WHERE id = ${1}`.get<{ value: string }>(db)).toEqual({ + value: "one", + }); + expect(sql`SELECT value FROM values_table ORDER BY id`.all<{ value: string }>(db)).toEqual([ + { value: "one" }, + { value: "two" }, + ]); + } finally { + db.close(); + } + }); +}); From 9b4f6a9e1f8f1db0d9d324456db108e2adc9253d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 18:14:26 +0200 Subject: [PATCH 011/284] doc(changelog): Fix link --- packages/coding-agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9c0cb0c1426..2c65f76a67c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,7 +6,7 @@ ### New Features -- **Fullscreen TUI mode** — Switch between regular and fullscreen modes at runtime, with a sticky editor and footer, independently scrollable transcript, and draggable scrollbars. See [UI & Display](docs/settings.md#ui--display). +- **Fullscreen TUI mode** — Switch between regular and fullscreen modes at runtime, with a sticky editor and footer, independently scrollable transcript, and draggable scrollbars. See [UI & Display](docs/settings.md#ui-display). - **Mermaid and LaTeX rendering** — Render Mermaid diagrams and terminal-friendly Unicode math in interactive transcripts. See [Markdown settings](docs/settings.md#markdown) and [TUI Markdown](../tui/README.md#markdown). - **Per-directory context overrides** — Use `AGENTS.override.md` to replace context files for a specific directory. See [Context Files](docs/usage.md#context-files). - **Advanced custom model sampling** — Configure arbitrary OpenAI-compatible `samplingParams` and opt-in vLLM `thinking_token_budget` values. See [Sampling Parameters](docs/models.md#sampling-parameters). From 9b17fd5bb3fe9be26e1e6099cbce3c6b5e1a6f5b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 18:16:01 +0200 Subject: [PATCH 012/284] fix: verify npm package registration before release --- scripts/release.mjs | 73 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/scripts/release.mjs b/scripts/release.mjs index 217561cf12d..422c08a8ab0 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -8,17 +8,18 @@ * * Steps: * 1. Check for uncommitted changes - * 2. Bump version via npm run version:xxx or set an explicit version - * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date - * 4. Regenerate release artifacts - * 5. Run checks and tests - * 6. Commit and tag the release - * 7. Add new [Unreleased] section to changelogs - * 8. Commit next-cycle changelog updates - * 9. Push main and the tag to trigger CI publishing + * 2. Verify every public workspace package is registered on npm + * 3. Bump version via npm run version:xxx or set an explicit version + * 4. Update CHANGELOG.md files: [Unreleased] -> [version] - date + * 5. Regenerate release artifacts + * 6. Run checks and tests + * 7. Commit and tag the release + * 8. Add new [Unreleased] section to changelogs + * 9. Commit next-cycle changelog updates + * 10. Push main and the tag to trigger CI publishing */ -import { execSync } from "node:child_process"; +import { execSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { findPackageDirectories } from "./package-workspaces.mjs"; @@ -50,6 +51,41 @@ function getVersion() { return pkg.version; } +function assertPackagesAreRegisteredWithNpm() { + const packageNames = findPackageDirectories() + .map((directory) => JSON.parse(readFileSync(join(directory, "package.json"), "utf8"))) + .filter((pkg) => pkg.private !== true) + .map((pkg) => pkg.name); + const unregisteredPackages = []; + + console.log("Checking npm package registration..."); + for (const packageName of packageNames) { + const result = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["view", packageName, "version", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status === 0 && result.stdout.trim()) { + console.log(` ${packageName}`); + continue; + } + + const output = [result.stdout, result.stderr, result.error?.message].filter(Boolean).join("\n"); + if (output.includes("E404") || output.includes("404 Not Found")) { + unregisteredPackages.push(packageName); + continue; + } + + throw new Error(output ? `Failed to query npm registration for ${packageName}\n${output}` : `Failed to query npm registration for ${packageName}`); + } + + if (unregisteredPackages.length > 0) { + throw new Error(`The following public workspace packages are not registered on npm:\n${unregisteredPackages.map((packageName) => ` ${packageName}`).join("\n")}\nRegister them before running a release.`); + } + + console.log(" All public workspace packages are registered on npm\n"); +} + function compareVersions(a, b) { const aParts = a.split(".").map(Number); const bParts = b.split(".").map(Number); @@ -190,16 +226,19 @@ if (status && status.trim()) { } console.log(" Working directory clean\n"); -// 2. Bump or set version +// 2. Verify npm package registration before modifying the worktree. +assertPackagesAreRegisteredWithNpm(); + +// 3. Bump or set version const version = bumpOrSetVersion(RELEASE_TARGET); console.log(` New version: ${version}\n`); -// 3. Update changelogs +// 4. Update changelogs console.log("Updating CHANGELOG.md files..."); updateChangelogsForRelease(version); console.log(); -// 4. Regenerate release artifacts +// 5. Regenerate release artifacts console.log("Regenerating release artifacts..."); run("npm run generate:models"); run("npm run check:model-data"); @@ -207,7 +246,7 @@ run("npm run shrinkwrap:coding-agent"); run("npm run install-lock:coding-agent"); console.log(); -// 5. Run checks and tests +// 6. Run checks and tests console.log("Running checks..."); run("npm run check"); console.log(); @@ -220,25 +259,25 @@ console.log("Running tests..."); run("./test.sh"); console.log(); -// 6. Commit and tag +// 7. Commit and tag console.log("Committing and tagging..."); stageChangedFiles(); run(`git commit -m "Release v${version}"`); run(`git tag v${version}`); console.log(); -// 7. Add new [Unreleased] sections +// 8. Add new [Unreleased] sections console.log("Adding [Unreleased] sections for next cycle..."); addUnreleasedSection(); console.log(); -// 8. Commit +// 9. Commit console.log("Committing changelog updates..."); stageChangedFiles(); run(`git commit -m "Add [Unreleased] section for next cycle"`); console.log(); -// 9. Push +// 10. Push console.log("Pushing to remote..."); run("git push origin main"); run(`git push origin v${version}`); From fc3554e16defffcf76553b1bd12f676adf26d0a4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 18:20:48 +0200 Subject: [PATCH 013/284] fix(tui): reduce mouse tracking in terminal multiplexers --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui-alt-screen.ts | 16 +++++++- packages/tui/test/tui-alt-screen.test.ts | 49 +++++++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 22903fe2749..1a4ca82de70 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed LaTeX relation, multiplication, and named-operator spacing, and correctly composed matrices with stacked fractions, operator limits, and adjacent matrices. +- Reduced fullscreen mouse event volume under tmux, Zellij, and GNU Screen by using button-motion tracking instead of all-motion tracking. ## [0.84.0] - 2026-08-06 diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index aa062e4f546..f0d80fb79dd 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -44,7 +44,8 @@ const ENTER_ALT_SCREEN = "\x1b[?1049h"; const EXIT_ALT_SCREEN = "\x1b[?1049l"; const DISABLE_AUTOWRAP = "\x1b[?7l"; const ENABLE_AUTOWRAP = "\x1b[?7h"; -const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; +const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h"; +const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; const FOCUS_IN = "\x1b[I"; const FOCUS_OUT = "\x1b[O"; @@ -201,8 +202,19 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.pressedUrl = undefined; this.selectionDragged = false; this.resetRenderState(); + const term = process.env.TERM?.toLowerCase() ?? ""; + // Multiplexers can lag when every pointer movement is forwarded. Button-motion + // tracking preserves clicks, wheel events, selections, and scrollbar dragging. + const mouseSequence = + process.env.TMUX !== undefined || + process.env.ZELLIJ !== undefined || + process.env.STY !== undefined || + term.startsWith("tmux") || + term.startsWith("screen") + ? ENABLE_BUTTON_MOTION_MOUSE + : ENABLE_ALL_MOTION_MOUSE; this.terminal.write( - `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? ENABLE_MOUSE : ""}\x1b[2J\x1b[H\x1b[?25l`, + `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`, ); } diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 5b2dabf48c8..15e07a69f5f 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -161,6 +161,53 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("uses button-motion tracking inside terminal multiplexers", () => { + const environmentKeys = ["TMUX", "ZELLIJ", "STY", "TERM"] as const; + const previousEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]])); + try { + for (const key of environmentKeys) delete process.env[key]; + process.env.TERM = "xterm-256color"; + const directTerminal = new RecordingTerminal(); + const directTui = new TuiAltScreen(directTerminal); + directTui.start(); + const directWrites = directTerminal.events + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(directWrites.includes("\x1b[?1003h")); + directTui.stop(); + + const multiplexers = [ + { name: "tmux environment", environment: { TMUX: "/tmp/tmux/default,1,0" } }, + { name: "tmux TERM", environment: { TERM: "tmux-256color" } }, + { name: "Zellij environment", environment: { ZELLIJ: "0" } }, + { name: "Screen environment", environment: { STY: "123.session" } }, + { name: "Screen TERM", environment: { TERM: "screen-256color" } }, + ]; + for (const { name, environment } of multiplexers) { + for (const key of environmentKeys) delete process.env[key]; + for (const [key, value] of Object.entries(environment)) process.env[key] = value; + const terminal = new RecordingTerminal(); + const tui = new TuiAltScreen(terminal); + tui.start(); + const writes = terminal.events + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(writes.includes("\x1b[?1002h"), `${name} should enable button-motion tracking`); + assert.ok(!writes.includes("\x1b[?1003h"), `${name} should not enable all-motion tracking`); + assert.ok(writes.includes("\x1b[?1006h"), `${name} should enable SGR mouse encoding`); + tui.stop(); + } + } finally { + for (const key of environmentKeys) { + const value = previousEnvironment.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + it("drags a visible scrollbar thumb and keeps it visible until release", async () => { const terminal = new RecordingTerminal(10, 5); const tui = new TuiAltScreen(terminal); @@ -214,9 +261,7 @@ describe("TuiAltScreen", () => { assert.strictEqual(scrollView.isScrollbarVisible, false); assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); - assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1003h"))); tui.stop(); - assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1003l"))); }); it("keeps the scrollbar column selectable while the thumb is hidden", async () => { From 58fc0431a7e3bb573b5943aa8e3af7a4ce6109ad Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 18:49:42 +0200 Subject: [PATCH 014/284] feat(tui): select words and lines with multi-click (closes #7725) --- packages/tui/CHANGELOG.md | 4 + packages/tui/src/tui-alt-screen.ts | 140 +++++++++++++++++++++-- packages/tui/test/tui-alt-screen.test.ts | 39 +++++++ 3 files changed, 173 insertions(+), 10 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1a4ca82de70..a1c785a9572 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added double-click word selection, word-aware drag selection, and triple-click line selection in the fullscreen TUI ([#7725](https://github.com/earendil-works/pi/issues/7725)). + ### Fixed - Fixed LaTeX relation, multiplication, and named-operator spacing, and correctly composed matrices with stacked fractions, operator limits, and adjacent matrices. diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index f0d80fb79dd..2032bf0f4a2 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -35,6 +35,7 @@ import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, + getWordSegmenter, sliceByColumn, stripTerminalSequences, visibleWidth, @@ -57,6 +58,8 @@ const PAGE_SCROLL_OVERLAP = 4; const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16; const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024; const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024; +const DOUBLE_CLICK_INTERVAL_MS = 500; +const wordSegmenter = getWordSegmenter(); interface CachedKittyImage { transmissionGeneration: number; @@ -68,6 +71,24 @@ interface SelectionPoint { row: number; col: number; scrollView?: ScrollView; + /** Whether this point lies between terminal cells rather than on a cell. */ + boundary?: boolean; +} + +interface SelectionRange { + start: SelectionPoint; + end: SelectionPoint; +} + +type SelectionGranularity = "character" | "word" | "line"; + +interface ClickTarget { + timestamp: number; + count: number; + row: number; + scrollView?: ScrollView; + wordStart: number; + wordEnd: number; } interface SgrMouseEvent { @@ -121,6 +142,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private readonly uploadedKittyImages = new Map(); private selectionAnchor?: SelectionPoint; private selectionFocus?: SelectionPoint; + private selectionGranularity: SelectionGranularity = "character"; + private selectionInitialRange?: SelectionRange; + private lastClick?: ClickTarget; private selectionDragPointer?: { x: number; y: number }; private selectionAutoScrollDirection: -1 | 0 | 1 = 0; private selectionAutoScrollTimer?: NodeJS.Timeout; @@ -199,6 +223,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.lastDocument = []; this.selectionAnchor = undefined; this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + this.lastClick = undefined; this.pressedUrl = undefined; this.selectionDragged = false; this.resetRenderState(); @@ -363,7 +390,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (hadActiveSelection) { this.selectionAnchor = undefined; this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; } + this.lastClick = undefined; this.requestRender(); return { consume: true }; } @@ -530,6 +560,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.selectionPressActive = false; this.selectionAnchor = undefined; this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + this.lastClick = undefined; this.pressedUrl = undefined; this.selectionDragged = false; this.setScrollbarHover(target.scrollView); @@ -575,6 +608,83 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { }; } + private getSelectionSourceLine(point: SelectionPoint): string { + if (point.scrollView && this.currentLayout) { + const lines = getScrollViewBox(this.currentLayout, point.scrollView)?.scrollContentLines; + if (lines) return lines[point.row] ?? ""; + } + return this.previousScreen[point.row] ?? ""; + } + + private getWordSelection(point: SelectionPoint): SelectionRange | undefined { + const line = stripTerminalSequences(this.getSelectionSourceLine(point)); + let start = 0; + for (const segment of wordSegmenter.segment(line)) { + const end = start + visibleWidth(segment.segment); + if (point.col >= start && point.col < end) { + return { + start: { ...point, col: start }, + end: { ...point, col: end, boundary: true }, + }; + } + start = end; + } + return undefined; + } + + private getLineSelection(point: SelectionPoint): SelectionRange { + return { + start: { ...point, col: 0 }, + end: { ...point, col: visibleWidth(this.getSelectionSourceLine(point)), boundary: true }, + }; + } + + private updateSelectionFocus(point: SelectionPoint): void { + if (this.selectionGranularity === "character" || !this.selectionInitialRange) { + this.selectionFocus = point; + return; + } + const range = this.selectionGranularity === "word" ? this.getWordSelection(point) : this.getLineSelection(point); + if (!range) return; + const initial = this.selectionInitialRange; + const targetBeforeInitial = + range.start.row < initial.start.row || + (range.start.row === initial.start.row && range.start.col < initial.start.col); + if (targetBeforeInitial) { + this.selectionAnchor = initial.end; + this.selectionFocus = range.start; + } else { + this.selectionAnchor = initial.start; + this.selectionFocus = range.end; + } + } + + private getClickCount(point: SelectionPoint, word: SelectionRange | undefined): number { + const now = Date.now(); + const previous = this.lastClick; + const count = + word && + previous && + now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS && + previous.row === point.row && + previous.scrollView === point.scrollView && + previous.wordStart === word.start.col && + previous.wordEnd === word.end.col + ? (previous.count % 3) + 1 + : 1; + this.lastClick = word + ? { + timestamp: now, + count, + row: point.row, + scrollView: point.scrollView, + wordStart: word.start.col, + wordEnd: word.end.col, + } + : undefined; + return count; + } + private updateSelectionAutoScroll(event: SgrMouseEvent): void { const scrollView = this.selectionAnchor?.scrollView; if (!scrollView || !this.currentLayout) { @@ -617,7 +727,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return; } const point = this.getScrollSelectionPoint(scrollView, pointer.x, pointer.y); - if (point) this.selectionFocus = point; + if (point) this.updateSelectionFocus(point); this.requestRender(); } @@ -639,7 +749,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.selectionPressActive = false; this.stopSelectionAutoScroll(); if (!this.selectionAnchor) return; - this.selectionFocus = point; + this.updateSelectionFocus(point); const clickedUrl = !this.selectionDragged && this.selectionAnchor.scrollView === point.scrollView && @@ -666,8 +776,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if ((event.button & 32) !== 0) { if (!this.selectionPressActive || !this.selectionAnchor) return; this.selectionDragged = true; + this.lastClick = undefined; this.pressedUrl = undefined; - this.selectionFocus = point; + this.updateSelectionFocus(point); this.updateSelectionAutoScroll(event); this.requestRender(); return; @@ -679,13 +790,20 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { ? getScrollViewsAt(this.currentLayout, event.x, event.y)[0] : undefined; const anchor = this.getSelectionPoint(event, scrollView); - this.selectionAnchor = anchor; - this.selectionFocus = anchor; + const word = this.getWordSelection(anchor); + const clickCount = this.getClickCount(anchor, word); + const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined; + this.selectionGranularity = range ? (clickCount === 2 ? "word" : "line") : "character"; + this.selectionInitialRange = range; + this.selectionAnchor = range?.start ?? anchor; + this.selectionFocus = range?.end ?? anchor; this.selectionDragged = false; - this.pressedUrl = getOsc8LinkAtColumn( - this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "", - Math.max(0, Math.min(this.terminal.columns - 1, event.x)), - ); + this.pressedUrl = range + ? undefined + : getOsc8LinkAtColumn( + this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "", + Math.max(0, Math.min(this.terminal.columns - 1, event.x)), + ); this.requestRender(); } @@ -720,7 +838,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth); } if (row === selection.end.row) { - end = getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth); + end = selection.end.boundary + ? Math.min(selection.end.col, lineWidth) + : (getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth)); } return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) }; } diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 15e07a69f5f..c344927024d 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -763,6 +763,45 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("selects whole words on double click, extends word drags, and selects lines on triple click", async () => { + const terminal = new RecordingTerminal(20, 2); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("zero alpha beta\ngamma delta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + // The second click lands on a different character in alpha. + terminal.sendInput("\x1b[<0;6;1M"); + terminal.sendInput("\x1b[<0;6;1m"); + terminal.sendInput("\x1b[<0;10;1M"); + terminal.sendInput("\x1b[<0;10;1m"); + await terminal.waitForRender(); + const alpha = `\x1b]52;c;${Buffer.from("alpha").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(alpha))); + + // A double-click drag includes each word touched, rather than partial words. + terminal.sendInput("\x1b[<0;12;1M"); + terminal.sendInput("\x1b[<0;12;1m"); + terminal.sendInput("\x1b[<0;14;1M"); + terminal.sendInput("\x1b[<32;3;2M"); + terminal.sendInput("\x1b[<0;3;2m"); + await terminal.waitForRender(); + const words = `\x1b]52;c;${Buffer.from("beta\ngamma").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(words))); + + terminal.sendInput("\x1b[<0;7;2M"); + terminal.sendInput("\x1b[<0;7;2m"); + terminal.sendInput("\x1b[<0;9;2M"); + terminal.sendInput("\x1b[<0;9;2m"); + terminal.sendInput("\x1b[<0;11;2M"); + terminal.sendInput("\x1b[<0;11;2m"); + await terminal.waitForRender(); + const line = `\x1b]52;c;${Buffer.from("gamma delta").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(line))); + + tui.stop(); + }); + it("ignores orphan selection events and cancels an active selection on focus loss", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); From a261366bde90c24826eb77bfc600f1bb62ad36e2 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 19:43:52 +0200 Subject: [PATCH 015/284] feat(coding-agent): add auth preflight closes #7152 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/cli/args.ts | 6 +- packages/coding-agent/src/cli/auth-check.ts | 73 ++++++++ packages/coding-agent/src/cli/auth-command.ts | 125 +++++++++++++ .../coding-agent/src/cli/credential-print.ts | 142 ++++----------- .../coding-agent/src/core/auth-storage.ts | 91 +++++++++- .../coding-agent/src/core/model-runtime.ts | 6 +- packages/coding-agent/src/main.ts | 115 ++++++++---- packages/coding-agent/test/auth-check.test.ts | 171 ++++++++++++++++++ .../test/credential-print.test.ts | 60 ++++-- 10 files changed, 631 insertions(+), 162 deletions(-) create mode 100644 packages/coding-agent/src/cli/auth-check.ts create mode 100644 packages/coding-agent/src/cli/auth-command.ts create mode 100644 packages/coding-agent/test/auth-check.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2c65f76a67c..9bbad2e7e47 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)). + ## [0.84.0] - 2026-08-06 ### New Features diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index b6adf435f8d..5ea1ee8fbf6 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -248,7 +248,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs ${APP_NAME} list List installed extensions from settings ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) - ${APP_NAME} auth Print credentials for external clients + ${APP_NAME} auth Print credentials or check provider readiness ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth ${chalk.bold("Options:")} @@ -299,10 +299,10 @@ Extensions can register additional flags (e.g., --plan from plan-mode extension) ${chalk.bold("Examples:")} # Print a provider API key for an external client - ${APP_NAME} auth print-api-key --provider openai --model gpt-5.5 + ${APP_NAME} auth print-api-key --provider openai # Print an OAuth bearer token for an external client (refreshes if expired) - ${APP_NAME} auth print-bearer-token --provider openai-codex --model gpt-5.5 + ${APP_NAME} auth print-bearer-token --provider openai-codex # Interactive mode ${APP_NAME} diff --git a/packages/coding-agent/src/cli/auth-check.ts b/packages/coding-agent/src/cli/auth-check.ts new file mode 100644 index 00000000000..9266ddb7f0a --- /dev/null +++ b/packages/coding-agent/src/cli/auth-check.ts @@ -0,0 +1,73 @@ +import type { CredentialStore } from "@earendil-works/pi-ai"; +import { resolveCliModel } from "../core/model-resolver.ts"; +import { ModelRuntime } from "../core/model-runtime.ts"; +import { InMemoryCodingAgentModelsStore } from "../core/models-store.ts"; +import type { Args } from "./args.ts"; +import { AuthCommandError, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts"; + +export type AuthCheckStatus = "ready" | "not_ready" | "invalid"; +export type AuthCheckReason = + | "provider_not_found" + | "credentials_not_configured" + | "credential_not_available" + | "invalid_state"; + +export interface AuthCheckResult { + status: AuthCheckStatus; + provider: string; + reason?: AuthCheckReason; + authType?: "api_key" | "oauth"; +} + +export async function checkProviderAuth( + args: Args, + modelRuntime: ModelRuntime, + options: { refresh: boolean } = { refresh: false }, +): Promise { + const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, "check"); + let provider = cliProvider; + if (cliModel) { + const resolved = resolveCliModel({ cliProvider, cliModel, modelRuntime }); + if (resolved.error || !resolved.model) { + throw new AuthCommandError(resolved.error ?? `Unable to resolve model "${cliModel}"`); + } + provider = resolved.model.provider; + } + if (!provider) throw new AuthCommandError("Unable to resolve an auth provider"); + if (modelRuntime.getError()) { + return { status: "invalid", provider, reason: "invalid_state" }; + } + if (!modelRuntime.getProvider(provider)) { + return { status: "not_ready", provider, reason: "provider_not_found" }; + } + try { + const auth = await modelRuntime.checkAuth(provider); + if (!auth) return { status: "not_ready", provider, reason: "credentials_not_configured" }; + if (options.refresh && !(await modelRuntime.getAuth(provider))) { + return { status: "not_ready", provider, reason: "credentials_not_configured" }; + } + return { status: "ready", provider, authType: auth.type }; + } catch { + return { status: "invalid", provider, reason: "invalid_state" }; + } +} + +export async function getProviderCredential( + providerId: string, + modelRuntime: ModelRuntime, + credentials: CredentialStore, + options: { refresh: boolean }, +): Promise { + const credential = await credentials.read(providerId); + if (!options.refresh && credential?.type === "oauth") return credential.access; + return getAuthCredential(await modelRuntime.getAuth(providerId)); +} + +export async function createAuthCheckModelRuntime(credentials: CredentialStore): Promise { + return ModelRuntime.create({ + credentials, + modelsStore: new InMemoryCodingAgentModelsStore(), + allowModelNetwork: false, + refreshOnCreate: false, + }); +} diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts new file mode 100644 index 00000000000..a0e58d14260 --- /dev/null +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -0,0 +1,125 @@ +import type { AuthResult } from "@earendil-works/pi-ai"; +import type { Args } from "./args.ts"; + +export type AuthCommandKind = "check" | "api_key" | "bearer_token"; + +export interface AuthCommand { + kind: AuthCommandKind; + args: string[]; + json: boolean; + credentials: boolean; + noRefresh: boolean; + minExpiryMs?: number; +} + +export class AuthCommandError extends Error {} + +const AUTH_COMMAND_USAGE: Record = { + check: "pi auth check --provider [--json] [--credentials] [--no-refresh]", + api_key: "pi auth print-api-key --provider [--model ]", + bearer_token: "pi auth print-bearer-token --provider [--model ] [--min-expiry ]", +}; + +export function getAuthCommandName(kind: AuthCommandKind): string { + return kind === "check" ? "auth check" : kind === "api_key" ? "auth print-api-key" : "auth print-bearer-token"; +} + +export function getAuthCommandUsage(kind: AuthCommandKind): string { + return AUTH_COMMAND_USAGE[kind]; +} + +export function isAuthCommandHelp(args: string[]): boolean { + return ( + args[0] === "auth" && + (args[1] === undefined || args[1] === "help" || args.includes("--help") || args.includes("-h")) + ); +} + +export function printAuthCommandHelp(): void { + console.log(`Usage: + pi auth print-api-key [--provider ] [--model ] + pi auth print-bearer-token [--provider ] [--model ] [--min-expiry ] + pi auth check [--provider ] [--model ] [--json] [--credentials] [--no-refresh] + +Auth commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the credential, or includes it in JSON output.`); +} + +export function parseAuthCommand(args: string[]): AuthCommand | undefined { + if (args[0] !== "auth") return undefined; + + const kind = + args[1] === "check" + ? "check" + : args[1] === "print-api-key" + ? "api_key" + : args[1] === "print-bearer-token" + ? "bearer_token" + : undefined; + if (!kind) { + throw new AuthCommandError( + `Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key", "pi auth print-bearer-token", or "pi auth check".`, + ); + } + + const commandArgs: string[] = []; + let json = false; + let credentials = false; + let noRefresh = false; + let minExpiryMs: number | undefined; + for (let index = 2; index < args.length; index++) { + const arg = args[index]; + if (arg === "--min-expiry") { + if (kind !== "bearer_token") + throw new AuthCommandError("--min-expiry is only supported by print-bearer-token"); + const value = args[++index]; + const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined; + if (!match) throw new AuthCommandError("--min-expiry must use a duration such as 30m or 1h"); + const amount = Number(match[1]); + const unit = match[2]; + minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000); + continue; + } + if (arg === "--json" || arg === "--credentials" || arg === "--no-refresh") { + if (kind !== "check") throw new AuthCommandError(`${arg} is only supported by auth check`); + if (arg === "--json") json = true; + else if (arg === "--credentials") credentials = true; + else noRefresh = true; + continue; + } + commandArgs.push(arg); + } + + return minExpiryMs === undefined + ? { kind, args: commandArgs, json, credentials, noRefresh } + : { kind, args: commandArgs, json, credentials, noRefresh, minExpiryMs }; +} + +export function validateAuthCommandArgs(args: Args, kind: AuthCommandKind): { provider?: string; model?: string } { + const provider = args.provider?.trim() || undefined; + const model = args.model?.trim() || undefined; + if (args.unknownFlags.size > 0) { + const option = args.unknownFlags.keys().next().value; + throw new AuthCommandError(`Unknown option --${option} for "${getAuthCommandName(kind)}".`); + } + if (args.apiKey !== undefined || args.messages.length > 0 || args.fileArgs.length > 0) { + throw new AuthCommandError("Auth commands only accept --provider and --model"); + } + if (kind === "check") { + if (!provider && !model) { + throw new AuthCommandError("Auth checks require --provider or --model "); + } + return { provider, model }; + } + if (!provider && !model) { + throw new AuthCommandError("Credential printing requires --provider or --model "); + } + return { provider, model }; +} + +export function getAuthCredential(auth: AuthResult | undefined): string | undefined { + if (auth?.auth.apiKey) return auth.auth.apiKey; + const authorization = Object.entries(auth?.auth.headers ?? {}).find( + ([name]) => name.toLowerCase() === "authorization", + )?.[1]; + return typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined; +} diff --git a/packages/coding-agent/src/cli/credential-print.ts b/packages/coding-agent/src/cli/credential-print.ts index 8f069781d54..304876659cd 100644 --- a/packages/coding-agent/src/cli/credential-print.ts +++ b/packages/coding-agent/src/cli/credential-print.ts @@ -2,81 +2,14 @@ import type { Api, CredentialInfo, Model } from "@earendil-works/pi-ai"; import { resolveCliModel } from "../core/model-resolver.ts"; import type { ModelRuntime } from "../core/model-runtime.ts"; import type { Args } from "./args.ts"; - -export type CredentialPrintKind = "api_key" | "bearer_token"; +import { AuthCommandError, type AuthCommandKind, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts"; const DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS = 30 * 60_000; -export interface CredentialPrintCommand { - kind: CredentialPrintKind; - args: string[]; - minExpiryMs?: number; -} - -export class CredentialPrintError extends Error {} - -export function isCredentialPrintHelp(args: string[]): boolean { - return ( - args[0] === "auth" && (args[1] === undefined || args[1] === "help" || args[1] === "--help" || args[1] === "-h") - ); -} - -export function printCredentialPrintHelp(): void { - console.log(`Usage: - pi auth print-api-key --model [--provider ] - pi auth print-bearer-token --model [--provider ] [--min-expiry ] - -Prints the configured credential alone on stdout. Provider inference uses configured credentials; specify --provider to select explicitly. Bearer tokens have a 30-minute minimum expiry by default. --min-expiry accepts ms, s, m, or h (for example, 30m).`); -} - -/** Parse the small, extensible `pi auth` command surface before normal startup. */ -export function parseCredentialPrintCommand(args: string[]): CredentialPrintCommand | undefined { - if (args[0] !== "auth") return undefined; - - const kind = args[1] === "print-api-key" ? "api_key" : args[1] === "print-bearer-token" ? "bearer_token" : undefined; - if (!kind) { - throw new CredentialPrintError( - `Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key" or "pi auth print-bearer-token".`, - ); - } - - const commandArgs: string[] = []; - let minExpiryMs: number | undefined; - for (let index = 2; index < args.length; index++) { - if (args[index] !== "--min-expiry") { - commandArgs.push(args[index]); - continue; - } - if (kind !== "bearer_token") { - throw new CredentialPrintError("--min-expiry is only supported by print-bearer-token"); - } - const value = args[++index]; - const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined; - if (!match) { - throw new CredentialPrintError("--min-expiry must use a duration such as 30m or 1h"); - } - const amount = Number(match[1]); - const unit = match[2]; - minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000); - } - - return minExpiryMs === undefined ? { kind, args: commandArgs } : { kind, args: commandArgs, minExpiryMs }; -} - -export function validateCredentialPrintArgs(args: Args): void { - if (!args.model?.trim()) { - throw new CredentialPrintError("Credential printing requires --model "); - } - if (args.apiKey !== undefined) { - throw new CredentialPrintError("Credential printing reads configured credentials; --api-key is not supported"); - } - if (args.messages.length > 0 || args.fileArgs.length > 0 || args.unknownFlags.size > 0) { - throw new CredentialPrintError("Credential printing only accepts --provider and --model"); - } -} +type CredentialPrintKind = Exclude; /** - * Resolve one request credential for a specific provider/model pair. + * Resolve one configured provider credential. * * This intentionally calls ModelRuntime.getAuth(), which refreshes and persists * OAuth credentials with less than five minutes remaining through the normal request-auth path. @@ -88,64 +21,67 @@ export async function resolveCredentialForPrint( minExpiryMs?: number, signal?: AbortSignal, ): Promise { - validateCredentialPrintArgs(args); - + const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, kind); const credentialTypes = new Map( (await modelRuntime.listCredentials({ signal })).map((credential) => [credential.providerId, credential.type]), ); - const models: Model[] = []; - if (args.provider) { - const resolved = resolveCliModel({ cliProvider: args.provider, cliModel: args.model, modelRuntime }); - if (resolved.error || !resolved.model) { - throw new CredentialPrintError(resolved.error ?? "Unable to resolve the requested provider/model"); + const providers: Array<{ id: string; model?: Model }> = []; + if (cliProvider) { + const provider = modelRuntime.getProvider(cliProvider); + if (!provider) { + throw new AuthCommandError(`Unknown provider "${cliProvider}". Use --list-models to see available providers.`); + } + if (cliModel) { + const resolved = resolveCliModel({ cliProvider: provider.id, cliModel, modelRuntime }); + if (resolved.error || !resolved.model) { + throw new AuthCommandError(resolved.error ?? "Unable to resolve the requested provider/model"); + } + providers.push({ id: provider.id, model: resolved.model }); + } else { + providers.push({ id: provider.id }); } - models.push(resolved.model); } else { for (const provider of modelRuntime.getProviders()) { if (!credentialTypes.has(provider.id)) continue; - const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: args.model, modelRuntime }); + const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: cliModel!, modelRuntime }); if (resolved.model && !resolved.error && !resolved.warning?.includes("Using custom model id")) { - models.push(resolved.model); + providers.push({ id: provider.id, model: resolved.model }); } } - if (models.length === 0) { - throw new CredentialPrintError(`Model "${args.model}" not found. Use --list-models to see available models.`); + if (providers.length === 0) { + throw new AuthCommandError(`Model "${cliModel}" not found. Use --list-models to see available models.`); } } const credentials: Array<{ providerId: string; value: string }> = []; - for (const model of models) { - const type = credentialTypes.get(model.provider); + for (const provider of providers) { + const type = credentialTypes.get(provider.id); if (kind === "api_key" && type === "oauth") continue; if (kind === "bearer_token" && type !== "oauth") continue; - - const auth = await modelRuntime.getAuth(model, { + const authOptions = { ...(kind === "bearer_token" ? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS } : {}), signal, - }); - const authorization = Object.entries(auth?.auth.headers ?? {}).find( - ([name]) => name.toLowerCase() === "authorization", - )?.[1]; - const bearerToken = typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined; - const value = kind === "bearer_token" ? (auth?.auth.apiKey ?? bearerToken) : auth?.auth.apiKey; - if (value) credentials.push({ providerId: model.provider, value }); + }; + const auth = provider.model + ? await modelRuntime.getAuth(provider.model, authOptions) + : await modelRuntime.getAuth(provider.id, authOptions); + const value = getAuthCredential(auth); + if (value) credentials.push({ providerId: provider.id, value }); } if (credentials.length === 1) return credentials[0].value; if (credentials.length === 0) { - const providerId = models[0]?.provider; + const providerId = providers[0]?.id; const type = providerId ? credentialTypes.get(providerId) : undefined; - if (args.provider && kind === "api_key" && type === "oauth") { - throw new CredentialPrintError(`Provider "${providerId}" is configured with OAuth, not an API key`); + if (cliProvider && kind === "api_key" && type === "oauth") { + throw new AuthCommandError(`Provider "${providerId}" is configured with OAuth, not an API key`); } - if (args.provider && kind === "bearer_token" && type !== "oauth") { - throw new CredentialPrintError(`Provider "${providerId}" is not configured with an OAuth bearer token`); + if (cliProvider && kind === "bearer_token" && type !== "oauth") { + throw new AuthCommandError(`Provider "${providerId}" is not configured with an OAuth bearer token`); } - throw new CredentialPrintError( - `No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`, - ); + throw new AuthCommandError(`No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`); } - throw new CredentialPrintError( - `Model "${args.model}" has multiple configured providers (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`, + throw new AuthCommandError( + `Multiple configured providers matched (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`, ); } diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 2f792f0dfb4..602e03a808f 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -11,7 +11,7 @@ import { setTimeout as sleep } from "timers/promises"; import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; -import { resolveConfigValue } from "./resolve-config-value.ts"; +import { isCommandConfigValue, resolveConfigValue } from "./resolve-config-value.ts"; type AuthStorageData = Record; @@ -201,6 +201,95 @@ export class FileAuthStorageBackend implements AuthStorageBackend { } } +export class ReadOnlyAuthStorage implements CredentialStore { + private readonly authPath: string; + private data: AuthStorageData | undefined; + + constructor(authPath: string = join(getAgentDir(), "auth.json")) { + this.authPath = normalizePath(authPath); + } + + private load(): AuthStorageData { + if (this.data) return this.data; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(this.authPath, "utf-8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + this.data = {}; + return this.data; + } + throw new Error(`Failed to read auth.json: ${error instanceof Error ? error.message : String(error)}`); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Invalid auth.json: expected an object"); + } + for (const [providerId, credential] of Object.entries(parsed)) { + if (typeof credential !== "object" || credential === null || Array.isArray(credential)) { + throw new Error(`Invalid auth.json credential for provider "${providerId}"`); + } + const value = credential as Record; + if (value.type === "api_key") { + const validKey = value.key === undefined || typeof value.key === "string"; + const validEnv = + value.env === undefined || + (typeof value.env === "object" && + value.env !== null && + !Array.isArray(value.env) && + Object.values(value.env).every((entry) => typeof entry === "string")); + if (validKey && validEnv) continue; + } else if ( + value.type === "oauth" && + typeof value.access === "string" && + typeof value.refresh === "string" && + typeof value.expires === "number" && + Number.isFinite(value.expires) + ) { + continue; + } + throw new Error(`Invalid auth.json credential for provider "${providerId}"`); + } + + this.data = parsed as AuthStorageData; + return this.data; + } + + async read(providerId: string, options?: AuthOperationOptions): Promise { + options?.signal?.throwIfAborted(); + const credential = this.load()[providerId]; + options?.signal?.throwIfAborted(); + if (!credential) return undefined; + if (credential.type !== "api_key" || !credential.key || isCommandConfigValue(credential.key)) { + return structuredClone(credential); + } + return { ...credential, key: resolveConfigValue(credential.key, credential.env) }; + } + + async list(options?: AuthOperationOptions): Promise { + options?.signal?.throwIfAborted(); + const credentials = Object.entries(this.load()).map(([providerId, credential]) => ({ + providerId, + type: credential.type, + })); + options?.signal?.throwIfAborted(); + return credentials; + } + + async modify( + _providerId: string, + _fn: (current: Credential | undefined) => Promise, + _options?: AuthOperationOptions, + ): Promise { + throw new Error("Read-only credential storage cannot modify auth.json"); + } + + async delete(_providerId: string, _options?: AuthOperationOptions): Promise { + throw new Error("Read-only credential storage cannot modify auth.json"); + } +} + export class InMemoryAuthStorageBackend implements AuthStorageBackend { private value: string | undefined; private asyncChain: Promise = Promise.resolve(); diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 0ecd88de35d..6f4071b60b3 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -77,6 +77,8 @@ export interface CreateModelRuntimeOptions { catalogBaseUrl?: string; /** Optional caller cancellation for initial cache restoration and availability checks. */ signal?: AbortSignal; + /** Skip initial catalog and availability refresh. Static models remain available. */ + refreshOnCreate?: boolean; } export interface ModelRuntimeAuthOverrides extends AuthOperationOptions { @@ -205,7 +207,9 @@ export class ModelRuntime implements Models { : controller.signal : options.signal; try { - await runtime.refresh({ allowNetwork: refreshFromNetwork, signal }); + if (options.refreshOnCreate !== false) { + await runtime.refresh({ allowNetwork: refreshFromNetwork, signal }); + } } finally { if (timeout) clearTimeout(timeout); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a100c7c7fef..8db96a6706f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -10,21 +10,29 @@ import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import chalk from "chalk"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; import { - type CredentialPrintCommand, - CredentialPrintError, - isCredentialPrintHelp, - parseCredentialPrintCommand, - printCredentialPrintHelp, - resolveCredentialForPrint, - validateCredentialPrintArgs, -} from "./cli/credential-print.ts"; + type AuthCheckResult, + checkProviderAuth, + createAuthCheckModelRuntime, + getProviderCredential, +} from "./cli/auth-check.ts"; +import { + type AuthCommand, + AuthCommandError, + getAuthCommandName, + getAuthCommandUsage, + isAuthCommandHelp, + parseAuthCommand, + printAuthCommandHelp, + validateAuthCommandArgs, +} from "./cli/auth-command.ts"; +import { resolveCredentialForPrint } from "./cli/credential-print.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { selectSession } from "./cli/session-picker.ts"; import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; -import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; +import { APP_NAME, ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { type AgentSessionRuntimeDiagnostic, @@ -32,6 +40,7 @@ import { createAgentSessionServices, } from "./core/agent-session-services.ts"; import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; +import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; @@ -127,17 +136,17 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean { return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); } -async function runCredentialPrintCommand(args: string[]): Promise { - if (isCredentialPrintHelp(args)) { - printCredentialPrintHelp(); +async function runAuthCommand(args: string[]): Promise { + if (isAuthCommandHelp(args)) { + printAuthCommandHelp(); return true; } - let command: CredentialPrintCommand | undefined; + let command: AuthCommand | undefined; try { - command = parseCredentialPrintCommand(args); + command = parseAuthCommand(args); } catch (error) { - const message = error instanceof CredentialPrintError ? error.message : "Failed to parse auth command"; + const message = error instanceof AuthCommandError ? error.message : "Failed to parse auth command"; console.error(chalk.red(`Error: ${message}`)); process.exitCode = 1; return true; @@ -145,30 +154,62 @@ async function runCredentialPrintCommand(args: string[]): Promise { if (!command) return false; const parsed = parseArgs(command.args); - if (parsed.diagnostics.length > 0) { - for (const diagnostic of parsed.diagnostics) { - console.error(chalk.red(`Error: ${diagnostic.message}`)); - } + if (parsed.unknownFlags.size > 0) { + const option = parsed.unknownFlags.keys().next().value; + console.error(chalk.red(`Unknown option --${option} for "${getAuthCommandName(command.kind)}".`)); + console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getAuthCommandUsage(command.kind)}".`)); process.exitCode = 1; return true; } - try { - validateCredentialPrintArgs(parsed); - const signal = AbortSignal.timeout(15_000); - const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal }); - const credential = await resolveCredentialForPrint( - parsed, - modelRuntime, - command.kind, - command.minExpiryMs, - signal, - ); - process.stdout.write(`${credential}\n`); + if (parsed.diagnostics.length > 0) { + throw new AuthCommandError(parsed.diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + } + if (command.kind !== "check") { + const signal = AbortSignal.timeout(15_000); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal }); + const credential = await resolveCredentialForPrint( + parsed, + modelRuntime, + command.kind, + command.minExpiryMs, + signal, + ); + process.stdout.write(`${credential}\n`); + return true; + } + + const requestedAuth = validateAuthCommandArgs(parsed, command.kind); + let result: AuthCheckResult; + let credential: string | undefined; + try { + const credentials = command.noRefresh ? new ReadOnlyAuthStorage() : AuthStorage.create(); + const modelRuntime = await createAuthCheckModelRuntime(credentials); + result = await checkProviderAuth(parsed, modelRuntime, { refresh: !command.noRefresh }); + if (command.credentials && result.status === "ready") { + credential = await getProviderCredential(result.provider, modelRuntime, credentials, { + refresh: !command.noRefresh, + }); + if (!credential) { + result = { status: "not_ready", provider: result.provider, reason: "credential_not_available" }; + } + } + } catch { + result = { + status: "invalid", + provider: requestedAuth.provider ?? requestedAuth.model!, + reason: "invalid_state", + }; + } + const output = command.json + ? JSON.stringify({ ...result, ...(credential ? { credentials: credential } : {}) }) + : (credential ?? result.status); + process.stdout.write(`${output}\n`); + process.exitCode = result.status === "ready" ? 0 : result.status === "not_ready" ? 1 : 2; } catch (error) { - const message = error instanceof CredentialPrintError ? error.message : "Failed to resolve credential"; + const message = error instanceof AuthCommandError ? error.message : "Failed to resolve credential"; console.error(chalk.red(`Error: ${message}`)); - process.exitCode = 1; + process.exitCode = command.kind === "check" ? 2 : 1; } return true; } @@ -534,6 +575,10 @@ export async function main(args: string[], options?: MainOptions) { process.env.PI_SKIP_VERSION_CHECK = "1"; } + if (await runAuthCommand(args)) { + return; + } + if (process.platform === "win32") { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } @@ -561,10 +606,6 @@ export async function main(args: string[], options?: MainOptions) { return; } - if (await runCredentialPrintCommand(args)) { - return; - } - const parsed = parseArgs(args); if (parsed.diagnostics.length > 0) { for (const d of parsed.diagnostics) { diff --git a/packages/coding-agent/test/auth-check.test.ts b/packages/coding-agent/test/auth-check.test.ts new file mode 100644 index 00000000000..5f1ce632797 --- /dev/null +++ b/packages/coding-agent/test/auth-check.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryModelsStore } from "@earendil-works/pi-ai"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { parseArgs } from "../src/cli/args.ts"; +import { checkProviderAuth, createAuthCheckModelRuntime, getProviderCredential } from "../src/cli/auth-check.ts"; +import { parseAuthCommand } from "../src/cli/auth-command.ts"; +import { AuthStorage, ReadOnlyAuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRuntime } from "../src/core/model-runtime.ts"; + +const tempDir = join(tmpdir(), `pi-test-auth-check-${Date.now()}-${Math.random().toString(36).slice(2)}`); + +async function createRuntime(credentials: AuthStorage | ReadOnlyAuthStorage): Promise { + return ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + refreshOnCreate: false, + }); +} + +describe("auth check command", () => { + beforeEach(() => { + if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + }); + + test("reports a configured provider as ready", async () => { + const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } })); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "ready", + provider: "openai", + authType: "api_key", + }); + }); + + test("resolves the provider from --model", async () => { + const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } })); + + await expect(checkProviderAuth(parseArgs(["--model", "openai/gpt-5.5"]), runtime)).resolves.toEqual({ + status: "ready", + provider: "openai", + authType: "api_key", + }); + await expect( + checkProviderAuth(parseArgs(["--provider", "openai", "--model", "gpt-5.5"]), runtime), + ).resolves.toMatchObject({ status: "ready", provider: "openai" }); + }); + + test("reads credentials without refreshing OAuth when requested", async () => { + const apiCredentials = AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } }); + const apiRuntime = await createRuntime(apiCredentials); + await expect(getProviderCredential("openai", apiRuntime, apiCredentials, { refresh: false })).resolves.toBe( + "test-key", + ); + + const credentials = AuthStorage.inMemory({ + "openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 }, + }); + const oauthRuntime = await createRuntime(credentials); + const oauth = oauthRuntime.getProvider("openai-codex")?.auth.oauth; + if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); + const refresh = vi.fn(oauth.refresh); + oauth.refresh = refresh; + + await expect(getProviderCredential("openai-codex", oauthRuntime, credentials, { refresh: false })).resolves.toBe( + "old-token", + ); + expect(refresh).not.toHaveBeenCalled(); + }); + + test("refreshes OAuth by default", async () => { + const credentials = AuthStorage.inMemory({ + "openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 }, + }); + const runtime = await createRuntime(credentials); + const oauth = runtime.getProvider("openai-codex")?.auth.oauth; + if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); + const refresh = vi.fn(async () => ({ + type: "oauth" as const, + access: "fresh-token", + refresh: "refresh-token", + expires: Date.now() + 60 * 60 * 1000, + })); + oauth.refresh = refresh; + + await expect( + checkProviderAuth(parseArgs(["--provider", "openai-codex"]), runtime, { refresh: true }), + ).resolves.toMatchObject({ + status: "ready", + }); + expect(refresh).toHaveBeenCalledOnce(); + }); + + test("reports an unknown provider as not ready", async () => { + const runtime = await createRuntime(AuthStorage.inMemory()); + + await expect(checkProviderAuth(parseArgs(["--provider", "not-installed"]), runtime)).resolves.toEqual({ + status: "not_ready", + provider: "not-installed", + reason: "provider_not_found", + }); + }); + + test("does not treat an unresolved stored environment reference as configured", async () => { + const authPath = join(tempDir, "auth.json"); + writeFileSync(authPath, JSON.stringify({ openai: { type: "api_key", key: "$MISSING_AUTH_CHECK_KEY" } }), "utf-8"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "not_ready", + provider: "openai", + reason: "credentials_not_configured", + }); + }); + + test("reports malformed auth state as invalid", async () => { + const authPath = join(tempDir, "auth.json"); + writeFileSync(authPath, "{invalid-json", "utf-8"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({ + status: "invalid", + provider: "openai", + reason: "invalid_state", + }); + }); + + test("does not create an auth file or its parent directory", async () => { + const authPath = join(tempDir, "agent", "auth.json"); + const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath)); + + await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toMatchObject({ + status: "not_ready", + reason: "credentials_not_configured", + }); + expect(existsSync(authPath)).toBe(false); + expect(existsSync(join(tempDir, "agent"))).toBe(false); + }); + + test("accepts optional JSON output, credential output, and --no-refresh", () => { + expect(parseAuthCommand(["auth", "check", "--provider", "openai"])).toEqual({ + kind: "check", + args: ["--provider", "openai"], + json: false, + credentials: false, + noRefresh: false, + }); + expect( + parseAuthCommand(["auth", "check", "--json", "--credentials", "--no-refresh", "--provider", "openai"]), + ).toEqual({ + kind: "check", + args: ["--provider", "openai"], + json: true, + credentials: true, + noRefresh: true, + }); + }); + + test("creates an auth-check runtime without catalog storage", async () => { + const runtime = await createAuthCheckModelRuntime(AuthStorage.inMemory()); + expect(runtime.getProvider("openai")).toBeDefined(); + }); +}); diff --git a/packages/coding-agent/test/credential-print.test.ts b/packages/coding-agent/test/credential-print.test.ts index be17a1b3e26..a1ca56d9c7e 100644 --- a/packages/coding-agent/test/credential-print.test.ts +++ b/packages/coding-agent/test/credential-print.test.ts @@ -1,14 +1,11 @@ import { InMemoryModelsStore } from "@earendil-works/pi-ai"; import { describe, expect, test, vi } from "vitest"; import { parseArgs } from "../src/cli/args.ts"; -import { - CredentialPrintError, - isCredentialPrintHelp, - parseCredentialPrintCommand, - resolveCredentialForPrint, -} from "../src/cli/credential-print.ts"; +import { AuthCommandError, isAuthCommandHelp, parseAuthCommand } from "../src/cli/auth-command.ts"; +import { resolveCredentialForPrint } from "../src/cli/credential-print.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; +import { main } from "../src/main.ts"; async function createRuntime(credentials: AuthStorage): Promise { return ModelRuntime.create({ @@ -22,7 +19,7 @@ async function createRuntime(credentials: AuthStorage): Promise { describe("credential print commands", () => { test("prints a resolved API key", async () => { const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-api-key" } })); - const args = parseArgs(["--model", "gpt-5.5"]); + const args = parseArgs(["--provider", "openai"]); await expect(resolveCredentialForPrint(args, runtime, "api_key")).resolves.toBe("test-api-key"); }); @@ -38,7 +35,7 @@ describe("credential print commands", () => { }, }), ); - const args = parseArgs(["--provider", "kimi-coding", "--model", "kimi-for-coding"]); + const args = parseArgs(["--provider", "kimi-coding"]); await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("header-test-token"); }); @@ -62,13 +59,31 @@ describe("credential print commands", () => { const oauth = runtime.getProvider("openai-codex")?.auth.oauth; if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); oauth.refresh = refresh; - const args = parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]); + const args = parseArgs(["--provider", "openai-codex"]); await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("fresh-test-token"); expect(refresh).toHaveBeenCalledOnce(); expect(await storage.read("openai-codex")).toMatchObject({ access: "fresh-test-token" }); }); + test("reports unknown auth options like package commands", async () => { + const originalExitCode = process.exitCode; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + process.exitCode = undefined; + await main(["auth", "check", "--provider", "openai-codex", "--credentails"]); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain('Unknown option --credentails for "auth check".'); + expect(stderr).toContain( + 'Use "pi --help" or "pi auth check --provider [--json] [--credentials] [--no-refresh]".', + ); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + } + }); + test("parses credential commands and rejects invalid arguments or credential types", async () => { const runtime = await createRuntime( AuthStorage.inMemory({ @@ -81,24 +96,35 @@ describe("credential print commands", () => { }), ); - expect(parseCredentialPrintCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({ + expect(parseAuthCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({ kind: "api_key", args: ["--provider", "openai"], + json: false, + credentials: false, + noRefresh: false, }); - expect(parseCredentialPrintCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" }); - expect(parseCredentialPrintCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({ + expect(parseAuthCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" }); + expect(parseAuthCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({ kind: "bearer_token", args: [], + json: false, + credentials: false, + noRefresh: false, minExpiryMs: 30 * 60_000, }); - expect(() => parseCredentialPrintCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow( + expect(() => parseAuthCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow( "only supported by print-bearer-token", ); - expect(isCredentialPrintHelp(["auth", "--help"])).toBe(true); - expect(() => parseCredentialPrintCommand(["auth", "unknown"])).toThrow(CredentialPrintError); - await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow("requires --model"); + expect(isAuthCommandHelp(["auth", "--help"])).toBe(true); + expect(isAuthCommandHelp(["auth", "print-api-key", "--help"])).toBe(true); + expect(isAuthCommandHelp(["auth", "print-bearer-token", "-h"])).toBe(true); + expect(isAuthCommandHelp(["auth", "check", "--help"])).toBe(true); + expect(() => parseAuthCommand(["auth", "unknown"])).toThrow(AuthCommandError); + await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow( + "requires --provider or --model ", + ); await expect( - resolveCredentialForPrint(parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]), runtime, "api_key"), + resolveCredentialForPrint(parseArgs(["--provider", "openai-codex"]), runtime, "api_key"), ).rejects.toThrow("configured with OAuth"); }); }); From 6fb2d766aee340bed96fe81d55603c1670b3af2a Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Thu, 6 Aug 2026 21:05:46 +0300 Subject: [PATCH 016/284] feat(coding-agent): add configurable Harness factory (#7686) * feat(coding-agent): add Harness construction factory * feat(coding-agent): configure Harness construction factory * feat(coding-agent): expose Harness JSONL session file * fix(agent): remove redundant bash environment test --- .../agent/test/harness/nodejs-env.test.ts | 31 ++ .../coding-agent/src/server/create-harness.ts | 159 ++++++++ .../test/server/create-harness.test.ts | 352 ++++++++++++++++++ 3 files changed, 542 insertions(+) create mode 100644 packages/coding-agent/src/server/create-harness.ts create mode 100644 packages/coding-agent/test/server/create-harness.test.ts diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index 09b706516fd..becaa8fc3b4 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -289,6 +289,37 @@ describe("NodeExecutionEnv", () => { expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); }); + it.each([ + ["a missing override preserves the base value", undefined, "x:/stale/parent.jsonl"], + ["an empty override shadows the base value", { PI_SESSION_FILE: "" }, "x:"], + [ + "a string override replaces the base value", + { PI_SESSION_FILE: "/sessions/current.jsonl" }, + "x:/sessions/current.jsonl", + ], + ] as const)( + "applies string shell environment overrides when %s", + async (_description, overrides, expectedSessionFile) => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ + cwd: root, + shellEnv: { + PI_SESSION_FILE: "/stale/parent.jsonl", + PI_CODING_AGENT: "true", + PI_NODE_ENV_PRESERVED_TEST: "preserved", + }, + }); + const result = getOrThrow( + await env.exec( + `printf '%s:%s|%s|%s' "\${PI_SESSION_FILE+x}" "\${PI_SESSION_FILE-}" "$PI_CODING_AGENT" "$PI_NODE_ENV_PRESERVED_TEST"`, + { env: overrides }, + ), + ); + + expect(result.stdout).toBe(`${expectedSessionFile}|true|preserved`); + }, + ); + it("can replace rather than inherit the default shell environment", async () => { const root = createTempDir(); const inheritedKey = "PI_NODE_ENV_INHERITED_TEST"; diff --git a/packages/coding-agent/src/server/create-harness.ts b/packages/coding-agent/src/server/create-harness.ts new file mode 100644 index 00000000000..80294fec23e --- /dev/null +++ b/packages/coding-agent/src/server/create-harness.ts @@ -0,0 +1,159 @@ +import { + AgentHarness, + type AgentHarnessOptions, + type AgentHarnessTool, + createBashTool, + createEditTool, + createReadTool, + createWriteTool, + type ExecutionEnv, + type ExecutionToolContext, + type HarnessTool, +} from "@earendil-works/pi-agent-core"; +import type { Static, TSchema } from "typebox"; +import { type BuildSystemPromptOptions, buildSystemPrompt } from "../core/system-prompt.ts"; +import { bashToolSystemPromptContribution } from "../core/tools/bash.ts"; +import { editToolSystemPromptContribution } from "../core/tools/edit.ts"; +import { readToolSystemPromptContribution } from "../core/tools/read.ts"; +import { writeToolSystemPromptContribution } from "../core/tools/write.ts"; + +export interface CodingAgentHarnessTool extends HarnessTool { + promptSnippet?: string; + promptGuidelines?: readonly string[]; +} + +function createCodingAgentHarnessTool( + tool: AgentHarnessTool, + context: ExecutionToolContext, + prompt: Required>, +): CodingAgentHarnessTool { + return { + ...tool, + ...prompt, + execute: (toolCallId, params, signal, onUpdate) => + tool.execute(toolCallId, params as Static, signal, onUpdate, context), + }; +} + +export interface CreateCodingAgentHarnessOptions extends Omit { + env: ExecutionEnv; + bashCommandPrefix?: string; + /** Path to the JSONL session file exposed to default bash commands as PI_SESSION_FILE. */ + sessionFile?: string; + tools?: CodingAgentHarnessTool[]; + systemPromptOptions?: Omit; +} + +export interface BuildCodingAgentHarnessSystemPromptOptions { + cwd: string; + tools: readonly CodingAgentHarnessTool[]; + activeToolNames: readonly string[]; + systemPromptOptions?: CreateCodingAgentHarnessOptions["systemPromptOptions"]; +} + +export function buildCodingAgentHarnessSystemPrompt(options: BuildCodingAgentHarnessSystemPromptOptions): string { + const activeTools = options.activeToolNames.flatMap((name) => { + const tool = options.tools.find((candidate) => candidate.name === name); + return tool ? [tool] : []; + }); + const toolSnippets = Object.fromEntries( + activeTools.flatMap((tool) => { + const promptSnippet = tool.promptSnippet + ?.replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return promptSnippet ? [[tool.name, promptSnippet]] : []; + }), + ); + const promptGuidelines = activeTools.flatMap((tool) => tool.promptGuidelines ?? []); + return buildSystemPrompt({ + ...options.systemPromptOptions, + cwd: options.cwd, + selectedTools: activeTools.map((tool) => tool.name), + toolSnippets, + promptGuidelines, + }); +} + +export async function createCodingAgentHarness(options: CreateCodingAgentHarnessOptions) { + const { + env, + bashCommandPrefix, + sessionFile, + systemPromptOptions, + tools: providedTools, + activeToolNames: providedActiveToolNames, + systemPrompt: providedSystemPrompt, + ...harnessOptions + } = options; + let harness: AgentHarness | undefined; + const getHarness = (): AgentHarness => { + if (!harness) throw new Error("Coding-agent Harness callback ran before Harness initialization"); + return harness; + }; + let tools = providedTools; + if (tools === undefined) { + const metadata = await options.session.getMetadata(); + const toolContext = { env } satisfies ExecutionToolContext; + tools = [ + createCodingAgentHarnessTool(createReadTool(), toolContext, { + promptSnippet: readToolSystemPromptContribution.snippet, + promptGuidelines: readToolSystemPromptContribution.guidelines, + }), + createCodingAgentHarnessTool( + createBashTool({ + commandPrefix: bashCommandPrefix, + prepare: async (execution) => { + const currentHarness = getHarness(); + const [model, thinkingLevel] = await Promise.all([ + currentHarness.getModel(), + currentHarness.getThinkingLevel(), + ]); + execution.env.PI_SESSION_ID = metadata.id; + execution.env.PI_SESSION_FILE = sessionFile ?? ""; + execution.env.PI_PROVIDER = model.provider; + execution.env.PI_MODEL = model.id; + execution.env.PI_REASONING_LEVEL = thinkingLevel; + }, + }), + toolContext, + { + promptSnippet: bashToolSystemPromptContribution.snippet, + promptGuidelines: bashToolSystemPromptContribution.guidelines, + }, + ), + createCodingAgentHarnessTool(createEditTool(), toolContext, { + promptSnippet: editToolSystemPromptContribution.snippet, + promptGuidelines: editToolSystemPromptContribution.guidelines, + }), + createCodingAgentHarnessTool(createWriteTool(), toolContext, { + promptSnippet: writeToolSystemPromptContribution.snippet, + promptGuidelines: writeToolSystemPromptContribution.guidelines, + }), + ]; + } + const activeToolNames = [...(providedActiveToolNames ?? tools.map((tool) => tool.name))]; + const systemPrompt = + providedSystemPrompt ?? + (async () => { + const currentHarness = getHarness(); + const [currentTools, currentActiveToolNames] = await Promise.all([ + currentHarness.getTools(), + currentHarness.getActiveTools(), + ]); + return buildCodingAgentHarnessSystemPrompt({ + cwd: env.cwd, + tools: currentTools, + activeToolNames: currentActiveToolNames, + systemPromptOptions, + }); + }); + const created = await AgentHarness.create({ + ...harnessOptions, + tools, + activeToolNames, + systemPrompt, + }); + harness = created.harness; + return created; +} diff --git a/packages/coding-agent/test/server/create-harness.test.ts b/packages/coding-agent/test/server/create-harness.test.ts new file mode 100644 index 00000000000..9073ef00f57 --- /dev/null +++ b/packages/coding-agent/test/server/create-harness.test.ts @@ -0,0 +1,352 @@ +import { + AgentHarness, + type AgentHarnessOptions, + type ExecutionError, + type HarnessTool, + InMemorySessionStorage, + type Result, + Session, + type ShellExecOptions, +} from "@earendil-works/pi-agent-core"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { createModels } from "@earendil-works/pi-ai"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { Type } from "typebox"; +import { describe, expect, test, vi } from "vitest"; +import { + buildCodingAgentHarnessSystemPrompt, + type CodingAgentHarnessTool, + createCodingAgentHarness, +} from "../../src/server/create-harness.ts"; + +class CapturingExecutionEnv extends NodeExecutionEnv { + executionOverrides: Record | undefined; + + override async exec( + command: string, + options?: ShellExecOptions, + ): Promise> { + this.executionOverrides = options?.env; + return super.exec(command, options); + } +} + +async function resolveSystemPrompt(systemPrompt: AgentHarnessOptions["systemPrompt"]): Promise { + if (typeof systemPrompt === "string") return systemPrompt; + if (systemPrompt === undefined) throw new Error("Expected a system prompt callback"); + return systemPrompt(); +} + +function createPromptTool(name: string, promptSnippet?: string, promptGuidelines?: string[]): CodingAgentHarnessTool { + return { + name, + label: name, + description: `${name} description`, + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), + promptSnippet, + promptGuidelines, + }; +} + +const defaultPromptTools = [ + createPromptTool("read", "Read file contents", ["Use read to examine files instead of cat or sed."]), + createPromptTool("bash", "Execute bash commands (ls, grep, find, etc.)", [ + "Inspect PI_* environment variables for current model and session details.", + ]), + createPromptTool("edit", "Edit files", ["Edit carefully."]), + createPromptTool("write", "Create or overwrite files", ["Use write only for new files or complete rewrites."]), +]; + +describe("coding-agent Harness construction", () => { + test("adds coding-agent policy to explicit Harness options", async () => { + const session = new Session(new InMemorySessionStorage({ id: "harness-session", createdAt: 1 })); + const env = new NodeExecutionEnv({ cwd: "/workspace" }); + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + thinkingLevel: "high", + env, + streamOptions: { maxTokens: 123 }, + retry: { enabled: true, maxRetries: 2, baseDelayMs: 10 }, + steeringMode: "all", + followUpMode: "all", + }); + try { + expect(created.suspended).toEqual([]); + expect(await created.harness.getActiveTools()).toEqual(["read", "bash", "edit", "write"]); + expect((await created.harness.getTools()).map((tool) => tool.name)).toEqual(["read", "bash", "edit", "write"]); + expect(await created.harness.getStreamOptions()).toEqual({ maxTokens: 123 }); + expect(await created.harness.getRetryPolicy()).toEqual({ enabled: true, maxRetries: 2, baseDelayMs: 10 }); + expect(await created.harness.getSteeringMode()).toBe("all"); + expect(await created.harness.getFollowUpMode()).toBe("all"); + } finally { + await created.harness.close(); + await env.cleanup(); + } + }); + + test("preserves coding-agent prompt snippets and guideline order", () => { + const prompt = buildCodingAgentHarnessSystemPrompt({ + cwd: "/workspace", + tools: defaultPromptTools, + activeToolNames: ["read", "bash", "edit", "write"], + }); + expect(prompt).toContain("- read: Read file contents"); + expect(prompt).toContain("- bash: Execute bash commands (ls, grep, find, etc.)"); + expect(prompt).toContain("Use read to examine files instead of cat or sed."); + expect(prompt).toContain("Inspect PI_* environment variables for current model and session details."); + expect(prompt.indexOf("Use read to examine files")).toBeLessThan( + prompt.indexOf("Inspect PI_* environment variables"), + ); + }); + + test("preserves caller-supplied tools and activation", async () => { + const session = new Session(new InMemorySessionStorage({ id: "custom-harness-session", createdAt: 1 })); + const env = new NodeExecutionEnv({ cwd: "/workspace" }); + const customTool: HarnessTool = { + name: "inspect", + label: "inspect", + description: "Inspect the configured service", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), + }; + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + env, + tools: [customTool], + activeToolNames: [], + systemPrompt: "Server-owned prompt", + }); + try { + expect((await created.harness.getTools()).map((tool) => tool.name)).toEqual(["inspect"]); + expect(await created.harness.getActiveTools()).toEqual([]); + } finally { + await created.harness.close(); + await env.cleanup(); + } + }); + + test("sets the optional session file in the default bash tool environment", async () => { + const session = new Session(new InMemorySessionStorage({ id: "session-file-harness", createdAt: 1 })); + const env = new CapturingExecutionEnv({ + cwd: process.cwd(), + shellEnv: { PI_SESSION_FILE: "/stale/parent.jsonl", PI_CODING_AGENT: "true" }, + }); + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + thinkingLevel: "high", + env, + sessionFile: "/sessions/current.jsonl", + }); + try { + const bash = (await created.harness.getTools()).find((tool) => tool.name === "bash"); + if (!bash) throw new Error("Expected the default bash tool"); + + const result = await bash.execute("bash-call", { + command: `printf '%s' "$PI_SESSION_ID|$PI_SESSION_FILE|$PI_PROVIDER|$PI_MODEL|$PI_REASONING_LEVEL|$PI_CODING_AGENT"`, + }); + + expect(env.executionOverrides).toEqual({ + PI_SESSION_ID: "session-file-harness", + PI_SESSION_FILE: "/sessions/current.jsonl", + PI_PROVIDER: "google", + PI_MODEL: "gemini-2.5-flash", + PI_REASONING_LEVEL: "high", + }); + expect(result.content).toEqual([ + { + type: "text", + text: "session-file-harness|/sessions/current.jsonl|google|gemini-2.5-flash|high|true", + }, + ]); + } finally { + await created.harness.close(); + await env.cleanup(); + } + }); + + test("keeps bash PI model variables synchronized with Harness state", async () => { + const session = new Session(new InMemorySessionStorage({ id: "dynamic-bash-session", createdAt: 1 })); + const env = new CapturingExecutionEnv({ + cwd: process.cwd(), + shellEnv: { PI_SESSION_FILE: "/stale/parent.jsonl", PI_CODING_AGENT: "true" }, + }); + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + thinkingLevel: "high", + env, + }); + try { + await created.harness.setModel(getModel("anthropic", "claude-sonnet-4-5")); + await created.harness.setThinkingLevel("low"); + const bash = (await created.harness.getTools()).find((tool) => tool.name === "bash"); + if (!bash) throw new Error("Expected the default bash tool"); + + const result = await bash.execute("bash-call", { + command: `printf '%s:%s' "\${PI_SESSION_FILE+x}" "$PI_SESSION_ID|$PI_PROVIDER|$PI_MODEL|$PI_REASONING_LEVEL|$PI_CODING_AGENT"`, + }); + + expect(env.executionOverrides).toEqual({ + PI_SESSION_ID: "dynamic-bash-session", + PI_SESSION_FILE: "", + PI_PROVIDER: "anthropic", + PI_MODEL: "claude-sonnet-4-5", + PI_REASONING_LEVEL: "low", + }); + expect(Object.hasOwn(env.executionOverrides ?? {}, "PI_SESSION_FILE")).toBe(true); + expect(env.executionOverrides?.PI_SESSION_FILE).toBe(""); + expect(result.content).toEqual([ + { + type: "text", + text: "x:dynamic-bash-session|anthropic|claude-sonnet-4-5|low|true", + }, + ]); + } finally { + await created.harness.close(); + await env.cleanup(); + } + }); + + test("builds each default system prompt from current Harness tool metadata", async () => { + const originalCreate = AgentHarness.create.bind(AgentHarness); + let configuredSystemPrompt: AgentHarnessOptions["systemPrompt"]; + const createSpy = vi.spyOn(AgentHarness, "create").mockImplementation(async (options) => { + configuredSystemPrompt = options.systemPrompt; + return originalCreate(options); + }); + const session = new Session(new InMemorySessionStorage({ id: "dynamic-prompt-session", createdAt: 1 })); + const env = new NodeExecutionEnv({ cwd: "/workspace" }); + try { + const created = await createCodingAgentHarness({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + env, + }); + createSpy.mockRestore(); + try { + const initialPrompt = await resolveSystemPrompt(configuredSystemPrompt); + expect(initialPrompt).toContain("- read: Read file contents"); + expect(initialPrompt).toContain("- bash: Execute bash commands (ls, grep, find, etc.)"); + expect(initialPrompt).toContain("- edit: Make precise file edits with exact text replacement"); + expect(initialPrompt).toContain("- write: Create or overwrite files"); + + await created.harness.setActiveTools(["write"]); + const writePrompt = await resolveSystemPrompt(configuredSystemPrompt); + expect(writePrompt).toContain("- write: Create or overwrite files"); + expect(writePrompt).not.toContain("- read:"); + expect(writePrompt).not.toContain("- bash:"); + + const read = (await created.harness.getTools()).find((tool) => tool.name === "read"); + if (!read) throw new Error("Expected the default read tool"); + await created.harness.setTools([read]); + const readPrompt = await resolveSystemPrompt(configuredSystemPrompt); + expect(readPrompt).toContain("- read: Read file contents"); + expect(readPrompt).not.toContain("- write:"); + + const inspectTool: CodingAgentHarnessTool = { + name: "inspect", + label: "inspect", + description: "Inspect the configured service", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), + promptSnippet: " Inspect\nthe configured service ", + promptGuidelines: ["Use inspect for service diagnostics."], + }; + await created.harness.setTools([inspectTool]); + const inspectPrompt = await resolveSystemPrompt(configuredSystemPrompt); + expect(inspectPrompt).toContain("- inspect: Inspect the configured service"); + expect(inspectPrompt).toContain("Use inspect for service diagnostics."); + } finally { + await created.harness.close(); + await env.cleanup(); + } + } finally { + createSpy.mockRestore(); + } + }); + + test("omits active custom tools without prompt metadata from the textual tools section", () => { + const prompt = buildCodingAgentHarnessSystemPrompt({ + cwd: "/workspace", + tools: [createPromptTool("hidden")], + activeToolNames: ["hidden"], + }); + + expect(prompt).toContain("Available tools:\n(none)"); + expect(prompt).not.toContain("- hidden:"); + expect(prompt).not.toContain("hidden description"); + }); + + test.each([ + [ + "bash", + "Execute bash commands (ls, grep, find, etc.)", + "Inspect PI_* environment variables for current model and session details.", + ], + ["read", "Read file contents", "Use read to examine files instead of cat or sed."], + [ + "edit", + "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", + "Use edit for precise changes (edits[].oldText must match exactly)", + ], + ["write", "Create or overwrite files", "Use write only for new files or complete rewrites."], + ] as const)( + "does not infer prompt metadata for a caller-supplied %s replacement", + (name, builtInSnippet, builtInGuideline) => { + const prompt = buildCodingAgentHarnessSystemPrompt({ + cwd: "/workspace", + tools: [createPromptTool(name)], + activeToolNames: [name], + }); + + expect(prompt).toContain("Available tools:\n(none)"); + expect(prompt).not.toContain(builtInSnippet); + expect(prompt).not.toContain(builtInGuideline); + }, + ); + + test("builds the default prompt from active tools and resolved prompt resources", () => { + const prompt = buildCodingAgentHarnessSystemPrompt({ + cwd: "/workspace", + tools: defaultPromptTools, + activeToolNames: ["write", "read"], + systemPromptOptions: { + contextFiles: [{ path: "/workspace/AGENTS.md", content: "Follow project policy." }], + skills: [ + { + name: "review", + description: "Review server changes", + filePath: "/skills/review/SKILL.md", + baseDir: "/skills/review", + sourceInfo: { + path: "/skills/review/SKILL.md", + source: "test", + scope: "temporary", + origin: "top-level", + }, + disableModelInvocation: false, + }, + ], + }, + }); + + expect(prompt).toContain("- write: Create or overwrite files"); + expect(prompt).toContain("- read: Read file contents"); + expect(prompt).not.toContain("- bash:"); + expect(prompt).not.toContain("Inspect PI_* environment variables"); + expect(prompt).toContain(''); + expect(prompt).toContain("review"); + expect(prompt.indexOf("Use write only for new files or complete rewrites.")).toBeLessThan( + prompt.indexOf("Use read to examine files instead of cat or sed."), + ); + }); +}); From 171c6b520bee23bed76fd34b730332f43067dddd Mon Sep 17 00:00:00 2001 From: Volkan Sagcan Date: Thu, 6 Aug 2026 22:25:00 +0200 Subject: [PATCH 017/284] fix(tui): correct multi-click text selection (#7733) --- packages/tui/src/tui-alt-screen.ts | 4 +-- packages/tui/test/tui-alt-screen.test.ts | 33 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 2032bf0f4a2..121f1a2074b 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -906,14 +906,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width); screenSelection = { start: { + ...selection.start, row: box.rect.y + selection.start.row - selection.start.scrollView.scrollTop, col: box.rect.x + selection.start.col, - scrollView: selection.start.scrollView, }, end: { + ...selection.end, row: box.rect.y + selection.end.row - selection.start.scrollView.scrollTop, col: box.rect.x + selection.end.col, - scrollView: selection.start.scrollView, }, }; } diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index c344927024d..cee169284c2 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -763,6 +763,39 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("does not append whitespace to double-click word highlighting", async () => { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("foo bar", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<0;3;1M"); + await terminal.waitForRender(); + + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("foo\x1b[27m"))); + tui.stop(); + }); + + it("highlights a complete whitespace segment during a word drag", async () => { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("foo bar", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<0;2;1M"); + terminal.sendInput("\x1b[<32;4;1M"); + await terminal.waitForRender(); + + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("foo \x1b[27m"))); + tui.stop(); + }); + it("selects whole words on double click, extends word drags, and selects lines on triple click", async () => { const terminal = new RecordingTerminal(20, 2); const tui = new TuiAltScreen(terminal); From a3e93ec85ea90ce94a305ab6c5ca96fba74baf24 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 22:30:13 +0200 Subject: [PATCH 018/284] feat(tui): add half-page transcript scrolling closes #7735 --- packages/coding-agent/docs/keybindings.md | 4 ++- packages/tui/CHANGELOG.md | 1 + packages/tui/src/keybindings.ts | 10 ++++++++ packages/tui/src/tui-alt-screen.ts | 8 ++++++ packages/tui/test/keybindings.test.ts | 2 ++ packages/tui/test/tui-alt-screen.test.ts | 30 +++++++++++++++++++++++ 6 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 5f5acc6a789..6c31b211d20 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -95,12 +95,14 @@ Fullscreen transcript bindings take precedence over editor bindings. The default | `pageUp`, `pageDown` | Editor | Transcript | | `ctrl+pageUp`, `ctrl+pageDown` | Editor | Editor | -This routing remains configurable through the ordinary action bindings. For example, `"tui.altScreen.pageUp": "ctrl+pageUp"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Setting `"tui.altScreen.pageUp": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action. +This routing remains configurable through the ordinary action bindings. For example, `"tui.altScreen.pageUp": "ctrl+pageUp"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Bind `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown` for smaller transcript steps while keeping the full-page bindings. Setting `"tui.altScreen.pageUp": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action. | Keybinding id | Default | Description | |--------|---------|-------------| | `tui.altScreen.pageUp` | `pageUp` | Scroll the transcript up by one page | | `tui.altScreen.pageDown` | `pageDown` | Scroll the transcript down by one page | +| `tui.altScreen.halfPageUp` | *(none)* | Scroll the transcript up by half a page | +| `tui.altScreen.halfPageDown` | *(none)* | Scroll the transcript down by half a page | | `tui.altScreen.previousPrompt` | `ctrl+shift+up` | Jump to the previous marked message | | `tui.altScreen.nextPrompt` | `ctrl+shift+down` | Jump to the next marked message | | `tui.altScreen.top` | `home` | Scroll to the beginning of the transcript | diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a1c785a9572..058ffb444f7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added unbound half-page transcript scrolling actions, `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown`, for fullscreen TUI keybindings ([#7735](https://github.com/earendil-works/pi/issues/7735)). - Added double-click word selection, word-aware drag selection, and triple-click line selection in the fullscreen TUI ([#7725](https://github.com/earendil-works/pi/issues/7725)). ### Fixed diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index d371d4c2fc3..b1a086d6763 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -44,6 +44,8 @@ export interface Keybindings { // Alternate-screen viewport navigation "tui.altScreen.pageUp": true; "tui.altScreen.pageDown": true; + "tui.altScreen.halfPageUp": true; + "tui.altScreen.halfPageDown": true; "tui.altScreen.previousPrompt": true; "tui.altScreen.nextPrompt": true; "tui.altScreen.top": true; @@ -157,6 +159,14 @@ export const TUI_KEYBINDINGS = { defaultKeys: "pageDown", description: "Scroll viewport down one page", }, + "tui.altScreen.halfPageUp": { + defaultKeys: [], + description: "Scroll viewport up half a page", + }, + "tui.altScreen.halfPageDown": { + defaultKeys: [], + description: "Scroll viewport down half a page", + }, "tui.altScreen.previousPrompt": { defaultKeys: "ctrl+shift+up", description: "Jump to previous semantic prompt", diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 121f1a2074b..cff48a261a5 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -427,6 +427,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } return { consume: true }; } + if (keybindings.matches(data, "tui.altScreen.halfPageUp")) { + if (!isRelease) this.scrollBy(-Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2))); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.halfPageDown")) { + if (!isRelease) this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2))); + return { consume: true }; + } if (keybindings.matches(data, "tui.altScreen.previousPrompt")) { if (!isRelease) this.scrollToPrompt(-1); return { consume: true }; diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index 811a121797b..c79a024aa0d 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -32,6 +32,8 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageUp"), ["pageUp"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageDown"), ["pageDown"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageUp"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index cee169284c2..750b58b9b1a 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -5,6 +5,7 @@ import { Image } from "../src/components/image.ts"; import { ScrollView } from "../src/components/scroll-view.ts"; import { Text } from "../src/components/text.ts"; import { VStack } from "../src/components/v-stack.ts"; +import { getKeybindings, KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts"; import { encodeKitty, hyperlink, @@ -352,6 +353,35 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("scrolls the transcript by half a page with custom bindings", async () => { + const originalKeybindings = getKeybindings(); + const terminal = new VirtualTerminal(20, 10); + const tui = new TuiAltScreen(terminal); + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.altScreen.halfPageUp": "ctrl+u", + "tui.altScreen.halfPageDown": "ctrl+d", + }), + ); + try { + tui.addChild(new Text(Array.from({ length: 30 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + + terminal.sendInput("\x15"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 15); + + terminal.sendInput("\x04"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + } finally { + tui.stop(); + setKeybindings(originalKeybindings); + } + }); + it("routes Ctrl-modified viewport navigation to the focused component", async () => { const terminal = new VirtualTerminal(20, 6); const tui = new TuiAltScreen(terminal); From 7d046bcf21cb934bc46cd374e531801454e558fc Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 22:36:26 +0200 Subject: [PATCH 019/284] fix(coding-agent): announce only verified npm releases --- .github/workflows/build-binaries.yml | 43 ++- AGENTS.md | 4 +- scripts/publish-release-announcement.mjs | 290 ++++++++++++++++++ scripts/publish-release-announcement.test.mjs | 62 ++++ scripts/publish.mjs | 28 +- scripts/release-packages.mjs | 13 + scripts/release.mjs | 15 +- 7 files changed, 418 insertions(+), 37 deletions(-) create mode 100644 scripts/publish-release-announcement.mjs create mode 100644 scripts/publish-release-announcement.test.mjs create mode 100644 scripts/release-packages.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 7927820663a..d76e6f6e993 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -270,11 +270,51 @@ jobs: - name: Publish npm packages run: node scripts/publish.mjs + announce-pi-dev-release: + runs-on: ubuntu-latest + needs: publish-npm + environment: pi-model-upload + concurrency: + group: announce-pi-dev-release + cancel-in-progress: false + permissions: + contents: read + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} + AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: 'true' + R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Verify AWS CLI + run: aws --version + + - name: Announce verified release on pi.dev + run: | + node scripts/publish-release-announcement.mjs \ + --version "${RELEASE_TAG#v}" \ + --bucket pi-artifacts \ + --endpoint "$R2_ENDPOINT" + publish-github-release: runs-on: ubuntu-latest needs: - stage-github-release - publish-npm + - announce-pi-dev-release permissions: contents: write env: @@ -305,8 +345,9 @@ jobs: - build - stage-github-release - publish-npm + - announce-pi-dev-release - publish-github-release - if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.publish-github-release.result != 'success') }} + if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.announce-pi-dev-release.result != 'success' || needs.publish-github-release.result != 'success') }} permissions: contents: write env: diff --git a/AGENTS.md b/AGENTS.md index 4c3a12d99e1..74b62808328 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,9 +154,9 @@ Attribution: The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed. -4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. +4. **CI verifies and announces the npm release**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. After publishing, `announce-pi-dev-release` verifies every public workspace package resolves at the exact release version and that its npm tarball is available, then writes the verified release marker to R2. `pi.dev/api/latest-version` reads that marker; it must never announce a release from npm before this job succeeds. -5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version. +5. **If CI publish or announcement fails**: inspect the failed job. The publish helper is idempotent and skips package versions already present on npm; the announcement job rechecks availability before updating the R2 marker. Rerun the failed job or workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version. ## User Override diff --git a/scripts/publish-release-announcement.mjs b/scripts/publish-release-announcement.mjs new file mode 100644 index 00000000000..238d868f84e --- /dev/null +++ b/scripts/publish-release-announcement.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +const RELEASES_PREFIX = "releases/v1"; +const REGISTRY_URL = "https://registry.npmjs.org"; +const RETRY_DELAY_MS = 5000; +const RETRY_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_POINTER_UPDATE_ATTEMPTS = 5; +const STABLE_SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +function parseArgs(args) { + const options = { + bucket: undefined, + endpoint: undefined, + sourceCommit: undefined, + version: undefined, + }; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg !== "--bucket" && arg !== "--endpoint" && arg !== "--source-commit" && arg !== "--version") { + throw new Error(`Unknown argument: ${arg}`); + } + const value = args[++index]; + if (!value) throw new Error(`${arg} requires a value`); + options[ + { "--bucket": "bucket", "--endpoint": "endpoint", "--source-commit": "sourceCommit", "--version": "version" }[arg] + ] = value; + } + + if (!options.bucket) throw new Error("--bucket is required"); + if (!options.endpoint) throw new Error("--endpoint is required"); + if (!options.version || !STABLE_SEMVER_RE.test(options.version)) { + throw new Error("--version must be a stable semver version"); + } + return options; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function resolvePackageFromNpm(pkg) { + const packageUrl = `${REGISTRY_URL}/${encodeURIComponent(pkg.name)}/${pkg.version}`; + const response = await fetch(packageUrl, { headers: { accept: "application/json" } }); + if (!response.ok) { + throw new Error(`${pkg.name}@${pkg.version}: npm registry returned ${response.status}`); + } + + const data = await response.json(); + if ( + typeof data !== "object" || + data === null || + Array.isArray(data) || + data.version !== pkg.version || + typeof data.dist !== "object" || + data.dist === null || + Array.isArray(data.dist) || + typeof data.dist.tarball !== "string" || + typeof data.dist.integrity !== "string" + ) { + throw new Error(`${pkg.name}@${pkg.version}: npm registry returned invalid metadata`); + } + + const tarball = await fetch(data.dist.tarball, { method: "HEAD" }); + if (!tarball.ok) { + throw new Error(`${pkg.name}@${pkg.version}: tarball returned ${tarball.status}`); + } + + return { + name: pkg.name, + version: pkg.version, + tarball: data.dist.tarball, + integrity: data.dist.integrity, + }; +} + +async function verifyPackagesAreAvailable(packages) { + const deadline = Date.now() + RETRY_TIMEOUT_MS; + let attempt = 0; + let failures = []; + + do { + attempt++; + const results = await Promise.allSettled(packages.map(resolvePackageFromNpm)); + failures = results.flatMap((result, index) => + result.status === "rejected" ? [`${packages[index].name}: ${result.reason}`] : [], + ); + if (failures.length === 0) { + console.log(`All ${packages.length} Pi packages are available from npm (attempt ${attempt}).`); + return results.map((result) => result.value); + } + + console.log(`Waiting for ${failures.length} Pi package${failures.length === 1 ? "" : "s"} on npm (attempt ${attempt}):`); + for (const failure of failures) console.log(` ${failure}`); + if (Date.now() < deadline) await sleep(RETRY_DELAY_MS); + } while (Date.now() < deadline); + + throw new Error(`Timed out waiting for Pi packages to become available from npm:\n${failures.map((failure) => ` ${failure}`).join("\n")}`); +} + +function gitSourceCommit() { + return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); +} + +function runAws(args, { allowNotFound = false, allowPreconditionFailure = false } = {}) { + const result = spawnSync("aws", args, { + encoding: "utf8", + env: { + ...process.env, + AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION || "auto", + AWS_EC2_METADATA_DISABLED: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status === 0) return result.stdout; + + const message = `${result.stdout}\n${result.stderr}`.trim(); + if (allowNotFound && /(?:404|NoSuchKey|Not Found)/i.test(message)) return undefined; + if (allowPreconditionFailure && /(?:412|PreconditionFailed|ConditionalRequestConflict)/i.test(message)) { + return undefined; + } + throw new Error(`aws ${args.slice(0, 2).join(" ")} failed:\n${message}`); +} + +function readLatestRelease(bucket, endpoint, outputPath) { + const head = runAws( + [ + "s3api", + "head-object", + "--bucket", + bucket, + "--key", + `${RELEASES_PREFIX}/latest.json`, + "--endpoint-url", + endpoint, + ], + { allowNotFound: true }, + ); + if (head === undefined) return undefined; + + const metadata = JSON.parse(head); + if (typeof metadata.ETag !== "string") { + throw new Error("Latest Pi release marker has no ETag."); + } + runAws([ + "s3api", + "get-object", + "--bucket", + bucket, + "--key", + `${RELEASES_PREFIX}/latest.json`, + "--endpoint-url", + endpoint, + outputPath, + ]); + const release = JSON.parse(readFileSync(outputPath, "utf8")); + if ( + typeof release !== "object" || + release === null || + Array.isArray(release) || + typeof release.version !== "string" || + !STABLE_SEMVER_RE.test(release.version) + ) { + throw new Error("Latest Pi release marker has an invalid version."); + } + return { etag: metadata.ETag, version: release.version }; +} + +function putJson(bucket, endpoint, path, key, cacheControl, condition) { + const args = [ + "s3api", + "put-object", + "--bucket", + bucket, + "--key", + key, + "--body", + path, + "--endpoint-url", + endpoint, + "--content-type", + "application/json; charset=utf-8", + "--cache-control", + cacheControl, + ]; + if (condition?.etag) args.push("--if-match", condition.etag); + if (condition?.missing) args.push("--if-none-match", "*"); + return runAws(args, { allowPreconditionFailure: Boolean(condition) }) !== undefined; +} + +export function compareReleaseVersions(left, right) { + const leftMatch = STABLE_SEMVER_RE.exec(left); + const rightMatch = STABLE_SEMVER_RE.exec(right); + if (!leftMatch || !rightMatch) throw new Error("Release versions must be stable semver versions."); + + for (const index of [1, 2, 3]) { + const difference = Number(leftMatch[index]) - Number(rightMatch[index]); + if (difference !== 0) return difference; + } + return 0; +} + +export async function advanceLatestRelease(version, readLatest, writeLatest) { + for (let attempt = 0; attempt < MAX_POINTER_UPDATE_ATTEMPTS; attempt++) { + const current = await readLatest(); + if (current && compareReleaseVersions(version, current.version) <= 0) { + return { advanced: false, version: current.version }; + } + + const updated = await writeLatest(current ? { etag: current.etag } : { missing: true }); + if (updated) { + return { advanced: true, version }; + } + } + throw new Error(`Could not advance the Pi release marker to ${version} after ${MAX_POINTER_UPDATE_ATTEMPTS} attempts.`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const packages = getPublicWorkspacePackages(); + for (const pkg of packages) { + if (pkg.version !== options.version) { + throw new Error(`${pkg.name} is ${pkg.version}; expected ${options.version}`); + } + } + + const publishedPackages = await verifyPackagesAreAvailable(packages); + const release = { + schemaVersion: 1, + version: options.version, + sourceCommit: options.sourceCommit ?? gitSourceCommit(), + publishedAt: new Date().toISOString(), + packages: publishedPackages, + }; + const temporaryDirectory = mkdtempSync(join(tmpdir(), "pi-release-announcement-")); + try { + const releasePath = join(temporaryDirectory, "release.json"); + const latestPath = join(temporaryDirectory, "latest.json"); + writeFileSync(releasePath, `${JSON.stringify(release, null, "\t")}\n`); + if ( + !putJson( + options.bucket, + options.endpoint, + releasePath, + `${RELEASES_PREFIX}/releases/${options.version}.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ) + ) { + console.log(`Release record ${options.version} already exists.`); + } + + writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`); + const result = await advanceLatestRelease( + options.version, + () => readLatestRelease(options.bucket, options.endpoint, join(temporaryDirectory, "latest-current.json")), + (condition) => + putJson( + options.bucket, + options.endpoint, + latestPath, + `${RELEASES_PREFIX}/latest.json`, + "no-store", + condition, + ), + ); + console.log( + result.advanced + ? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json` + : `Pi ${result.version} is already the latest announced release.`, + ); + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/publish-release-announcement.test.mjs b/scripts/publish-release-announcement.test.mjs new file mode 100644 index 00000000000..1a51146dd7d --- /dev/null +++ b/scripts/publish-release-announcement.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { advanceLatestRelease, compareReleaseVersions } from "./publish-release-announcement.mjs"; + +test("compares stable release versions numerically", () => { + assert.ok(compareReleaseVersions("0.85.0", "0.84.9") > 0); + assert.ok(compareReleaseVersions("0.84.10", "0.84.9") > 0); + assert.equal(compareReleaseVersions("0.84.0", "0.84.0"), 0); + assert.throws(() => compareReleaseVersions("0.85.0-beta.1", "0.84.0")); +}); + +test("does not regress an existing newer release marker", async () => { + let writeCount = 0; + const result = await advanceLatestRelease( + "0.84.0", + async () => ({ etag: '"newer"', version: "0.85.0" }), + async () => { + writeCount++; + return true; + }, + ); + + assert.deepEqual(result, { advanced: false, version: "0.85.0" }); + assert.equal(writeCount, 0); +}); + +test("retries a lost conditional update and preserves a racing newer marker", async () => { + let readCount = 0; + let writeCount = 0; + const result = await advanceLatestRelease( + "0.84.0", + async () => { + readCount++; + return readCount === 1 + ? { etag: '"previous"', version: "0.83.0" } + : { etag: '"newer"', version: "0.85.0" }; + }, + async (condition) => { + writeCount++; + assert.deepEqual(condition, { etag: '"previous"' }); + return false; + }, + ); + + assert.deepEqual(result, { advanced: false, version: "0.85.0" }); + assert.equal(writeCount, 1); +}); + +test("creates a missing marker with an if-none-match condition", async () => { + let condition; + const result = await advanceLatestRelease( + "0.84.0", + async () => undefined, + async (value) => { + condition = value; + return true; + }, + ); + + assert.deepEqual(result, { advanced: true, version: "0.84.0" }); + assert.deepEqual(condition, { missing: true }); +}); diff --git a/scripts/publish.mjs b/scripts/publish.mjs index e680e25f973..0ce30944ec6 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -1,20 +1,11 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { join } from "node:path"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; -const packages = [ - { directory: "packages/telemetry", name: "@earendil-works/pi-telemetry" }, - { directory: "packages/ai", name: "@earendil-works/pi-ai" }, - { directory: "packages/agent", name: "@earendil-works/pi-agent-core" }, - { directory: "packages/protocol", name: "@earendil-works/pi-protocol" }, - { directory: "packages/client", name: "@earendil-works/pi-client" }, - { directory: "packages/session-backends/sqlite-node", name: "@earendil-works/pi-session-backend-sqlite-node" }, - { directory: "packages/server", name: "@earendil-works/pi-server" }, - { directory: "packages/tui", name: "@earendil-works/pi-tui" }, - { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, -]; +const packages = getPublicWorkspacePackages(); const dryRun = process.argv.includes("--dry-run"); const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run"); @@ -44,10 +35,6 @@ function run(command, args, options = {}) { return result; } -function readPackageJson(directory) { - return JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); -} - function assertBuildOutputExists(directory) { if (!existsSync(join(directory, "dist"))) { throw new Error(`${directory}/dist does not exist. Run npm run build before publishing.`); @@ -78,14 +65,7 @@ function isPublished(name, version) { throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`); } -const packageVersions = new Map(); -for (const pkg of packages) { - const packageJson = readPackageJson(pkg.directory); - if (packageJson.name !== pkg.name) { - throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`); - } - packageVersions.set(pkg.name, packageJson.version); -} +const packageVersions = new Map(packages.map((pkg) => [pkg.name, pkg.version])); const versions = [...new Set(packageVersions.values())]; if (versions.length !== 1) { diff --git a/scripts/release-packages.mjs b/scripts/release-packages.mjs new file mode 100644 index 00000000000..fadc4fc0828 --- /dev/null +++ b/scripts/release-packages.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { findPackageDirectories } from "./package-workspaces.mjs"; + +export function getPublicWorkspacePackages() { + return findPackageDirectories() + .map((directory) => ({ + directory, + ...JSON.parse(readFileSync(join(directory, "package.json"), "utf8")), + })) + .filter((pkg) => pkg.private !== true) + .map(({ directory, name, version }) => ({ directory, name, version })); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 422c08a8ab0..02e1fd0fc54 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -16,13 +16,14 @@ * 7. Commit and tag the release * 8. Add new [Unreleased] section to changelogs * 9. Commit next-cycle changelog updates - * 10. Push main and the tag to trigger CI publishing + * 10. Push main and the tag to trigger CI publication and verified pi.dev announcement */ import { execSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { findPackageDirectories } from "./package-workspaces.mjs"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; const RELEASE_TARGET = process.argv[2]; const BUMP_TYPES = new Set(["major", "minor", "patch"]); @@ -52,10 +53,7 @@ function getVersion() { } function assertPackagesAreRegisteredWithNpm() { - const packageNames = findPackageDirectories() - .map((directory) => JSON.parse(readFileSync(join(directory, "package.json"), "utf8"))) - .filter((pkg) => pkg.private !== true) - .map((pkg) => pkg.name); + const packageNames = getPublicWorkspacePackages().map((pkg) => pkg.name); const unregisteredPackages = []; console.log("Checking npm package registration..."); @@ -106,10 +104,7 @@ function shellQuote(value) { function removeStaleWorkspaceLockEntries() { const workspaceVersions = new Map( - findPackageDirectories() - .map((directory) => JSON.parse(readFileSync(join(directory, "package.json"), "utf8"))) - .filter((pkg) => pkg.private !== true) - .map((pkg) => [pkg.name, pkg.version]), + getPublicWorkspacePackages().map((pkg) => [pkg.name, pkg.version]), ); const lockPath = "package-lock.json"; const lock = JSON.parse(readFileSync(lockPath, "utf8")); @@ -283,4 +278,4 @@ run("git push origin main"); run(`git push origin v${version}`); console.log(); -console.log(`=== Prepared release v${version}; CI publishing starts after the tag push ===`); +console.log(`=== Prepared release v${version}; CI publication and pi.dev announcement start after the tag push ===`); From c96bfaccd00d85e946493bc009b9613e5890ca7a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 22:39:09 +0200 Subject: [PATCH 020/284] fix(coding-agent): support Windows fullscreen right-click paste --- packages/coding-agent/CHANGELOG.md | 4 +++ .../src/modes/interactive/interactive-mode.ts | 25 +++++++++++++++++- .../coding-agent/test/interactive-tui.test.ts | 21 +++++++++++++++ packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui-alt-screen.ts | 17 ++++++++++++ packages/tui/test/tui-alt-screen.test.ts | 26 +++++++++++++++++++ 6 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9bbad2e7e47..cd0f8afc854 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)). +### Fixed + +- Fixed right-click not pasting clipboard text in fullscreen mode on Windows. + ## [0.84.0] - 2026-08-06 ### New Features diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index a306d900bb1..912de637fe6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -336,13 +336,17 @@ interface InteractiveTuiOptions { showHardwareCursor: boolean; logDirectory: string; terminal?: Terminal; + onRightClickPaste?: () => void; } /** Composition root for selecting the interactive terminal renderer. */ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen { const terminal = options.terminal ?? new ProcessTerminal(); if (options.tuiMode === "fullscreen") { - return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser }); + return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { + openUrl: openBrowser, + onRightClickPaste: options.onRightClickPaste, + }); } return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory); } @@ -493,6 +497,9 @@ export class InteractiveMode { private customHeader: (Component & { dispose?(): void }) | undefined = undefined; private options: InteractiveModeOptions; + private readonly onRightClickPaste = (): void => { + void this.handleRightClickPaste(); + }; private autoTrustOnReloadCwd: string | undefined; private themeController: InteractiveThemeController; @@ -526,6 +533,7 @@ export class InteractiveMode { tuiMode, showHardwareCursor: this.settingsManager.getShowHardwareCursor(), logDirectory: getAgentDir(), + onRightClickPaste: this.onRightClickPaste, }); this.ui = createInteractiveTuiReference(() => this.renderer); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); @@ -794,6 +802,7 @@ export class InteractiveMode { showHardwareCursor, logDirectory: getAgentDir(), terminal, + onRightClickPaste: this.onRightClickPaste, }); nextUi.setClearOnShrink(clearOnShrink); nextUi.onDebug = onDebug; @@ -2811,6 +2820,20 @@ export class InteractiveMode { }; } + private async handleRightClickPaste(): Promise { + const target = this.renderer.getFocusedComponent(); + const handleInput = target?.handleInput; + if (!target || !handleInput) return; + try { + const text = await readClipboardText(); + if (!text || this.renderer.getFocusedComponent() !== target) return; + handleInput.call(target, `\x1b[200~${text}\x1b[201~`); + this.ui.requestRender(); + } catch { + // Silently ignore clipboard errors (may not have permission, etc.) + } + } + private async handleClipboardPaste(): Promise { try { const image = await readClipboardImage(); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index 996e5e56c75..82a29edc664 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -128,6 +128,27 @@ describe("createInteractiveTui", () => { }); }); +describe("InteractiveMode right-click paste", () => { + it("feeds clipboard text to the focused component as a bracketed paste", async () => { + clipboardMocks.readClipboardText.mockResolvedValue("clipboard text"); + const handleInput = vi.fn<(data: string) => void>(); + const target = { render: () => [], invalidate: () => {}, handleInput } satisfies Component; + const requestRender = vi.fn(); + const context = { + renderer: { getFocusedComponent: () => target }, + ui: { requestRender }, + }; + const prototype = InteractiveMode.prototype as unknown as { + handleRightClickPaste(this: typeof context): Promise; + }; + + await prototype.handleRightClickPaste.call(context); + + expect(handleInput).toHaveBeenCalledWith("\x1b[200~clipboard text\x1b[201~"); + expect(requestRender).toHaveBeenCalledOnce(); + }); +}); + type CopyCommandContext = { session: { getLastAssistantText: () => string | undefined }; ui: ReturnType; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 058ffb444f7..c7553502199 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,7 @@ - Added unbound half-page transcript scrolling actions, `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown`, for fullscreen TUI keybindings ([#7735](https://github.com/earendil-works/pi/issues/7735)). - Added double-click word selection, word-aware drag selection, and triple-click line selection in the fullscreen TUI ([#7725](https://github.com/earendil-works/pi/issues/7725)). +- Added an optional right-click paste handler to the alternate-screen TUI, currently enabled on Windows. ### Fixed diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index cff48a261a5..fa7063e4c68 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -121,6 +121,8 @@ export interface TuiAltScreenOptions { mouse?: boolean; /** Open an OSC 8 hyperlink activated with a primary-button click. */ openUrl?: (url: string) => void; + /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ + onRightClickPaste?: () => void; } /** Alternate-screen TUI with a scrollable, application-owned viewport. */ @@ -156,6 +158,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private readonly wheelScrollLines: number; private readonly mouseEnabled: boolean; private readonly openUrl?: (url: string) => void; + private readonly onRightClickPaste?: () => void; constructor( terminal: Terminal, @@ -175,6 +178,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1)); this.mouseEnabled = options.mouse ?? true; this.openUrl = options.openUrl; + this.onRightClickPaste = options.onRightClickPaste; this.addInputListener((data) => this.handleViewportInput(data)); } @@ -406,6 +410,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } const mouseEvent = this.parseSgrMouseEvent(data); if (mouseEvent) { + if (this.handleRightClickPaste(mouseEvent)) return { consume: true }; const handled = this.handleScrollbarMouseEvent(mouseEvent); if (!this.scrollbarDrag) this.updateScrollbarHover(mouseEvent.x, mouseEvent.y); if (!handled) this.handleSelectionMouseEvent(mouseEvent); @@ -506,6 +511,18 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { }; } + private handleRightClickPaste(event: SgrMouseEvent): boolean { + if (!this.onRightClickPaste || process.platform !== "win32" || event.release || event.button !== 2) { + return false; + } + try { + this.onRightClickPaste(); + } catch { + // Clipboard paste is best-effort. + } + return true; + } + private getScrollbarTargetAt(x: number, y: number): ScrollbarTarget | undefined { if (this.hasOverlay() || !this.currentLayout) return undefined; for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 750b58b9b1a..dc84f2fd5f6 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -209,6 +209,32 @@ describe("TuiAltScreen", () => { } }); + it("invokes the right-click paste handler only on Windows", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + assert.ok(platformDescriptor); + const terminal = new VirtualTerminal(); + let pasteCount = 0; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + onRightClickPaste: () => { + pasteCount += 1; + }, + }); + try { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + tui.start(); + terminal.sendInput("\x1b[<2;1;1M"); + terminal.sendInput("\x1b[<2;1;1m"); + assert.strictEqual(pasteCount, 1); + + Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + terminal.sendInput("\x1b[<2;1;1M"); + assert.strictEqual(pasteCount, 1); + } finally { + tui.stop(); + Object.defineProperty(process, "platform", platformDescriptor); + } + }); + it("drags a visible scrollbar thumb and keeps it visible until release", async () => { const terminal = new RecordingTerminal(10, 5); const tui = new TuiAltScreen(terminal); From 4e64de6950a16bebf40f067a5e735576fb8a33dd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 22:49:47 +0200 Subject: [PATCH 021/284] fix(coding-agent): soften PI environment guideline Attempt to address #7128 without closing the issue. --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/core/tools/bash.ts | 2 +- .../test/agent-session-dynamic-tools.test.ts | 2 +- packages/coding-agent/test/sdk-session-manager.test.ts | 2 +- .../coding-agent/test/server/create-harness.test.ts | 10 +++++----- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cd0f8afc854..d4a342646a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)). +### Changed + +- Softened the bash tool's `PI_*` environment guideline in an attempt to reduce unnecessary inspection commands ([#7128](https://github.com/earendil-works/pi/issues/7128)). + ### Fixed - Fixed right-click not pasting clipboard text in fullscreen mode on Windows. diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 5bdfe2c4f89..d1c80487412 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -44,7 +44,7 @@ const bashSchema = Type.Object({ export const bashToolSystemPromptContribution = { snippet: "Execute bash commands (ls, grep, find, etc.)", - guidelines: ["Inspect PI_* environment variables for current model and session details."], + guidelines: ["You can inspect PI_* environment variables for current model and session details."], } as const; export type BashToolInput = Static; diff --git a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts index 719550fedb5..5edc1f6c097 100644 --- a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts @@ -74,7 +74,7 @@ describe("AgentSession dynamic tool registration", () => { const bashTool = session.agent.state.tools.find((tool) => tool.name === "bash")!; expect(session.systemPrompt).toContain( - "Inspect PI_* environment variables for current model and session details.", + "You can inspect PI_* environment variables for current model and session details.", ); await bashTool.execute("bash-env", { command: "printf ok" }); expect(sessionEnv).toMatchObject({ diff --git a/packages/coding-agent/test/sdk-session-manager.test.ts b/packages/coding-agent/test/sdk-session-manager.test.ts index d60ea9d2eee..8ab4c431dfe 100644 --- a/packages/coding-agent/test/sdk-session-manager.test.ts +++ b/packages/coding-agent/test/sdk-session-manager.test.ts @@ -105,7 +105,7 @@ describe("createAgentSession session manager defaults", () => { }); expect(session.sessionFile).toBeTruthy(); expect(session.systemPrompt).toContain( - "Inspect PI_* environment variables for current model and session details.", + "You can inspect PI_* environment variables for current model and session details.", ); const bashTool = session.agent.state.tools.find((tool) => tool.name === "bash"); diff --git a/packages/coding-agent/test/server/create-harness.test.ts b/packages/coding-agent/test/server/create-harness.test.ts index 9073ef00f57..c16f2d37cb3 100644 --- a/packages/coding-agent/test/server/create-harness.test.ts +++ b/packages/coding-agent/test/server/create-harness.test.ts @@ -52,7 +52,7 @@ function createPromptTool(name: string, promptSnippet?: string, promptGuidelines const defaultPromptTools = [ createPromptTool("read", "Read file contents", ["Use read to examine files instead of cat or sed."]), createPromptTool("bash", "Execute bash commands (ls, grep, find, etc.)", [ - "Inspect PI_* environment variables for current model and session details.", + "You can inspect PI_* environment variables for current model and session details.", ]), createPromptTool("edit", "Edit files", ["Edit carefully."]), createPromptTool("write", "Create or overwrite files", ["Use write only for new files or complete rewrites."]), @@ -96,9 +96,9 @@ describe("coding-agent Harness construction", () => { expect(prompt).toContain("- read: Read file contents"); expect(prompt).toContain("- bash: Execute bash commands (ls, grep, find, etc.)"); expect(prompt).toContain("Use read to examine files instead of cat or sed."); - expect(prompt).toContain("Inspect PI_* environment variables for current model and session details."); + expect(prompt).toContain("You can inspect PI_* environment variables for current model and session details."); expect(prompt.indexOf("Use read to examine files")).toBeLessThan( - prompt.indexOf("Inspect PI_* environment variables"), + prompt.indexOf("You can inspect PI_* environment variables"), ); }); @@ -290,7 +290,7 @@ describe("coding-agent Harness construction", () => { [ "bash", "Execute bash commands (ls, grep, find, etc.)", - "Inspect PI_* environment variables for current model and session details.", + "You can inspect PI_* environment variables for current model and session details.", ], ["read", "Read file contents", "Use read to examine files instead of cat or sed."], [ @@ -342,7 +342,7 @@ describe("coding-agent Harness construction", () => { expect(prompt).toContain("- write: Create or overwrite files"); expect(prompt).toContain("- read: Read file contents"); expect(prompt).not.toContain("- bash:"); - expect(prompt).not.toContain("Inspect PI_* environment variables"); + expect(prompt).not.toContain("You can inspect PI_* environment variables"); expect(prompt).toContain(''); expect(prompt).toContain("review"); expect(prompt.indexOf("Use write only for new files or complete rewrites.")).toBeLessThan( From 4688e4f92f38c9b82523454d8ba9eba7c8753700 Mon Sep 17 00:00:00 2001 From: Zhichao Li <57812115+zhichli@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:11:39 -0700 Subject: [PATCH 022/284] docs(coding-agent): reconcile keybinding behavior (#7729) --- packages/coding-agent/docs/keybindings.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 6c31b211d20..97e5365a682 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -10,7 +10,7 @@ After editing `keybindings.json`, run `/reload` in pi to apply the changes witho ## Key Format -`modifier+key` where modifiers are `ctrl`, `shift`, `alt` (combinable) and keys are: +`modifier+key` where modifiers are `ctrl`, `shift`, `alt`, `super` (combinable) and keys are: - **Letters:** `a-z` - **Digits:** `0-9` @@ -18,7 +18,9 @@ After editing `keybindings.json`, run `/reload` in pi to apply the changes witho - **Function:** `f1`-`f12` - **Symbols:** `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?` -Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1`, etc. +Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `super+k`, `ctrl+super+k`, `ctrl+1`, etc. + +`super` bindings require a terminal that reports the modifier separately, typically through the Kitty keyboard protocol. They may not work in terminals without that support. ## All Actions @@ -113,11 +115,11 @@ This routing remains configurable through the ordinary action bindings. For exam | Keybinding id | Default | Description | |--------|---------|-------------| | `app.interrupt` | `escape` | Cancel / abort | -| `app.clear` | `ctrl+c` | Clear editor | +| `app.clear` | `ctrl+c` | Clear editor (first) / exit (second) | | `app.exit` | `ctrl+d` | Exit (when editor empty) | | `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background | | `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) | -| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image from clipboard | +| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image or text from clipboard | ### Sessions From 7cb2a51c0512097900bb9bfdd5eec3249eebb843 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 00:21:32 +0200 Subject: [PATCH 023/284] fix(coding-agent): reduce automatic theme detection delay --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/theme/theme.ts | 14 +++++-- .../coding-agent/test/theme-detection.test.ts | 41 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d4a342646a8..81495eef5f7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - Softened the bash tool's `PI_*` environment guideline in an attempt to reduce unnecessary inspection commands ([#7128](https://github.com/earendil-works/pi/issues/7128)). +- Reduced worst-case automatic terminal theme detection delay from 200 ms to 100 ms by probing color-scheme and background support concurrently. ### Fixed diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index b3867862965..c6bb899d456 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -793,13 +793,21 @@ export async function detectTerminalThemeForAuto({ timeoutMs, env, }: TerminalAutoThemeDetectionOptions): Promise { + let colorSchemePromise: Promise | undefined; try { - const colorScheme = await ui.queryTerminalColorScheme?.({ timeoutMs }); + colorSchemePromise = ui.queryTerminalColorScheme?.({ timeoutMs }); + } catch { + // Fall back to OSC 11 / COLORFGBG detection when starting the color-scheme query fails. + } + const backgroundThemePromise = detectTerminalBackgroundTheme({ ui, timeoutMs, env }); + + try { + const colorScheme = await colorSchemePromise; if (colorScheme) return colorScheme; } catch { - // Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported. + // Fall back to the concurrently queried OSC 11 / COLORFGBG detection. } - return (await detectTerminalBackgroundTheme({ ui, timeoutMs, env })).theme; + return (await backgroundThemePromise).theme; } export function getDefaultTheme(): string { diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 6bdc597bd7e..c323ec6f858 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { detectTerminalBackgroundFromEnv, detectTerminalBackgroundTheme, + detectTerminalThemeForAuto, getThemeByName, getThemeForRgbColor, parseAutoThemeSetting, @@ -99,6 +100,46 @@ describe("detectTerminalBackgroundTheme", () => { }); }); +describe("detectTerminalThemeForAuto", () => { + it("starts both queries and returns the preferred color-scheme result without waiting", async () => { + let resolveColorScheme!: (theme: "dark" | "light" | undefined) => void; + let backgroundQueryStarted = false; + const detection = detectTerminalThemeForAuto({ + timeoutMs: 100, + ui: { + queryTerminalColorScheme: () => + new Promise((resolve) => { + resolveColorScheme = resolve; + }), + queryTerminalBackgroundColor: () => { + backgroundQueryStarted = true; + return new Promise(() => {}); + }, + }, + }); + + expect(backgroundQueryStarted).toBe(true); + resolveColorScheme("dark"); + await expect(detection).resolves.toBe("dark"); + }); + + it("uses the background result when the color-scheme query fails", async () => { + await expect( + detectTerminalThemeForAuto({ + timeoutMs: 100, + ui: { + async queryTerminalColorScheme(): Promise { + throw new Error("color-scheme query failed"); + }, + async queryTerminalBackgroundColor(): Promise { + return { r: 250, g: 250, b: 250 }; + }, + }, + }), + ).resolves.toBe("light"); + }); +}); + describe("theme color mode", () => { it("uses terminal capabilities", () => { setCapabilities({ images: null, trueColor: false, hyperlinks: false }); From 666d8972ff0b6da5067e05973249760964194769 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 00:40:44 +0200 Subject: [PATCH 024/284] fix(coding-agent): preserve TUI wrapper routing (closes #7731) --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 16 +++++++--- .../7731-tui-method-wrapping.test.ts | 31 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7731-tui-method-wrapping.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 81495eef5f7..e4cd9f10eb0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed extension TUI method wrappers recursing indefinitely when delegating to the original method ([#7731](https://github.com/earendil-works/pi/issues/7731)). - Fixed right-click not pasting clipboard text in fullscreen mode on Windows. ## [0.84.0] - 2026-08-06 diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 912de637fe6..720f9b0f9f7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -358,11 +358,19 @@ export function createInteractiveTuiReference(getTui: () => TUI): TUI { const tui = getTui(); const value = Reflect.get(tui, property, tui); if (typeof value !== "function") return value; + let methodTui = tui; + let method = value; return (...args: unknown[]) => { - const tui = getTui(); - const method = Reflect.get(tui, property, tui); - if (typeof method !== "function") throw new TypeError(`TUI property ${String(property)} is not callable`); - return Reflect.apply(method, tui, args); + const currentTui = getTui(); + if (currentTui !== methodTui) { + const currentMethod = Reflect.get(currentTui, property, currentTui); + if (typeof currentMethod !== "function") { + throw new TypeError(`TUI property ${String(property)} is not callable`); + } + methodTui = currentTui; + method = currentMethod; + } + return Reflect.apply(method, methodTui, args); }; }, set: (_target, property, value) => { diff --git a/packages/coding-agent/test/suite/regressions/7731-tui-method-wrapping.test.ts b/packages/coding-agent/test/suite/regressions/7731-tui-method-wrapping.test.ts new file mode 100644 index 00000000000..ae91246ab50 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7731-tui-method-wrapping.test.ts @@ -0,0 +1,31 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import { createInteractiveTuiReference } from "../../../src/modes/interactive/interactive-mode.ts"; + +describe("TUI method wrapping", () => { + it("calls the method captured before a replacement", () => { + const renderer = { + render: (width: number) => [`width: ${width}`], + } as unknown as TUI; + const tui = createInteractiveTuiReference(() => renderer); + const originalRender = tui.render; + tui.render = (width: number) => originalRender(width); + + expect(tui.render(80)).toEqual(["width: 80"]); + }); + + it("routes a captured method to a replacement renderer", () => { + const regularRequestRender = vi.fn(); + const fullscreenRequestRender = vi.fn(); + let renderer = { requestRender: regularRequestRender } as unknown as TUI; + const tui = createInteractiveTuiReference(() => renderer); + const requestRender = tui.requestRender; + + requestRender(); + renderer = { requestRender: fullscreenRequestRender } as unknown as TUI; + requestRender(); + + expect(regularRequestRender).toHaveBeenCalledOnce(); + expect(fullscreenRequestRender).toHaveBeenCalledOnce(); + }); +}); From 79f72fccecd1465930516fffbb53bfbd5b430da3 Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:23:06 +0100 Subject: [PATCH 025/284] docs(tui): Clarify TUI test runner and remove stale Vitest config (#7732) * docs[tui]: clarify Node test runner guidance * docs[tui]: preserve test guidance wording * docs[tui]: keep runner guidance focused --- AGENTS.md | 4 +++- packages/tui/vitest.config.ts | 7 ------- 2 files changed, 3 insertions(+), 8 deletions(-) delete mode 100644 packages/tui/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index 74b62808328..fb6446da53a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,9 @@ - After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests. - Never run `npm run build` or `npm test` unless requested by the user. -- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`. +- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: + - Vitest: `node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/specific.test.ts` + - `packages/tui` (`node:test`): `node --test test/specific.test.ts` - If you create or modify a test file, run it and iterate on test or implementation until it passes. - For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` + the faux provider. No real provider APIs, keys, or paid tokens. - Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` named `-.test.ts`. diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts deleted file mode 100644 index a90c176d921..00000000000 --- a/packages/tui/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["test/wrap-ansi.test.ts"], - }, -}); From ae1e410e37ce335fd14b50c0a590349bd9cfc848 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:24:00 +0200 Subject: [PATCH 026/284] fix: sqlite queries optimizations (#7727) * fix: findEntriesOnBranch filters * fix: branch entry membership index * fix: get log limit, add test * fix: record indexes * fix: use facts index * fix: composite desc index for cwd, created at --- .../src/sqlite/migrations/001_initial.sql | 7 +- .../sqlite-node/src/sqlite/repo.ts | 69 ++++++++++++------- .../src/sqlite/storage/branch-entries.ts | 42 ++++++----- .../sqlite-node/src/sqlite/storage/entries.ts | 5 +- .../sqlite-node/src/sqlite/storage/facts.ts | 25 ++++--- .../sqlite-node/src/sqlite/storage/lanes.ts | 9 ++- .../sqlite-node/test/branch-query.test.ts | 14 ++-- .../sqlite-node/test/facts-query.test.ts | 31 +++++++++ .../sqlite-node/test/log-query.test.ts | 35 ++++++++++ .../sqlite-node/test/migrations.test.ts | 12 ++++ 10 files changed, 185 insertions(+), 64 deletions(-) create mode 100644 packages/session-backends/sqlite-node/test/facts-query.test.ts create mode 100644 packages/session-backends/sqlite-node/test/log-query.test.ts diff --git a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql index e400a4367c7..74cbf349065 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql +++ b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql @@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS sessions ( ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd); +CREATE INDEX IF NOT EXISTS idx_sessions_cwd_created_at ON sessions(cwd, created_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id); CREATE TABLE IF NOT EXISTS entries ( @@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS branch_entries ( ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_seq ON branch_entries(session_id, branch_id, entry_seq); -CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id); +CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id, branch_id, entry_seq); CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_type_seq ON branch_entries(session_id, branch_id, entry_type, entry_seq); CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_custom_seq ON branch_entries(session_id, branch_id, custom_type, entry_seq); @@ -80,6 +80,9 @@ CREATE TABLE IF NOT EXISTS records ( ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS idx_records_session_seq ON records(session_id, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_lane_seq ON records(session_id, lane, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_type_seq ON records(session_id, type, seq); +CREATE INDEX IF NOT EXISTS idx_records_session_type_op_kind_seq ON records(session_id, type, op_kind, seq); CREATE INDEX IF NOT EXISTS idx_records_session_lane_type_seq ON records(session_id, lane, type, seq); CREATE INDEX IF NOT EXISTS idx_records_session_lane_type_op_kind_seq ON records(session_id, lane, type, op_kind, seq); CREATE INDEX IF NOT EXISTS idx_records_session_run_id_seq ON records(session_id, run_id, seq); diff --git a/packages/session-backends/sqlite-node/src/sqlite/repo.ts b/packages/session-backends/sqlite-node/src/sqlite/repo.ts index 8eb8ba96ca0..5bd2f259119 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/repo.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/repo.ts @@ -301,10 +301,15 @@ function decodeRecord(row: { seq: number; timestamp: string; payload: string }): } } -function validateCachedBranchRows(rows: readonly CachedBranchEntryRow[], query: BranchBounds): void { - if (rows.length === 0) return; +function validateCachedBranchRows(rows: readonly CachedBranchEntryRow[], query: BranchBounds & EntryQuery): void { + if (rows.length === 0 || query.type !== undefined || query.customType !== undefined) return; const path = [...rows].sort((left, right) => left.entry_seq - right.entry_seq); - if (query.stopAtId === undefined && query.stopAtType === undefined && path[0]?.parent_id !== null) { + const shouldIncludeRoot = + query.stopAtId === undefined && + query.stopAtType === undefined && + query.cursor === undefined && + (query.order === "oldestFirst" || query.limit === undefined); + if (shouldIncludeRoot && path[0]?.parent_id !== null) { throw new SessionError("invalid_entry", `Entry ${path[0]?.parent_id} not found`); } for (let index = 1; index < path.length; index++) { @@ -567,33 +572,47 @@ class SqliteSessionStorage implements SessionStorage { async getLog(options: LogOptions = {}): Promise { const afterSeq = options.afterSeq ?? 0; - const entryRows = readEntryRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst" }); - const recordRows = readRecordRows(this.db, this.metadata.id, { afterSeq }); - const laneRows = readLaneMoveRows(this.db, this.metadata.id, { afterSeq }); - const factRows = readFactRows(this.db, this.metadata.id, { afterSeq }); - - const log: LogItem[] = [ - ...entryRows.map((row) => ({ kind: "entry" as const, seq: row.seq, entry: decodeEntry(row) })), - ...recordRows.map((row) => ({ kind: "record" as const, seq: row.seq, record: decodeRecord(row) })), - ...laneRows.map((row) => ({ kind: "lane" as const, seq: row.seq, lane: row.lane, leafId: row.leaf_id })), - ...factRows.map((row) => { - if (row.kind === "name") + const limit = options.limit; + const entryRows = readEntryRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst", limit }); + const recordRows = readRecordRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst", limit }); + const laneRows = readLaneMoveRows(this.db, this.metadata.id, { afterSeq, limit }); + const factRows = readFactRows(this.db, this.metadata.id, { afterSeq, limit }); + + const logRows: { seq: number; decode: () => LogItem }[] = [ + ...entryRows.map((row) => ({ + seq: row.seq, + decode: () => ({ kind: "entry" as const, seq: row.seq, entry: decodeEntry(row) }), + })), + ...recordRows.map((row) => ({ + seq: row.seq, + decode: () => ({ kind: "record" as const, seq: row.seq, record: decodeRecord(row) }), + })), + ...laneRows.map((row) => ({ + seq: row.seq, + decode: () => ({ kind: "lane" as const, seq: row.seq, lane: row.lane, leafId: row.leaf_id }), + })), + ...factRows.map((row) => ({ + seq: row.seq, + decode: () => { + if (row.kind === "name") + return { + kind: "fact" as const, + seq: row.seq, + fact: "name" as const, + name: JSON.parse(row.value ?? "null") as string, + }; return { kind: "fact" as const, seq: row.seq, - fact: "name" as const, - name: JSON.parse(row.value ?? "null") as string, + fact: "label" as const, + targetId: row.key ?? "", + label: row.value === null ? undefined : (JSON.parse(row.value) as string), }; - return { - kind: "fact" as const, - seq: row.seq, - fact: "label" as const, - targetId: row.key ?? "", - label: row.value === null ? undefined : (JSON.parse(row.value) as string), - }; - }), + }, + })), ].sort((left, right) => left.seq - right.seq); - return options.limit === undefined ? log : log.slice(0, options.limit); + const selectedRows = options.limit === undefined ? logRows : logRows.slice(0, options.limit); + return selectedRows.map((row) => row.decode()); } async getName(): Promise { diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts index caaf47fd6e7..58807897d61 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts @@ -19,9 +19,13 @@ export interface CachedBranchEntryRow { } export interface CachedBranchQuery { + type?: Entry["type"]; + customType?: string; stopAtType?: Entry["type"]; stopAtId?: string; + cursor?: { afterSeq: number }; order?: "newestFirst" | "oldestFirst"; + limit?: number; } export function readCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { @@ -41,12 +45,13 @@ export function queryCachedBranchRows( query: CachedBranchQuery, ) { const oldestFirst = query.order === "oldestFirst"; - const stopPredicates = []; - if (query.stopAtType !== undefined) stopPredicates.push(sql`stop_entry.type = ${query.stopAtType}`); + const stopPredicates: ReturnType[] = []; + if (query.stopAtType !== undefined) stopPredicates.push(sql`stop.entry_type = ${query.stopAtType}`); if (query.stopAtId !== undefined) stopPredicates.push(sql`stop.entry_id = ${query.stopAtId}`); const aggregate = oldestFirst ? sql`MIN` : sql`MAX`; - const comparison = oldestFirst ? sql`<=` : sql`>=`; + const boundaryComparison = oldestFirst ? sql`<=` : sql`>=`; + const cursorComparison = oldestFirst ? sql`>` : sql`<`; const direction = oldestFirst ? sql`ASC` : sql`DESC`; const boundary = stopPredicates.length === 0 @@ -54,28 +59,33 @@ export function queryCachedBranchRows( : sql`WITH boundary AS ( SELECT ${aggregate}(stop.entry_seq) AS entry_seq FROM branch_entries AS stop - JOIN entries AS stop_entry - ON stop_entry.session_id = stop.session_id AND stop_entry.id = stop.entry_id WHERE stop.session_id = ${sessionId} AND stop.branch_id = ${branch.branchId} AND stop.entry_seq <= ${branch.leafSeq} AND (${joinSqlFragments(stopPredicates, " OR ")}) )`; - const range = - stopPredicates.length === 0 - ? sql`` - : sql`AND b.entry_seq ${comparison} COALESCE( - (SELECT entry_seq FROM boundary), ${oldestFirst ? branch.leafSeq : 0} - )`; + + const predicates = [ + sql`b.session_id = ${sessionId}`, + sql`b.branch_id = ${branch.branchId}`, + sql`b.entry_seq <= ${branch.leafSeq}`, + ]; + if (stopPredicates.length > 0) { + predicates.push(sql`b.entry_seq ${boundaryComparison} COALESCE( + (SELECT entry_seq FROM boundary), ${oldestFirst ? branch.leafSeq : 0} + )`); + } + if (query.cursor !== undefined) predicates.push(sql`b.entry_seq ${cursorComparison} ${query.cursor.afterSeq}`); + if (query.type !== undefined) predicates.push(sql`b.entry_type = ${query.type}`); + if (query.customType !== undefined) predicates.push(sql`b.custom_type = ${query.customType}`); + const limit = query.limit === undefined ? sql`` : sql` LIMIT ${query.limit}`; + return sql`${boundary} SELECT e.session_id, e.id, e.seq AS entry_seq, e.parent_id, e.type, e.timestamp, e.payload FROM branch_entries AS b JOIN entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id - WHERE b.session_id = ${sessionId} - AND b.branch_id = ${branch.branchId} - AND b.entry_seq <= ${branch.leafSeq} - ${range} - ORDER BY b.entry_seq ${direction}`.all(db); + WHERE ${joinSqlFragments(predicates, " AND ")} + ORDER BY b.entry_seq ${direction}${limit}`.all(db); } export function deleteBranchEntries(db: SqliteDatabase, sessionId: string) { diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts index 9086f61dd35..c2a8b625e0b 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts @@ -42,14 +42,15 @@ export function readEntryRow(db: SqliteDatabase, sessionId: string, entryId: str export function readEntryRows( db: SqliteDatabase, sessionId: string, - options: { afterSeq?: number; order?: EntryOrder } = {}, + options: { afterSeq?: number; order?: EntryOrder; limit?: number } = {}, ) { const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; const direction = options.order === "oldestFirst" ? sql`ASC` : sql`DESC`; + const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; return sql`SELECT session_id, seq, id, parent_id, type, timestamp, payload FROM entries WHERE session_id = ${sessionId}${after} - ORDER BY seq ${direction}`.all(db); + ORDER BY seq ${direction}${limit}`.all(db); } export function idExistsInEntries(db: SqliteDatabase, sessionId: string, id: string) { diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts index 12031caddf8..a9654aededb 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts @@ -24,28 +24,37 @@ export function appendFact( export function readLatestFact(db: SqliteDatabase, sessionId: string, kind: string, key: string | null) { return sql`SELECT session_id, seq, kind, key, value - FROM facts + FROM facts INDEXED BY idx_facts_session_kind_key_seq WHERE session_id = ${sessionId} AND kind = ${kind} AND key IS ${key} ORDER BY seq DESC LIMIT 1`.get(db); } export function readLatestLabelFacts(db: SqliteDatabase, sessionId: string) { - return sql`SELECT key, value FROM ( - SELECT key, value, ROW_NUMBER() OVER (PARTITION BY key ORDER BY seq DESC) AS rank - FROM facts + return sql`WITH latest AS ( + SELECT key, MAX(seq) AS seq + FROM facts INDEXED BY idx_facts_session_kind_key_seq WHERE session_id = ${sessionId} AND kind = 'label' + GROUP BY key ) - WHERE rank = 1 AND value IS NOT NULL - ORDER BY key`.all<{ key: string; value: string }>(db); + SELECT latest.key, f.value + FROM latest + JOIN facts AS f ON f.session_id = ${sessionId} AND f.seq = latest.seq + WHERE f.value IS NOT NULL + ORDER BY latest.key`.all<{ key: string; value: string }>(db); } -export function readFactRows(db: SqliteDatabase, sessionId: string, options: { afterSeq?: number } = {}) { +export function readFactRows( + db: SqliteDatabase, + sessionId: string, + options: { afterSeq?: number; limit?: number } = {}, +) { const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; + const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; return sql`SELECT session_id, seq, kind, key, value FROM facts WHERE session_id = ${sessionId}${after} - ORDER BY seq`.all(db); + ORDER BY seq${limit}`.all(db); } export function deleteFactRows(db: SqliteDatabase, sessionId: string) { diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts index fc040b934f7..02b40d540e2 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts @@ -99,12 +99,17 @@ export function finishLaneOperation(db: SqliteDatabase, sessionId: string, lane: WHERE session_id = ${sessionId} AND lane = ${lane} AND open_operation_id = ${runId}`.run(db); } -export function readLaneMoveRows(db: SqliteDatabase, sessionId: string, options: { afterSeq?: number } = {}) { +export function readLaneMoveRows( + db: SqliteDatabase, + sessionId: string, + options: { afterSeq?: number; limit?: number } = {}, +) { const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; + const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; return sql`SELECT session_id, seq, lane, leaf_id FROM lane_moves WHERE session_id = ${sessionId}${after} - ORDER BY seq`.all(db); + ORDER BY seq${limit}`.all(db); } export function deleteLaneRows(db: SqliteDatabase, sessionId: string) { diff --git a/packages/session-backends/sqlite-node/test/branch-query.test.ts b/packages/session-backends/sqlite-node/test/branch-query.test.ts index 210f7475380..54f017b68a4 100644 --- a/packages/session-backends/sqlite-node/test/branch-query.test.ts +++ b/packages/session-backends/sqlite-node/test/branch-query.test.ts @@ -51,7 +51,7 @@ describe("SQLite branch queries", () => { }); }); - it("validates entries before branch query filters and limits", async () => { + it("does not decode entries excluded by branch query filters and limits", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); const env = new NodeExecutionEnv({ cwd: root }); @@ -70,10 +70,9 @@ describe("SQLite branch queries", () => { } finally { await db.close(); } - await expect(session.findEntriesOnBranch({ start: leafId, type: "message", limit: 1 })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${customId}`), - }); + expect( + (await session.findEntriesOnBranch({ start: leafId, type: "message", limit: 1 })).map((entry) => entry.id), + ).toEqual([leafId]); const invalidJsonDb = await sqlite.open(databasePath); try { @@ -83,10 +82,7 @@ describe("SQLite branch queries", () => { } finally { await invalidJsonDb.close(); } - await expect(session.findEntriesOnBranch({ start: leafId, customType: "other" })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${customId}`), - }); + expect(await session.findEntriesOnBranch({ start: leafId, customType: "other" })).toEqual([]); }); it("does not validate ancestors beyond newest-first stop bounds", async () => { diff --git a/packages/session-backends/sqlite-node/test/facts-query.test.ts b/packages/session-backends/sqlite-node/test/facts-query.test.ts new file mode 100644 index 00000000000..84cc4987ba3 --- /dev/null +++ b/packages/session-backends/sqlite-node/test/facts-query.test.ts @@ -0,0 +1,31 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { applyMigrations, createNodeSqliteFactory } from "../src/index.ts"; +import { appendFact, readLatestFact, readLatestLabelFacts } from "../src/sqlite/storage/facts.ts"; +import { createTempDir } from "./test-utils.ts"; + +describe("SQLite fact queries", () => { + it("reads latest facts and latest non-null labels", async () => { + const databasePath = join(createTempDir(), "sessions.sqlite"); + const db = await createNodeSqliteFactory().open(databasePath); + try { + await applyMigrations(db); + appendFact(db, "session-1", 1, "label", "entry-1", JSON.stringify("old")); + appendFact(db, "session-1", 2, "label", "entry-2", JSON.stringify("kept")); + appendFact(db, "session-1", 3, "label", "entry-1", JSON.stringify("new")); + appendFact(db, "session-1", 4, "label", "entry-3", JSON.stringify("removed")); + appendFact(db, "session-1", 5, "label", "entry-3", null); + appendFact(db, "session-1", 6, "name", null, JSON.stringify("session name")); + appendFact(db, "other-session", 1, "label", "entry-1", JSON.stringify("other")); + + expect(readLatestFact(db, "session-1", "label", "entry-1")?.value).toBe(JSON.stringify("new")); + expect(readLatestFact(db, "session-1", "name", null)?.value).toBe(JSON.stringify("session name")); + expect(readLatestLabelFacts(db, "session-1")).toEqual([ + { key: "entry-1", value: JSON.stringify("new") }, + { key: "entry-2", value: JSON.stringify("kept") }, + ]); + } finally { + db.close(); + } + }); +}); diff --git a/packages/session-backends/sqlite-node/test/log-query.test.ts b/packages/session-backends/sqlite-node/test/log-query.test.ts new file mode 100644 index 00000000000..385c05d9c0f --- /dev/null +++ b/packages/session-backends/sqlite-node/test/log-query.test.ts @@ -0,0 +1,35 @@ +import { join } from "node:path"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { describe, expect, it } from "vitest"; +import { createNodeSqliteFactory, SqliteSessionRepository } from "../src/index.ts"; +import { createTempDir, createUserMessage } from "./test-utils.ts"; + +describe("SQLite log queries", () => { + it("does not decode rows beyond the requested log limit", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + const rootId = await session.appendMessage(createUserMessage("root")); + await session.setName("name"); + const tailId = await session.appendMessage(createUserMessage("tail")); + + const db = await sqlite.open(databasePath); + try { + await db + .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") + .run("not json", "session-1", tailId); + } finally { + await db.close(); + } + + expect(await session.getLog({ limit: 1 })).toEqual([ + expect.objectContaining({ kind: "entry", seq: 1, entry: expect.objectContaining({ id: rootId }) }), + ]); + expect(await session.getLog({ afterSeq: 1, limit: 1 })).toEqual([ + expect.objectContaining({ kind: "fact", seq: 2, fact: "name", name: "name" }), + ]); + }); +}); diff --git a/packages/session-backends/sqlite-node/test/migrations.test.ts b/packages/session-backends/sqlite-node/test/migrations.test.ts index 5d59f55d613..ae8f4f35282 100644 --- a/packages/session-backends/sqlite-node/test/migrations.test.ts +++ b/packages/session-backends/sqlite-node/test/migrations.test.ts @@ -34,8 +34,20 @@ describe("SQLite migrations", () => { ); const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>(); expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id"); + const sessionIndexes = db.prepare("PRAGMA index_list(sessions)").all<{ name: string }>(); + expect(sessionIndexes.map((index) => index.name)).toContain("idx_sessions_cwd_created_at"); const laneColumns = db.prepare("PRAGMA table_info(lanes)").all<{ name: string }>(); expect(laneColumns.map((column) => column.name)).toContain("open_operation_id"); + const branchEntryIndexes = db.prepare("PRAGMA index_list(branch_entries)").all<{ name: string }>(); + expect(branchEntryIndexes.map((index) => index.name)).toContain("idx_branch_entries_session_entry"); + const recordIndexes = db.prepare("PRAGMA index_list(records)").all<{ name: string }>(); + expect(recordIndexes.map((index) => index.name)).toEqual( + expect.arrayContaining([ + "idx_records_session_lane_seq", + "idx_records_session_type_seq", + "idx_records_session_type_op_kind_seq", + ]), + ); } finally { db.close(); } From 6ba4d23e7c953f601fca4bd3c8488e1e99d96fc8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 07:42:43 +0200 Subject: [PATCH 027/284] docs: audit changelogs since v0.84.0 --- packages/agent/CHANGELOG.md | 8 ++++++++ packages/ai/CHANGELOG.md | 4 ++++ packages/coding-agent/CHANGELOG.md | 16 +++++++++++++++- packages/tui/CHANGELOG.md | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 4d297adb037..be36386e53c 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Added `BeforeToolCallResult.terminate` so blocked tool calls can participate in the existing batch early-termination rule ([#7715](https://github.com/earendil-works/pi/pull/7715) by [@muyiyr](https://github.com/muyiyr)). + +### Fixed + +- Fixed `Agent.reset()` clearing transcript and runtime state during active runs; it now rejects until the agent is idle ([#7717](https://github.com/earendil-works/pi/pull/7717) by [@wesleyzhangwq](https://github.com/wesleyzhangwq)). + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 92840d74694..8a489f54476 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added Qwen Token Plan Individual as a built-in provider with its documented subscription model catalog and the shared international `QWEN_TOKEN_PLAN_API_KEY` ([#7659](https://github.com/earendil-works/pi/pull/7659) by [@arasovic](https://github.com/arasovic)). + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e4cd9f10eb0..32d2cdb9850 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,9 +2,20 @@ ## [Unreleased] +### New Features + +- **Qwen Token Plan Individual** — Use the built-in provider for models documented for Individual subscriptions. See [API Keys](docs/providers.md#api-keys). +- **Authentication readiness checks** — Use `pi auth check` to verify provider or model credentials, optionally emitting the resolved credential. +- **Improved fullscreen interaction** — Select words and paragraphs with multiple clicks and configure half-page transcript scrolling. See [TUI Fullscreen Viewport](docs/keybindings.md#tui-fullscreen-viewport). +- **Terminating blocked tool calls** — Extension `tool_call` handlers can stop all-terminating batches without another model call. See [Tool Events](docs/extensions.md#tool-events). + ### Added +- Added Qwen Token Plan Individual as a built-in provider with its documented subscription model catalog and the shared international `QWEN_TOKEN_PLAN_API_KEY`. See [API Keys](docs/providers.md#api-keys) ([#7659](https://github.com/earendil-works/pi/pull/7659) by [@arasovic](https://github.com/arasovic)). - Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)). +- Added `terminate` support to blocked extension `tool_call` events so all-terminating batches can skip the automatic follow-up model call. See [Tool Events](docs/extensions.md#tool-events) ([#7715](https://github.com/earendil-works/pi/pull/7715) by [@muyiyr](https://github.com/muyiyr)). +- Added inherited double-click word and whitespace selection, granularity-aware drag selection, and triple-click paragraph selection in fullscreen mode ([#7725](https://github.com/earendil-works/pi/issues/7725), [#7733](https://github.com/earendil-works/pi/pull/7733) by [@volsa](https://github.com/volsa)). +- Added inherited unbound half-page transcript scrolling actions for fullscreen mode. See [TUI Fullscreen Viewport](docs/keybindings.md#tui-fullscreen-viewport) ([#7735](https://github.com/earendil-works/pi/issues/7735)). ### Changed @@ -13,8 +24,12 @@ ### Fixed +- Fixed Bun standalone binaries crashing on startup when the cwd contains a `bunfig.toml` with `preload` by compiling with `--no-compile-autoload-bunfig` ([#7685](https://github.com/earendil-works/pi/pull/7685) by [@geril07](https://github.com/geril07)). - Fixed extension TUI method wrappers recursing indefinitely when delegating to the original method ([#7731](https://github.com/earendil-works/pi/issues/7731)). - Fixed right-click not pasting clipboard text in fullscreen mode on Windows. +- Fixed inherited `Agent.reset()` clearing transcript and runtime state during active runs; it now rejects until the agent is idle ([#7717](https://github.com/earendil-works/pi/pull/7717) by [@wesleyzhangwq](https://github.com/wesleyzhangwq)). +- Fixed inherited LaTeX relation, multiplication, and named-operator spacing, and matrix composition with stacked fractions, operator limits, and adjacent matrices. +- Reduced inherited fullscreen mouse event volume under tmux, Zellij, and GNU Screen by using button-motion tracking instead of all-motion tracking. ## [0.84.0] - 2026-08-06 @@ -137,7 +152,6 @@ ### Fixed -- Fixed Bun standalone binaries crashing on startup when the cwd contains a `bunfig.toml` with `preload` by compiling with `--no-compile-autoload-bunfig` ([#7684](https://github.com/earendil-works/pi/issues/7684)). - Fixed the footer showing `(sub)` for generic OAuth/OpenID sign-ins without a known subscription; extension OAuth providers can opt in with `isSubscription`. - Fixed inherited OAuth token refreshes so stalled requests release the credential-store lock ([#7508](https://github.com/earendil-works/pi/issues/7508)). - Fixed inherited tool argument validation to preserve values that already match an `anyOf`/`oneOf` union arm before coercion, avoiding nullable unions converting `null` to another primitive value ([#7328](https://github.com/earendil-works/pi/issues/7328)). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c7553502199..ddb88083278 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Added unbound half-page transcript scrolling actions, `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown`, for fullscreen TUI keybindings ([#7735](https://github.com/earendil-works/pi/issues/7735)). -- Added double-click word selection, word-aware drag selection, and triple-click line selection in the fullscreen TUI ([#7725](https://github.com/earendil-works/pi/issues/7725)). +- Added double-click word and whitespace selection, granularity-aware drag selection, and triple-click paragraph selection in the fullscreen TUI ([#7725](https://github.com/earendil-works/pi/issues/7725), [#7733](https://github.com/earendil-works/pi/pull/7733) by [@volsa](https://github.com/volsa)). - Added an optional right-click paste handler to the alternate-screen TUI, currently enabled on Windows. ### Fixed From 53fa77ccd8a279eb87e92294ef3687b03ff80112 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 07:46:28 +0200 Subject: [PATCH 028/284] Release v0.84.1 --- package-lock.json | 60 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 6 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 4 +- packages/client/CHANGELOG.md | 2 +- packages/client/package.json | 4 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/gondolin/package-lock.json | 4 +- .../examples/extensions/gondolin/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +- .../extensions/with-deps/package.json | 2 +- .../install-lock/package-lock.json | 52 ++++++++-------- .../coding-agent/install-lock/package.json | 4 +- packages/coding-agent/npm-shrinkwrap.json | 46 +++++++------- packages/coding-agent/package.json | 12 ++-- packages/evals/package.json | 6 +- packages/protocol/CHANGELOG.md | 2 +- packages/protocol/package.json | 2 +- packages/server/CHANGELOG.md | 2 +- packages/server/package.json | 6 +- .../session-backends/sqlite-node/CHANGELOG.md | 6 +- .../session-backends/sqlite-node/package.json | 6 +- packages/telemetry/CHANGELOG.md | 2 +- packages/telemetry/package.json | 2 +- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 32 files changed, 132 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5583b562462..48e638d5e8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5429,11 +5429,11 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -5475,12 +5475,12 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -5522,10 +5522,10 @@ }, "packages/client": { "name": "@earendil-works/pi-client", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-protocol": "^0.84.1" }, "devDependencies": { "shx": "0.4.0", @@ -5537,14 +5537,14 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-client": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0", - "@earendil-works/pi-tui": "^0.84.0", + "@earendil-works/pi-agent-core": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-client": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1", + "@earendil-works/pi-tui": "^0.84.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5586,32 +5586,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.84.0", + "version": "0.84.1", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.84.0" + "version": "0.84.1" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.84.0", + "version": "0.84.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.14.0", + "version": "1.14.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.84.0", + "version": "0.84.1", "dependencies": { "ms": "2.1.3" }, @@ -5682,10 +5682,10 @@ }, "packages/evals": { "name": "@earendil-works/pi-evals", - "version": "0.84.0", + "version": "0.84.1", "devDependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-coding-agent": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-coding-agent": "^0.84.1", "@types/node": "24.12.4", "shx": "0.4.0", "typescript": "5.9.3", @@ -5712,7 +5712,7 @@ }, "packages/protocol": { "name": "@earendil-works/pi-protocol", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -5727,11 +5727,11 @@ }, "packages/server": { "name": "@earendil-works/pi-server", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1" }, "devDependencies": { "shx": "0.4.0", @@ -5743,11 +5743,11 @@ }, "packages/session-backends/sqlite-node": { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0" + "@earendil-works/pi-agent-core": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.1" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", @@ -5759,7 +5759,7 @@ }, "packages/telemetry": { "name": "@earendil-works/pi-telemetry", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "devDependencies": { "@types/node": "24.12.4", @@ -5788,7 +5788,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index be36386e53c..78c7b90599b 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index 844141ea2a3..4c011eaf789 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.84.0", + "version": "0.84.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -35,8 +35,8 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8a489f54476..1df3086dc55 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 7ca360ffd33..50a06caab36 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.84.0", + "version": "0.84.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -62,7 +62,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 7d57807ad48..7981b61cbad 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ## [0.84.0] - 2026-08-06 diff --git a/packages/client/package.json b/packages/client/package.json index 961eb1377f4..81efdead8a1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-client", - "version": "0.84.0", + "version": "0.84.1", "description": "Transport-neutral client for remote pi sessions over framed CBOR bytes", "type": "module", "main": "./dist/index.js", @@ -47,7 +47,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-protocol": "^0.84.1" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 32d2cdb9850..6f4aeb3d21c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ### New Features diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 41a9e45fad4..40ac536edcf 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.84.0", + "version": "0.84.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.84.0", + "version": "0.84.1", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 21dd6a46a5e..e4760cc1112 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.84.0", + "version": "0.84.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 6008711e372..a13a3a8cb70 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.84.0", + "version": "0.84.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index 5d4a5ee32b0..5ba0c2d840b 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.84.0", + "version": "0.84.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.84.0", + "version": "0.84.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index a9b1cc116b3..83019c1d39d 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.84.0", + "version": "0.84.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 450b5b5d799..450c0460dc5 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.14.0", + "version": "1.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.14.0", + "version": "1.14.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index df95cdfd081..7e028f2848c 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.14.0", + "version": "1.14.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index d0299608b05..963fb84a8ca 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.84.0", + "version": "0.84.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.84.0", + "version": "0.84.1", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 8ac0a700338..08a30f3300d 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.84.0", + "version": "0.84.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 92575e18546..32536f4a129 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.0", + "version": "0.84.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.0", + "version": "0.84.1", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.0" + "@earendil-works/pi-coding-agent": "0.84.1" }, "engines": { "node": ">=22.19.0" @@ -450,12 +450,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -466,13 +466,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -491,26 +491,26 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-protocol": "^0.84.1" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-client": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0", - "@earendil-works/pi-tui": "^0.84.0", + "@earendil-works/pi-agent-core": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-client": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1", + "@earendil-works/pi-tui": "^0.84.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -539,8 +539,8 @@ } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -550,16 +550,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index dda433d5f5e..a761b0bdcab 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.0", + "version": "0.84.1", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.0" + "@earendil-works/pi-coding-agent": "0.84.1" }, "overrides": { "protobufjs": "7.6.5", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 825f2e79315..ea3af110df7 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,19 +1,19 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.0", + "version": "0.84.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.0", + "version": "0.84.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-client": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0", - "@earendil-works/pi-tui": "^0.84.0", + "@earendil-works/pi-agent-core": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-client": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1", + "@earendil-works/pi-tui": "^0.84.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -477,12 +477,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -493,13 +493,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.0", + "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -518,19 +518,19 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-protocol": "^0.84.1" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -540,16 +540,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 3bd3f733a5c..f6fc21a3d58 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.0", + "version": "0.84.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -43,11 +43,11 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.0", - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-client": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0", - "@earendil-works/pi-tui": "^0.84.0", + "@earendil-works/pi-agent-core": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-client": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1", + "@earendil-works/pi-tui": "^0.84.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/evals/package.json b/packages/evals/package.json index 08585a7e5f7..087ad17bf33 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-evals", - "version": "0.84.0", + "version": "0.84.1", "private": true, "type": "module", "scripts": { @@ -9,8 +9,8 @@ "test": "vitest run --config vitest.test.config.ts" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-coding-agent": "^0.84.0", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-coding-agent": "^0.84.1", "@types/node": "24.12.4", "shx": "0.4.0", "typescript": "5.9.3", diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index b82e3a98d17..b3b459b0371 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ## [0.84.0] - 2026-08-06 diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 12447afef5e..6f9186fc979 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-protocol", - "version": "0.84.0", + "version": "0.84.1", "description": "Transport-neutral CBOR protocol for remote pi sessions", "type": "module", "main": "./dist/index.js", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index f4aad93afb2..2a8b3b6ef2f 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ## [0.84.0] - 2026-08-06 diff --git a/packages/server/package.json b/packages/server/package.json index aefd50f2da4..5cf0bd71874 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-server", - "version": "0.84.0", + "version": "0.84.1", "description": "experimental server package for pi", "type": "module", "main": "./dist/index.js", @@ -47,8 +47,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-protocol": "^0.84.0" + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index 3260d5a1bb9..4a00258066d 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -1,11 +1,15 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ### Added - Added the composable, parameterized `sql` template tag for SQLite queries. +### Fixed + +- Fixed SQLite branch queries to apply filters, cursors, and limits in SQL; bounded log reads; and added covering indexes for session, record, branch, and fact queries ([#7727](https://github.com/earendil-works/pi/pull/7727) by [@cristinaponcela](https://github.com/cristinaponcela)). + ## [0.84.0] - 2026-08-06 ### Breaking Changes diff --git a/packages/session-backends/sqlite-node/package.json b/packages/session-backends/sqlite-node/package.json index ab96ff0350c..e54f41a08c8 100644 --- a/packages/session-backends/sqlite-node/package.json +++ b/packages/session-backends/sqlite-node/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.0", + "version": "0.84.1", "description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions", "type": "module", "main": "./dist/index.js", @@ -34,8 +34,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.0", - "@earendil-works/pi-agent-core": "^0.84.0" + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-agent-core": "^0.84.1" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 1592c327e6c..0564b559cab 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ## [0.84.0] - 2026-08-06 diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index cfc4c6f8176..a06674100e0 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-telemetry", - "version": "0.84.0", + "version": "0.84.1", "description": "Vendor-neutral telemetry contracts and typed schema utilities for pi", "type": "module", "main": "./dist/index.js", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ddb88083278..28be408b31d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index 4ed8d148a5b..52cabe21296 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.84.0", + "version": "0.84.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 310411ba4666e207e1ce840ebadc8a5429f35398 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 07:46:31 +0200 Subject: [PATCH 029/284] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/client/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/protocol/CHANGELOG.md | 2 ++ packages/server/CHANGELOG.md | 2 ++ packages/session-backends/sqlite-node/CHANGELOG.md | 2 ++ packages/telemetry/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 9 files changed, 18 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 78c7b90599b..7911fbc60c4 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1df3086dc55..1328c60a7f9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 7981b61cbad..396ee220bea 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ## [0.84.0] - 2026-08-06 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f4aeb3d21c..18a37327887 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ### New Features diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index b3b459b0371..3e32c6a5726 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ## [0.84.0] - 2026-08-06 diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 2a8b3b6ef2f..7dcb6e9e1f9 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ## [0.84.0] - 2026-08-06 diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index 4a00258066d..ba9a214ff4a 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 0564b559cab..9dc96f4f819 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ## [0.84.0] - 2026-08-06 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 28be408b31d..96c181613e1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.1] - 2026-08-07 ### Added From 102667952585134e99b5d86c9ff07dd28c86962c Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 08:56:05 +0200 Subject: [PATCH 030/284] docs(agent): unreserve R3 --- packages/agent/docs/harness-v2.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index a8700372e94..9219ef401e8 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -3230,8 +3230,6 @@ These packages merge R0 → R1 → R2 → R3. R1 and R2 add a reducer module ins - Keep `LaneState` limited to orchestration state. Reduction exclusively owns all three outputs; later recovery packages consume `LaneReductionResult` and do not re-reduce tool or operation records. - Acceptance: table-driven tests cover idle and every suspended state, configuration fallback/override, and terminal-failure provenance; reduction is deterministic and performs no writes. -**Reserved: R3 by @vegarsti.** - - [ ] **R3 — harness restore inventory.** Dependencies: F0, R2. - Primary files: `packages/agent/src/harness/agent-harness.ts`, reducer integration helpers, and restore tests. - Wire `AgentHarness.create()` to use indexed open-operation discovery, bounded idle/open scans, explicit provisioned-id point lookups, and bounded configuration lookups. Return accurate `SuspendedOperation[]` without starting effects. From 9d090bc5dcecf8f35354e09fe57413e57dea8e5b Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 09:10:14 +0200 Subject: [PATCH 031/284] fix(agent): reject conflicting JSONL session creation --- .../agent/src/harness/session/jsonl/repo.ts | 55 +++++++++++++++---- .../agent/test/harness/session/jsonl.test.ts | 26 ++++++++- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index 5cde6f6c3f3..cd83762b189 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -38,6 +38,7 @@ export class JsonlSessionRepo { private readonly fs: JsonlSessionRepoFileSystem; private readonly sessionsRootInput: string; + private readonly activeCreateDestinations = new Set(); private rootPromise: Promise | undefined; constructor(options: JsonlSessionRepoOptions) { @@ -46,16 +47,17 @@ export class JsonlSessionRepo } async create(options: JsonlSessionCreateOptions): Promise> { - const { header, path } = await this.prepareCreate(options); - return new Session(await JsonlSessionStorage.create(this.fs, path, header)); + const destination = await this.resolveCreateDestination(options); + return this.claimCreateDestination(destination, async () => { + const { header, path } = await this.prepareCreate(destination, options); + return new Session(await JsonlSessionStorage.create(this.fs, path, header)); + }); } async open(metadata: JsonlSessionMetadata): Promise> { return new Session(await this.loadStorage(metadata)); } - list(): Promise; - list(options: JsonlSessionListOptions): Promise; async list(options: JsonlSessionListOptions = {}): Promise { return this.listDirect(options); } @@ -69,11 +71,15 @@ export class JsonlSessionRepo options: ForkOptions & JsonlSessionCreateOptions, ): Promise> { const sourceStorage = await this.loadStorage(source); - const { header, path } = await this.prepareCreate({ + const createOptions = { ...options, parentSessionId: options.parentSessionId ?? source.id, + }; + const destination = await this.resolveCreateDestination(createOptions); + return this.claimCreateDestination(destination, async () => { + const { header, path } = await this.prepareCreate(destination, createOptions); + return new Session(await sourceStorage.fork(path, header, options)); }); - return new Session(await sourceStorage.fork(path, header, options)); } private async loadStorage(metadata: JsonlSessionMetadata): Promise { @@ -88,13 +94,42 @@ export class JsonlSessionRepo return storage; } - private async prepareCreate(options: JsonlSessionCreateOptions): Promise<{ - header: JsonlV4Header; - path: string; - }> { + private async resolveCreateDestination(options: JsonlSessionCreateOptions): Promise<{ id: string; cwd: string }> { const id = options.id ?? uuidv7(); validateSessionId(id); const cwd = fileResult(await this.fs.absolutePath(options.cwd), `Failed to resolve session cwd ${options.cwd}`); + return { id, cwd }; + } + + /** + * Prevent same-process create/fork races for one logical destination. The durable filename includes a + * timestamp, so the async filesystem existence check alone can let two concurrent calls both decide the + * same {cwd, id} is free and publish duplicate sessions. + */ + private async claimCreateDestination( + destination: { id: string; cwd: string }, + operation: () => Promise, + ): Promise { + const key = `${destination.cwd}\0${destination.id}`; + if (this.activeCreateDestinations.has(key)) { + throw new SessionError("already_exists", `Session already exists: ${destination.id}`); + } + this.activeCreateDestinations.add(key); + try { + return await operation(); + } finally { + this.activeCreateDestinations.delete(key); + } + } + + private async prepareCreate( + destination: { id: string; cwd: string }, + options: JsonlSessionCreateOptions, + ): Promise<{ + header: JsonlV4Header; + path: string; + }> { + const { id, cwd } = destination; if (await this.sessionIdExists(id, cwd)) { throw new SessionError("already_exists", `Session already exists: ${id}`); } diff --git a/packages/agent/test/harness/session/jsonl.test.ts b/packages/agent/test/harness/session/jsonl.test.ts index e5fcffea7d6..abbdb8f96eb 100644 --- a/packages/agent/test/harness/session/jsonl.test.ts +++ b/packages/agent/test/harness/session/jsonl.test.ts @@ -35,7 +35,7 @@ function createRepository(root: string): JsonlSessionRepo { }); } -function withDefaultSessionCwd(repository: SessionRepo, cwd: string): SessionRepo { +function withDefaultSessionCwd(repository: JsonlSessionRepo, cwd: string): SessionRepo { return { create(options) { const optionsWithCwd = { ...options, cwd }; @@ -145,6 +145,30 @@ describe("JSONL v4 persistence", () => { expect((await repository.list()).map((metadata) => metadata.id)).toEqual(["shared", "shared"]); }); + it("rejects concurrent create calls for the same destination", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + try { + const root = createTempDir(); + const repository = createRepository(root); + const cwd = join(root, "workspace"); + + const results = await Promise.allSettled([ + repository.create({ id: "same", cwd }), + repository.create({ id: "same", cwd }), + ]); + const successes = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ code: "already_exists" }); + expect((await repository.list({ cwd })).map((listed) => listed.id)).toEqual(["same"]); + } finally { + vi.useRealTimers(); + } + }); + it("sorts listed sessions by current filesystem modification time", async () => { const root = createTempDir(); const repository = createRepository(root); From f27941774e2bf0ba94e1651dcbd7115c5172a690 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 09:20:02 +0200 Subject: [PATCH 032/284] fix(agent): read JSONL headers when listing sessions --- packages/agent/src/harness/session/jsonl/repo.ts | 5 ++--- packages/agent/src/harness/session/jsonl/types.ts | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index cd83762b189..b683a1babda 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -163,11 +163,10 @@ export class JsonlSessionRepo `Failed to list sessions directory ${directory}`, ).filter((entry) => entry.kind !== "directory" && entry.name.endsWith(".jsonl")); for (const file of files) { - const content = fileResult( - await this.fs.readTextFile(file.path), + const [firstLine] = fileResult( + await this.fs.readTextLines(file.path, { maxLines: 1 }), `Failed to read session header ${file.path}`, ); - const firstLine = content.split("\n", 1)[0]; if (!firstLine) throw invalidFile(file.path, 1, "is missing a header"); metadata.push(metadataFromHeader(parseHeader(firstLine, file.path), file.path, file.mtimeMs)); } diff --git a/packages/agent/src/harness/session/jsonl/types.ts b/packages/agent/src/harness/session/jsonl/types.ts index 0483d9e558b..f838a90f37e 100644 --- a/packages/agent/src/harness/session/jsonl/types.ts +++ b/packages/agent/src/harness/session/jsonl/types.ts @@ -6,6 +6,7 @@ export type JsonlSessionRepoFileSystem = Pick< | "absolutePath" | "joinPath" | "readTextFile" + | "readTextLines" | "writeFile" | "appendFile" | "renameFile" From 80ef7ff0f493c87e870e1bb73f0fdf0666c6a8a4 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 09:47:29 +0200 Subject: [PATCH 033/284] fix(agent): populate session types with doc comments from design doc --- packages/agent/src/harness/session/types.ts | 25 +++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 77b05ad24f1..9b3fbf52366 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -14,9 +14,9 @@ export interface IdGenerator { export interface EntryBase { type: string; id: string; - seq: number; - parentId: string | null; - timestamp: number; + seq: number; // shared sequence; read-side, storage-assigned + parentId: string | null; // storage-assigned: the appending lane's leaf + timestamp: number; // Unix ms, storage-assigned } export interface MessageEntry extends EntryBase { @@ -222,15 +222,16 @@ export interface EntryCursor { export interface EntryQuery { type?: Entry["type"]; - customType?: string; - order?: EntryOrder; + customType?: string; // for type "custom" + order?: EntryOrder; // default newestFirst limit?: number; cursor?: EntryCursor; } +/** Bounds of a branch scan. Default: the whole path, leaf to root. */ export interface BranchBounds { - start?: string; - stopAtType?: Entry["type"]; + start?: string; // default: the view's lane leaf + stopAtType?: Entry["type"]; // scan ends after the first match, inclusive stopAtId?: string; } @@ -318,14 +319,24 @@ export interface SessionTree { getLeafId(): Promise; getEntry(id: string): Promise; getStats(): Promise; + + // Global facts. Latest wins; not branch-scoped. "set", not "append": + // append vocabulary is reserved for tree writes. getName(): Promise; setName(name: string): Promise; getLabel(targetId: string): Promise; setLabel(targetId: string, label: string | undefined): Promise; + + /** Session-wide, all branches, sequence order. */ findEntries(query?: EntryQuery): Promise; findEntry(query?: EntryQuery): Promise; + + /** Branch-scoped: the path from start toward root. */ findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise; findEntryOnBranch(query?: EntryQuery & BranchBounds): Promise; + + // Writes. Resolve on durable acceptance; the returned id is the entry's + // id (provisioned when the write defers). appendMessage(message: AgentMessage): Promise; appendCustomEntry(customType: string, data?: unknown): Promise; } From f562a1a44f32fa52c345738c0079d12eb2c5cfba Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 09:55:13 +0200 Subject: [PATCH 034/284] docs(agent): clarify branch query helpers --- packages/agent/src/harness/session/session.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 262cdcd8c28..f4be8adfacd 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -223,6 +223,7 @@ export class Session implem return this.queryLog(options); } + /** Returns the lane's current leaf, or null when empty. Throws when the lane does not exist. */ private async getLeafIdForLane(lane: string): Promise { const pointer = (await this.getLanes()).find((candidate) => candidate.lane === lane); if (!pointer) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); @@ -235,14 +236,18 @@ export class Session implem return this.storage.findEntries(resultLimit === query.limit ? query : { ...query, limit: resultLimit }); } + /** + * Queries from `query.start` toward the root, defaulting to the lane's current leaf. + * `resultLimit` lets single-entry queries cap results without changing the caller's query. + */ private async queryBranchEntries( - lane: string, + defaultLane: string, query: EntryQuery & BranchBounds = {}, resultLimit = query.limit, ): Promise { assertValidLimit(query.limit); assertValidCursor(query.cursor?.afterSeq); - const start = query.start ?? (await this.getLeafIdForLane(lane)); + const start = query.start ?? (await this.getLeafIdForLane(defaultLane)); if (start === null) return []; const storageQuery = resultLimit === query.limit ? query : { ...query, limit: resultLimit }; return this.storage.findEntriesOnBranch({ ...storageQuery, start }); From 87644c7b5749c255ac7ea96103c6531ebd7d0a46 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 10:18:48 +0200 Subject: [PATCH 035/284] docs(agent): clarify branch query start requirement --- packages/agent/src/harness/session/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 9b3fbf52366..17685d38fc2 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -292,7 +292,7 @@ export interface SessionStorage; findEntries(query?: EntryQuery): Promise; - /** start is mandatory here; defaulting to a lane's leaf is view sugar. */ + /** start is mandatory here (as opposed to SessionTree's findEntriesOnBranch); defaulting to a lane's leaf is view sugar. */ findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise; findRecords( query: RecordQuery & { type: K }, From 10474bd697b2270defa200998f16baf2166775a8 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 10:03:39 +0200 Subject: [PATCH 036/284] fix(agent): refactor jsonl codec --- .../agent/src/harness/session/jsonl/codec.ts | 146 +++++++++++------- 1 file changed, 90 insertions(+), 56 deletions(-) diff --git a/packages/agent/src/harness/session/jsonl/codec.ts b/packages/agent/src/harness/session/jsonl/codec.ts index e7abcdc0c68..e0fdc04fd59 100644 --- a/packages/agent/src/harness/session/jsonl/codec.ts +++ b/packages/agent/src/harness/session/jsonl/codec.ts @@ -112,68 +112,102 @@ export function metadataFromHeader(header: JsonlV4Header, path: string, modified }; } +function parseEntryMutation( + value: Record, + path: string, + lineNumber: number, + seq: number, +): Extract { + const lane = value.lane === undefined ? undefined : requireString(value.lane, path, lineNumber, "lane"); + const id = requireString(value.id, path, lineNumber, "id"); + const type = requireString(value.type, path, lineNumber, "entry type"); + if (!ENTRY_TYPES.has(type as Entry["type"])) throw invalidFile(path, lineNumber, `has unknown entry type ${type}`); + const parentId = requireNullableId(value.parentId, path, lineNumber, "parentId"); + const timestamp = requireTimestamp(value.timestamp, path, lineNumber); + if (type === "custom") requireString(value.customType, path, lineNumber, "customType"); + const { kind: _kind, lane: _lane, ...entryFields } = value; + const entry = { ...entryFields, id, type, parentId, seq, timestamp } as unknown as Entry; + return lane === undefined ? { kind: "entry", entry } : { kind: "entry", lane, entry }; +} + +function parseRecordMutation( + value: Record, + path: string, + lineNumber: number, + seq: number, +): Extract { + const id = requireString(value.id, path, lineNumber, "id"); + const lane = requireString(value.lane, path, lineNumber, "lane"); + const type = requireString(value.type, path, lineNumber, "record type"); + if (!RECORD_TYPES.has(type as LaneRecord["type"])) { + throw invalidFile(path, lineNumber, `has unknown record type ${type}`); + } + const timestamp = requireTimestamp(value.timestamp, path, lineNumber); + if (type === "operation_started") { + if (!isObject(value.intent)) throw invalidFile(path, lineNumber, "has invalid intent"); + const operationKind = requireString(value.intent.kind, path, lineNumber, "operation kind"); + if (!OPERATION_KINDS.has(operationKind)) { + throw invalidFile(path, lineNumber, `has unknown operation kind ${operationKind}`); + } + } + if (type === "operation_finished") requireString(value.runId, path, lineNumber, "runId"); + const { kind: _kind, ...recordFields } = value; + return { + kind: "record", + record: { ...recordFields, id, lane, type, seq, timestamp } as unknown as LaneRecord, + }; +} + +function parseLaneMutation( + value: Record, + path: string, + lineNumber: number, + seq: number, +): Extract { + return { + kind: "lane", + seq, + lane: requireString(value.lane, path, lineNumber, "lane"), + leafId: requireNullableId(value.leafId, path, lineNumber, "leafId"), + }; +} + +function parseFactMutation( + value: Record, + path: string, + lineNumber: number, + seq: number, +): Extract { + if (value.fact === "name") { + return { kind: "fact", seq, fact: "name", name: requireString(value.name, path, lineNumber, "name") }; + } + if (value.fact === "label") { + if (value.label !== undefined && typeof value.label !== "string") { + throw invalidFile(path, lineNumber, "has invalid label"); + } + return { + kind: "fact", + seq, + fact: "label", + targetId: requireString(value.targetId, path, lineNumber, "targetId"), + label: value.label, + }; + } + throw invalidFile(path, lineNumber, "has unknown fact type"); +} + export function parseMutation(line: string, path: string, lineNumber: number): SessionMutation { const value = parseObject(line, path, lineNumber); const seq = requireSequence(value.seq, path, lineNumber); switch (value.kind) { - case "entry": { - const lane = value.lane === undefined ? undefined : requireString(value.lane, path, lineNumber, "lane"); - const id = requireString(value.id, path, lineNumber, "id"); - const type = requireString(value.type, path, lineNumber, "entry type"); - if (!ENTRY_TYPES.has(type as Entry["type"])) - throw invalidFile(path, lineNumber, `has unknown entry type ${type}`); - const parentId = requireNullableId(value.parentId, path, lineNumber, "parentId"); - const timestamp = requireTimestamp(value.timestamp, path, lineNumber); - if (type === "custom") requireString(value.customType, path, lineNumber, "customType"); - const { kind: _kind, lane: _lane, ...entryFields } = value; - const entry = { ...entryFields, id, type, parentId, seq, timestamp } as unknown as Entry; - return lane === undefined ? { kind: "entry", entry } : { kind: "entry", lane, entry }; - } - case "record": { - const id = requireString(value.id, path, lineNumber, "id"); - const lane = requireString(value.lane, path, lineNumber, "lane"); - const type = requireString(value.type, path, lineNumber, "record type"); - if (!RECORD_TYPES.has(type as LaneRecord["type"])) - throw invalidFile(path, lineNumber, `has unknown record type ${type}`); - const timestamp = requireTimestamp(value.timestamp, path, lineNumber); - if (type === "operation_started") { - if (!isObject(value.intent)) throw invalidFile(path, lineNumber, "has invalid intent"); - const operationKind = requireString(value.intent.kind, path, lineNumber, "operation kind"); - if (!OPERATION_KINDS.has(operationKind)) { - throw invalidFile(path, lineNumber, `has unknown operation kind ${operationKind}`); - } - } - if (type === "operation_finished") requireString(value.runId, path, lineNumber, "runId"); - const { kind: _kind, ...recordFields } = value; - return { - kind: "record", - record: { ...recordFields, id, lane, type, seq, timestamp } as unknown as LaneRecord, - }; - } + case "entry": + return parseEntryMutation(value, path, lineNumber, seq); + case "record": + return parseRecordMutation(value, path, lineNumber, seq); case "lane": - return { - kind: "lane", - seq, - lane: requireString(value.lane, path, lineNumber, "lane"), - leafId: requireNullableId(value.leafId, path, lineNumber, "leafId"), - }; + return parseLaneMutation(value, path, lineNumber, seq); case "fact": - if (value.fact === "name") { - return { kind: "fact", seq, fact: "name", name: requireString(value.name, path, lineNumber, "name") }; - } - if (value.fact === "label") { - if (value.label !== undefined && typeof value.label !== "string") { - throw invalidFile(path, lineNumber, "has invalid label"); - } - return { - kind: "fact", - seq, - fact: "label", - targetId: requireString(value.targetId, path, lineNumber, "targetId"), - label: value.label, - }; - } - throw invalidFile(path, lineNumber, "has unknown fact type"); + return parseFactMutation(value, path, lineNumber, seq); default: throw invalidFile(path, lineNumber, "has unknown mutation kind"); } From e0900a6eaf6fb56312a868d9c06891129c4a5cbe Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 11:01:18 +0200 Subject: [PATCH 037/284] docs(agent): clarify record query semantics --- packages/agent/docs/harness-v2.md | 12 +++++++++++- packages/agent/src/harness/session/types.ts | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 9219ef401e8..4b7693b8f6b 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -1553,13 +1553,23 @@ class Session implements SessionTree { // bound to "main" interface IdGenerator { next(): string; } interface RecordQuery { + /** Exact lane match. Omit to query every lane. */ lane?: string; + /** Exact record discriminant match. Omit to query every record type. */ type?: LaneRecord["type"]; + /** + * Operation identity. Matches OperationStartedRecord.id and the runId + * property of operation-owned records. Records without an operation + * identity do not match. + */ runId?: string; - /** Valid only with type "operation_started". */ + /** Exact operation intent kind. Valid only with type "operation_started". */ operationKind?: OperationStartedRecord["intent"]["kind"]; + /** Exclusive chronological lower bound: seq > afterSeq, regardless of order. */ afterSeq?: number; + /** Sequence order. Default: "newestFirst". */ order?: "oldestFirst" | "newestFirst"; + /** Positive maximum number of matching records. */ limit?: number; } ``` diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 17685d38fc2..5df425f1574 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -236,13 +236,23 @@ export interface BranchBounds { } export interface RecordQuery { + /** Exact lane match. Omit to query every lane. */ lane?: string; + /** Exact record discriminant match. Omit to query every record type. */ type?: LaneRecord["type"]; + /** + * Operation identity. Matches OperationStartedRecord.id and the runId + * property of operation-owned records. Records without an operation + * identity do not match. + */ runId?: string; - /** Valid only with type "operation_started". */ + /** Exact operation intent kind. Valid only with type "operation_started". */ operationKind?: OperationStartedRecord["intent"]["kind"]; + /** Exclusive chronological lower bound: seq > afterSeq, regardless of order. */ afterSeq?: number; + /** Sequence order. Default: "newestFirst". */ order?: EntryOrder; + /** Positive maximum number of matching records. */ limit?: number; } From 709aa03194301afd008a07d64ff1bf12e4f7ece6 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 11:27:41 +0200 Subject: [PATCH 038/284] docs(agent): add j6 typebox validation (#7768) --- packages/agent/docs/harness-v2.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 4b7693b8f6b..896303e7fb2 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -3213,9 +3213,9 @@ Implementation packages derive their tests from this design and do not use the p - [x] **QA2 — salvage storage and query tests.** Dependencies: QA1, R0. - Port worthwhile bounded-query, corruption, fork, immutable-read, lane, record-query, and recovery-query cases whose replacement APIs already exist. Skip deleted implementation details and behavior already covered by backend conformance. - - Acceptance: each reviewed storage/query case is covered by a cited current test, ported as a comprehensive invariant test, marked inapplicable, or left blocked on J1–J5. + - Acceptance: each reviewed storage/query case is covered by a cited current test, ported as a comprehensive invariant test, marked inapplicable, or left blocked on J1–J6. -- [ ] **QA3 — salvage remaining legacy tests.** Dependencies: QA2, J5, O2. +- [ ] **QA3 — salvage remaining legacy tests.** Dependencies: QA2, J6, O2. - After the new storage and harness runtime are complete, review every matrix case still blocked or uncovered. Port only still-valid invariants against the new public APIs; do not restore deleted APIs or old implementation details. QA3 may change focused tests and the matrix, but no production code. - Acceptance: every matrix row ends covered by a cited current test, ported by a comprehensive new test, or explicitly inapplicable; no row remains blocked or uncovered. @@ -3247,9 +3247,9 @@ These packages merge R0 → R1 → R2 → R3. R1 and R2 add a reducer module ins ### Track J — JSONL storage -**In progress and reserved: @davidbrai.** The work began before this plan was split into J0–J5. Before merge, the track owner must include or rebase onto R0's recovery-query contract and report which J packages are complete. Other agents must not pick a J package while this ownership marker remains. +**In progress and reserved: @davidbrai.** The work began before this plan was split into J0–J6. Before merge, the track owner must include or rebase onto R0's recovery-query contract and report which J packages are complete. Other agents must not pick a J package while this ownership marker remains. -These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete `JsonlSessionRepo` export, and `packages/agent/test/harness/session/jsonl*.test.ts`. They merge J0 → J1 → J2 → J3 → J4 → J5 and may proceed in parallel with tracks L and I after R0. +These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete `JsonlSessionRepo` export, and `packages/agent/test/harness/session/jsonl*.test.ts`. They merge J0 → J1 → J2 → J3 → J4 → J5 → J6 and may proceed in parallel with tracks L and I after R0. - [x] **J0 — JSONL metadata and codec contracts.** Dependencies: R0. - Primary files: JSONL type/codec modules and focused codec tests; no public repository export yet. @@ -3273,6 +3273,10 @@ These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete ` - Rewrite through a temporary format-4 file on the first mutation, preserve metadata/facts/tree and resolved or legacy parent linkage, and add the aggregate v3 usage adjustment. - Acceptance: crash-safe conversion tests cover failure before rename, successful reopen, statistics preservation, unresolved legacy parent paths, and no second conversion. +- [ ] **J6 — schema-based durable payload validation.** Dependencies: J5. + - Define shared TypeBox schemas for format-4 JSON and derive session types from them, including runtime schema registration for application-defined `AgentMessage` variants. + - Acceptance: malformed durable payloads are rejected consistently and JSONL decoding uses the shared schemas. + ### Track I — primitives I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins after R2 fixes the `LaneState` shape. These packages use separate modules with focused unit tests; I5 remains primitive-only and does not edit `agent-harness.ts`. @@ -3375,7 +3379,7 @@ These packages also own `agent-harness.ts` and merge after H8, in order C1 → C ### Track O — observability and core completion -These packages merge O1 → O2 → O3 → O4 after N1, with QA3 between O2 and O3. QA3 also requires J5. They may not modify `packages/coding-agent/**`. +These packages merge O1 → O2 → O3 → O4 after N1, with QA3 between O2 and O3. QA3 also requires J6. They may not modify `packages/coding-agent/**`. - [ ] **O1 — snapshots and event completeness.** Dependencies: N1, I2. - Finish live lane/session snapshots, event filtering, streaming/running-tool state, and all section 10 event insertion points. @@ -3386,15 +3390,15 @@ These packages merge O1 → O2 → O3 → O4 after N1, with QA3 between O2 and O - [ ] **O3 — action-prefix and race audit.** Dependencies: O2, QA3. - Complete Tier C for every race row, mechanically reopen every action prefix, compare automatic/manual logs, and verify reducer/live-state fixed points. - Acceptance: every race row has both orders and no documented crash action lacks a reopen test. -- [ ] **O4 — backend parity and final core audit.** Dependencies: J5, O3. +- [ ] **O4 — backend parity and final core audit.** Dependencies: J6, O3. - Run the complete storage/recovery matrix across memory, JSONL, and SQLite; remove dead agent/storage declarations and compatibility comments; verify exports/declarations and `./node`; update changelogs and core documentation. - Acceptance: all non-e2e tests and `npm run check` pass, no active harness operation remains scaffolded, `packages/coding-agent/**` is unchanged, and the worktree is clean. ### Dependency, priority, and merge summary -The serial storage lane is **R0 → J0 → J1 → J2 → J3 → J4 → J5**. The reducer lane is **R0 → R1 → R2 → R3**. The loop lane is **I0 → L1 → L2 → L3**. The effects lane is **R2 → I3 → I4 → I5**, with I4 also requiring I0, I1, and L3. Before H0, the convergence gate is **F0 + R3 + I2 + I5**. +The serial storage lane is **R0 → J0 → J1 → J2 → J3 → J4 → J5 → J6**. The reducer lane is **R0 → R1 → R2 → R3**. The loop lane is **I0 → L1 → L2 → L3**. The effects lane is **R2 → I3 → I4 → I5**, with I4 also requiring I0, I1, and L3. Before H0, the convergence gate is **F0 + R3 + I2 + I5**. -The runtime merge lane is strictly **H0 → H1 → H2 → H3 → H4 → H5 → H6 → H7 → H8 → C1 → C2 → C3 → N1 → O1 → O2 → QA3 → O3 → O4**. J5 may land independently at any time before QA3. This ordering prevents concurrent rewrites of `agent-harness.ts`, assigns every public method, and ensures every live path lands only after its reducer, telemetry, interception, and effect boundaries exist. +The runtime merge lane is strictly **H0 → H1 → H2 → H3 → H4 → H5 → H6 → H7 → H8 → C1 → C2 → C3 → N1 → O1 → O2 → QA3 → O3 → O4**. J6 may land independently at any time before QA3. This ordering prevents concurrent rewrites of `agent-harness.ts`, assigns every public method, and ensures every live path lands only after its reducer, telemetry, interception, and effect boundaries exist. ## 21. Required reading From f0feecd75250827b936b89664b6eab454c63528d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 7 Aug 2026 11:56:44 +0200 Subject: [PATCH 039/284] fix(ai): update OpenCode completions fixture --- packages/ai/test/openai-completions-tool-choice.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 844c819c6d2..3837799636d 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1403,7 +1403,7 @@ describe("openai-completions tool_choice", () => { }); it("sends max_tokens for OpenCode completions models", async () => { - const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "grok-build-0.1")!] as const; + const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "kimi-k2.6")!] as const; for (const model of cases) { let payload: unknown; From 958c13f25080b59d4b736193f972a8502a7a2f8b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 7 Aug 2026 11:56:52 +0200 Subject: [PATCH 040/284] docs(agent): restart checkpoint after auto-compaction --- packages/agent/docs/harness-v2.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 896303e7fb2..7d3636f8b44 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -2239,7 +2239,10 @@ async function driverLoop(): Promise { for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); for (const m of steeringForThisCheckpoint(op)) await fx.consumeQueueItem(op.id, "steer", m.id); if (op.aborting) return await abortPath(); - if (await contextOverLimit()) await autoCompact(pressureReason()); // may throw RunFailed + if (await contextOverLimit()) { + await autoCompact(pressureReason()); // may throw RunFailed + continue; // fresh checkpoint: input may have arrived during compaction + } if (needsAssistant()) { const outcome = await runTurn(); From 4bf1bba203c699a0b79da669b084052c72b7a35a Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 12:32:20 +0200 Subject: [PATCH 041/284] docs(agent): reserve I2 --- packages/agent/docs/harness-v2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 7d3636f8b44..f12f4647aa7 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -3296,6 +3296,9 @@ I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins - Primary files: `packages/agent/src/harness/hooks.ts`, `packages/agent/test/harness/hooks.test.ts`. - Implement typed registration, stable-id validation, ordered aggregation, error isolation, fail-closed `before_tool`, and per-id resume data handling. - Acceptance: focused tests cover every section 11 aggregation and failure rule; no operation wiring yet. + +**Reserved: I2 by @vegarsti.** + - [ ] **I2 — passive events and watch buffering.** Dependencies: none. - Primary files: `packages/agent/src/harness/events.ts`, `packages/agent/test/harness/events.test.ts`. - Implement passive listener isolation and the snapshot/start/unsubscribe buffer primitive used by lane and session watchers. From 14ad9801be29948516cf55c65bcd864d5cf8ff9a Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 13:20:40 +0200 Subject: [PATCH 042/284] feat(agent): add harness events subscription interface --- packages/agent/src/harness/events.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/agent/src/harness/events.ts diff --git a/packages/agent/src/harness/events.ts b/packages/agent/src/harness/events.ts new file mode 100644 index 00000000000..1c466ed478b --- /dev/null +++ b/packages/agent/src/harness/events.ts @@ -0,0 +1,7 @@ +export interface Events { + /** + * Register a passive listener for future events and return its unsubscribe function. + * Earlier events are not replayed and no current-state snapshot is provided; use a lane or session watch for both. + */ + on(type: string, listener: (event: unknown) => void | Promise): () => void; +} From 4a0e2f115ad46d34c19d200c6a71fa79d264092d Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 13:47:01 +0200 Subject: [PATCH 043/284] fix(agent): complete JSONL crash and corruption handling --- packages/agent/docs/harness-v2-test-matrix.md | 16 +-- packages/agent/docs/harness-v2.md | 4 +- .../agent/src/harness/session/jsonl/repo.ts | 11 +- .../agent/test/harness/session/jsonl.test.ts | 115 ++++++++++++++++-- 4 files changed, 126 insertions(+), 20 deletions(-) diff --git a/packages/agent/docs/harness-v2-test-matrix.md b/packages/agent/docs/harness-v2-test-matrix.md index 383d6eb1ca5..8df60635a30 100644 --- a/packages/agent/docs/harness-v2-test-matrix.md +++ b/packages/agent/docs/harness-v2-test-matrix.md @@ -16,10 +16,10 @@ No production or test changes are part of QA1. | Area | Removed cases | Status | |---|---:|---| | Harness runtime and stream behavior | 37 | Mostly uncovered by design while `AgentHarness` is scaffolded; assigned to H/L/I/C/N packages. Scaffold-safe configuration is covered by F0. | -| Branch query and corruption behavior | 6 | Core query semantics are covered; bounded SQLite validation gaps were ported by QA2, and remaining JSONL corruption gaps are assigned to J3. | +| Branch query and corruption behavior | 6 | Core query semantics are covered; bounded SQLite validation gaps were ported by QA2, and JSONL corruption behavior is covered by J3. | | Compaction helper behavior | 2 | Covered by current compaction/context tests. | | Memory/SQLite v4 conformance entrypoints | 3 | Ported to `packages/agent/test/harness/session/*` and `packages/session-backends/sqlite-node/test/conformance.test.ts`. | -| Repository/backend lifecycle and JSONL behavior | 38 | Most covered by v4 conformance or J0–J2; QA2 lifecycle/query audits are resolved, with remaining crash/corruption/v3 gaps assigned to J3–J5. | +| Repository/backend lifecycle and JSONL behavior | 38 | Format-4 lifecycle and crash/corruption behavior are covered by v4 conformance and J0–J3; format-3 normalization and conversion remain assigned to J4–J5. | | Session aggregate/context behavior | 17 | Covered by v4 conformance plus current context tests. | | SQLite search | 1 | Ported to SQLite package search tests; old scanning backend is inapplicable. | @@ -122,7 +122,7 @@ Removed files: | builds context from the branch storage without loading complete history | Inapplicable / Covered | Old branch-storage optimization was deleted; v4 context behavior is covered by `session/context.test.ts`. | | rejects repository operations and session writes after disposal | Covered / Inapplicable | The v4 core `SessionRepo` contract has no disposable state, and the in-memory/JSONL repos do not implement permanent disposal. SQLite disposal is resource release rather than repo poisoning; `packages/session-backends/sqlite-node/test/repository.test.ts` covers the remaining applicable behavior in `closes active sessions when the repository is disposed`, proving active session writes reject after repository disposal. | | supports lexical ownership with await using | Inapplicable | The old test covered permanent disposal on the deleted in-memory repository. The v4 core `SessionRepo` contract has no disposable surface, and memory/JSONL repos do not implement lexical ownership. SQLite `await using` is resource cleanup rather than repo poisoning; active-session closure is covered by `closes active sessions when the repository is disposed` in `packages/session-backends/sqlite-node/test/repository.test.ts`. | -| serializes conflicting create and fork destinations | Uncovered / J3 | The old test covered JSONL backend-wide serialization for concurrent create/create and create/fork operations targeting the same id. V4 intentionally removed global repository serialization, but the remaining format-4 lifecycle/concurrency question is whether conflicting destination creation can duplicate files or silently overwrite; assign to J3 lifecycle/concurrency edge cases. | +| serializes conflicting create and fork destinations | Covered | JSONL tests cover concurrent create/create, create/fork, and fork/fork operations targeting the same destination, plus reservation release after failed operations. | | encodes custom session IDs used in filenames | Covered | J2 JSONL repository lifecycle validates file-safe ids; `jsonl.test.ts` rejects invalid coding-agent filenames. | | allows appends to different sessions to run concurrently | Covered | J2/v4 repository conformance and JSONL concurrent write tests cover accepted concurrent writes without the old keyed queue. | | caps concurrent operations across JSONL sessions at four by default | Inapplicable | Old JSONL keyed-operation-queue implementation detail was deleted. | @@ -135,8 +135,8 @@ Removed files: | waits for accepted appends before disposal and rejects later writes | Inapplicable | The old test covered deleted JSONL repository disposal: drain accepted appends, enter a permanent disposed state, then reject later writes through existing sessions. V4 JSONL repos are not disposable, do not retain opened storages, and have no repo-level closed state. Per-session append serialization remains covered by backend conformance `linearizes concurrent writes across two lanes` and JSONL-specific `persists concurrent cross-lane writes in shared sequence order`; close/drain/reject-after-close semantics belong to harness H5/O3, not `SessionRepo` disposal. | | parses once when opened and retains state across appends | Inapplicable | Old JSONL in-memory aggregate implementation detail; v4 correctness is covered by reopen/shared-sequence tests. | | collects sessions below encoded cwd directories and lists by cwd | Covered | J2 metadata lifecycle and listing tests cover v4 JSONL metadata and cwd filtering. | -| fails loudly when listing a malformed session file | Uncovered | J3 owns JSONL crash/corruption behavior for malformed files. | -| rejects a missing active leaf when opened | Uncovered | J3 owns JSONL missing-reference rejection. SQLite equivalent is covered in `repository.test.ts`. | +| fails loudly when listing a malformed session file | Inapplicable / Covered | Section 13 now requires best-effort listing: malformed headers are skipped without opening or replaying the session. JSONL tests cover skipping the malformed file while retaining valid results; direct `open()` still rejects it. | +| rejects a missing active leaf when opened | Covered | JSONL and SQLite tests cover missing-reference rejection. | | opens, deletes, and forks by metadata (JSONL) | Covered | J2 JSONL repo conformance. | | persists header metadata through create, list, and fork | Covered | J0 codec and J2 repository metadata tests. | | repository disposal closes its owned storage | Covered / Inapplicable | Old in-memory repo disposal is inapplicable because v4 memory/JSONL repos are not disposable and do not own returned session storage lifetimes. SQLite is the only disposable repository because it owns DB/lease resources; active-session closure is covered by `closes active sessions when the repository is disposed`, and DB close behavior is covered by existing SQLite connection lifecycle tests. | @@ -145,10 +145,10 @@ Removed files: | includes assistant and summary usage in statistics | Covered | v4 conformance `keeps latest-value facts and computes ledger statistics across lanes`, JSONL storage, and SQLite repository statistics tests. | | stops branch traversal at retained-tail compaction | Covered / Inapplicable | Branch-query stop semantics are still required outside context projection and are covered explicitly by backend conformance `supports bounded filtered and cursor-based queries` via `findEntriesOnBranch({ stopAtType: "compaction" })` across memory, JSONL, and SQLite. Retained-tail materialization is covered by context test `starts at the latest compaction and materializes its retained tail`. The old implicit `getBranch()` auto-stop-at-retained-tail-compaction behavior is inapplicable because v4 uses explicit branch bounds plus context projection. | | writes headers and entries and reopens the aggregate | Covered | J1/J2 JSONL storage/repository tests. | -| fails loudly for malformed headers and entries | Covered / J3 | J3 owns malformed physical file behavior; current JSONL tests already cover malformed tail/middle lines. | +| fails loudly for malformed headers and entries | Covered | Direct JSONL opens reject malformed headers and complete invalid mutations without modifying the file; only a torn final JSON fragment is repaired. Listing separately skips malformed headers as required by section 13. | | enforces entry uniqueness and does not recreate deleted files | Covered | v4 conformance rejects duplicate ids; J2 lifecycle covers delete/reopen behavior. | | scopes entry uniqueness to the session path | Covered | v4 repository/session isolation conformance. | -| rejects non-object header metadata | Uncovered | J3/J4 should cover malformed JSONL header metadata for format-4 and v3 normalization. | +| rejects non-object header metadata | Covered | Format-4 open rejects non-object header metadata, while listing skips that malformed file. Format-3 normalization remains assigned to J4. | ## Session aggregate and context tests @@ -187,7 +187,7 @@ Removed case from `packages/agent/test/harness/sqlite-node.test.ts`. These packages must land before QA3 can re-evaluate the uncovered rows above. They do not use this matrix as their test plan. - **QA2**: completed storage/query audit and ports for bounded-query corruption/validation behavior, repository/session disposal lifecycle, listing/disposal barriers, and branch-query retained-tail semantics outside context projection. -- **J3**: JSONL malformed file, torn-tail, missing-reference, and lifecycle/concurrency edge cases. +- **J3**: completed JSONL malformed-file, torn-tail, missing-reference, and lifecycle/concurrency coverage. - **J4/J5**: v3 read-only normalization and first-write conversion; include malformed v3/header metadata cases. - **I1/I2/I3/I4/L1-L3**: hook/event/mutation/effects/loop primitive coverage required before runtime harness tests can return. - **H1-H8**: durable run, queue, configuration, wait/abort, tool, recovery, and deferred-provider runtime behavior formerly covered by legacy `agent-harness*.test.ts`. diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index f12f4647aa7..b88e5678b63 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -1660,7 +1660,7 @@ interface JsonlSessionListOptions { cwd?: string; } A v3 `parentSession` path resolves to the parent header's id when that file is available. If it is unavailable, metadata retains `legacyParentSessionPath`; first-write conversion preserves that optional header field rather than silently dropping the relationship. Format-4 code uses `parentSessionId` for repository relationships. `modifiedAt` is read from the filesystem and is not a sequenced session mutation. -The repository layout matches coding-agent v3. Under `sessionsRoot`, each resolved cwd uses a directory named `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`. New files are named `${createdAtIso.replace(/[:.]/g, "-")}_${sessionId}.jsonl`. `list({ cwd })` scans that cwd's directory; `list()` scans every direct child directory. First-write v3 conversion replaces the original file in place and never changes its directory or filename. +The repository layout matches coding-agent v3. Under `sessionsRoot`, each resolved cwd uses a directory named `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`. New files are named `${createdAtIso.replace(/[:.]/g, "-")}_${sessionId}.jsonl`. `list({ cwd })` scans that cwd's directory; `list()` scans every direct child directory. Listing reads only each file's header and filesystem metadata; it does not open or replay the session. A file with a missing or malformed header is omitted from the result. First-write v3 conversion replaces the original file in place and never changes its directory or filename. One file per session: a header line, then one JSON object per line, in `seq` order. Every logical mutation is exactly one line; a line is the atomic unit. @@ -3265,7 +3265,7 @@ These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete ` - [x] **J2 — format-4 repository lifecycle and forks.** Dependencies: J1. - Add create/open/list/delete, one writer queue per session, metadata ordering/filtering, branch/tree forks, and the concrete public `JsonlSessionRepo` export. - Acceptance: the complete backend-neutral conformance suite passes against JSONL, including concurrent lane writes and forks. -- [ ] **J3 — format-4 crash and corruption behavior.** Dependencies: J2. +- [x] **J3 — format-4 crash and corruption behavior.** Dependencies: J2. - Add torn-tail truncation, malformed-interior rejection, missing-reference rejection, and lifecycle/concurrency edge cases. - Acceptance: acknowledged writes survive reopen and malformed non-tail data is never silently repaired. - [ ] **J4 — read-only v3 normalization.** Dependencies: J3. diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index b683a1babda..46e7f006f51 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -2,7 +2,7 @@ import { uuidv7 } from "@earendil-works/pi-ai"; import { assertJsonSerializable, Session } from "../session.ts"; import { type ForkOptions, SessionError, type SessionRepo } from "../types.ts"; import { metadataFromHeader, parseHeader } from "./codec.ts"; -import { fileResult, invalidFile } from "./errors.ts"; +import { fileResult } from "./errors.ts"; import { JsonlSessionStorage } from "./storage.ts"; import type { JsonlSessionCreateOptions, @@ -167,8 +167,13 @@ export class JsonlSessionRepo await this.fs.readTextLines(file.path, { maxLines: 1 }), `Failed to read session header ${file.path}`, ); - if (!firstLine) throw invalidFile(file.path, 1, "is missing a header"); - metadata.push(metadataFromHeader(parseHeader(firstLine, file.path), file.path, file.mtimeMs)); + if (!firstLine) continue; + try { + metadata.push(metadataFromHeader(parseHeader(firstLine, file.path), file.path, file.mtimeMs)); + } catch (error) { + if (error instanceof SessionError && error.code === "invalid_entry") continue; + throw error; + } } } return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); diff --git a/packages/agent/test/harness/session/jsonl.test.ts b/packages/agent/test/harness/session/jsonl.test.ts index abbdb8f96eb..878cebcd6b1 100644 --- a/packages/agent/test/harness/session/jsonl.test.ts +++ b/packages/agent/test/harness/session/jsonl.test.ts @@ -122,6 +122,41 @@ describe("JSONL v4 persistence", () => { expect(await repository.list({ cwd: join(root, "other", "project") })).toEqual([]); }); + it("rejects a malformed JSON header on open and skips it when listing", async () => { + const root = createTempDir(); + const repository = createRepository(root); + await repository.create({ id: "valid", cwd: root }); + const session = await repository.create({ id: "malformed-header", cwd: root }); + const metadata = await session.getMetadata(); + const malformed = "not json\n"; + writeFileSync(metadata.path, malformed); + + await expect(repository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); + expect((await repository.list({ cwd: root })).map((listed) => listed.id)).toEqual(["valid"]); + expect(readFileSync(metadata.path, "utf8")).toBe(malformed); + }); + + it("rejects non-object header metadata on open and skips it when listing", async () => { + const root = createTempDir(); + const repository = createRepository(root); + await repository.create({ id: "valid", cwd: root }); + const session = await repository.create({ id: "invalid-header-metadata", cwd: root }); + const metadata = await session.getMetadata(); + const malformed = `${JSON.stringify({ + kind: "header", + version: 4, + id: metadata.id, + createdAt: metadata.createdAt, + cwd: metadata.cwd, + metadata: "invalid", + })}\n`; + writeFileSync(metadata.path, malformed); + + await expect(repository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); + expect((await repository.list({ cwd: root })).map((listed) => listed.id)).toEqual(["valid"]); + expect(readFileSync(metadata.path, "utf8")).toBe(malformed); + }); + it("rejects session ids that cannot be used in coding-agent filenames", async () => { const root = createTempDir(); const repository = createRepository(root); @@ -145,30 +180,66 @@ describe("JSONL v4 persistence", () => { expect((await repository.list()).map((metadata) => metadata.id)).toEqual(["shared", "shared"]); }); - it("rejects concurrent create calls for the same destination", async () => { + it.each([ + ["create", "create"], + ["create", "fork"], + ["fork", "fork"], + ] as const)("rejects concurrent %s and %s calls for the same destination", async (firstKind, secondKind) => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); try { const root = createTempDir(); const repository = createRepository(root); const cwd = join(root, "workspace"); - - const results = await Promise.allSettled([ - repository.create({ id: "same", cwd }), - repository.create({ id: "same", cwd }), - ]); + const source = await repository.create({ id: "source", cwd }); + const sourceMetadata = await source.getMetadata(); + const run = (kind: "create" | "fork") => + kind === "create" + ? repository.create({ id: "same", cwd }) + : repository.fork(sourceMetadata, { id: "same", cwd }); + + const results = await Promise.allSettled([run(firstKind), run(secondKind)]); const successes = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); expect(successes).toHaveLength(1); expect(failures).toHaveLength(1); expect(failures[0]).toMatchObject({ code: "already_exists" }); - expect((await repository.list({ cwd })).map((listed) => listed.id)).toEqual(["same"]); + expect((await repository.list({ cwd })).filter((listed) => listed.id === "same")).toHaveLength(1); } finally { vi.useRealTimers(); } }); + it.each(["create", "fork"] as const)("releases a destination reservation after a failed %s", async (kind) => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const repository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); + const cwd = join(root, "workspace"); + const source = await repository.create({ id: "source", cwd }); + const sourceMetadata = await source.getMetadata(); + const run = () => + kind === "create" + ? repository.create({ id: "retry", cwd }) + : repository.fork(sourceMetadata, { id: "retry", cwd }); + + if (kind === "create") { + vi.spyOn(env, "writeFile").mockResolvedValueOnce({ + ok: false, + error: new FileError("unknown", "injected creation failure"), + }); + } else { + vi.spyOn(env, "renameFile").mockResolvedValueOnce({ + ok: false, + error: new FileError("unknown", "injected fork failure"), + }); + } + + await expect(run()).rejects.toMatchObject({ code: "storage" }); + await expect(run()).resolves.toBeDefined(); + expect((await repository.list({ cwd })).filter((listed) => listed.id === "retry")).toHaveLength(1); + }); + it("sorts listed sessions by current filesystem modification time", async () => { const root = createTempDir(); const repository = createRepository(root); @@ -415,6 +486,15 @@ describe("JSONL v4 persistence", () => { expect((await reopened.getEntry(appendedId))?.seq).toBe(2); }); + it("rejects a complete invalid final mutation without modifying the file", async () => { + const root = createTempDir(); + const metadata = writeRawSession(root, "invalid-final-mutation", [{ kind: "unknown", seq: 1 }]); + const corrupted = readFileSync(metadata.path, "utf8"); + + await expect(createRepository(root).open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); + expect(readFileSync(metadata.path, "utf8")).toBe(corrupted); + }); + it("rejects a malformed middle line without modifying the file", async () => { const root = createTempDir(); const repository = createRepository(root); @@ -662,4 +742,25 @@ describe("JSONL v4 persistence", () => { expect(readFileSync(metadata.path, "utf8")).toBe(original); expect(existsSync(`${metadata.path}.tmp`)).toBe(false); }); + + it("preserves the session when torn-tail repair cannot be published", async () => { + const root = createTempDir(); + const repository = createRepository(root); + const session = await repository.create({ id: "repair-rename-failure", cwd: root }); + const metadata = await session.getMetadata(); + await session.appendCustomEntry("kept"); + appendFileSync(metadata.path, '{"kind":"entry"'); + const original = readFileSync(metadata.path, "utf8"); + + const env = new NodeExecutionEnv({ cwd: root }); + vi.spyOn(env, "renameFile").mockResolvedValueOnce({ + ok: false, + error: new FileError("unknown", "injected repair rename failure"), + }); + const failingRepository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); + + await expect(failingRepository.open(metadata)).rejects.toMatchObject({ code: "storage" }); + expect(readFileSync(metadata.path, "utf8")).toBe(original); + expect(existsSync(`${metadata.path}.tmp`)).toBe(false); + }); }); From fe10558eb3fa6d8fc3f6211bf1532e1659b9093d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 7 Aug 2026 14:16:50 +0200 Subject: [PATCH 044/284] fix(ai): retry upstream request buffer failures --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/utils/retry.ts | 1 + packages/ai/test/retry.test.ts | 11 +++++++++++ 3 files changed, 16 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1328c60a7f9..86fc38efef1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed upstream request buffer limit failures to trigger automatic assistant retries. + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index 463b90329c6..b17bf091473 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -41,6 +41,7 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([ // Wrapper/provider text for transient upstream failures, including OpenRouter // "Provider returned error" responses (#2264). "provider.?returned.?error", + "exceeded request buffer limit while retrying upstream", // Network, proxy, and fetch transport failures. This includes OpenAI Codex // raw-fetch failures such as "upstream connect", "connection refused", and diff --git a/packages/ai/test/retry.test.ts b/packages/ai/test/retry.test.ts index fc79f6916b8..0d8875e08c0 100644 --- a/packages/ai/test/retry.test.ts +++ b/packages/ai/test/retry.test.ts @@ -40,6 +40,17 @@ describe("provider retry classification", () => { ).toBe(true); }); + it("matches upstream request buffer exhaustion wording", () => { + expect( + isRetryableAssistantError( + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "Error: exceeded request buffer limit while retrying upstream", + }), + ), + ).toBe(true); + }); + it.each([ wrappedDnsLookupError, "connect ENOTFOUND api.example.com", From 7aca0d7b3e041a9e2b635e8370b2549f032932d6 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 14:27:35 +0200 Subject: [PATCH 045/284] fix(agent): make JSONL decode errors explicit Return typed results from JSONL parsers, add file context during replay, and classify torn tails explicitly. --- .../agent/src/harness/session/jsonl/codec.ts | 156 ++++++++++-------- .../agent/src/harness/session/jsonl/errors.ts | 14 +- .../agent/src/harness/session/jsonl/repo.ts | 9 +- .../src/harness/session/jsonl/storage.ts | 49 +++--- .../test/harness/session/jsonl-codec.test.ts | 20 ++- .../agent/test/harness/session/jsonl.test.ts | 2 +- 6 files changed, 143 insertions(+), 107 deletions(-) diff --git a/packages/agent/src/harness/session/jsonl/codec.ts b/packages/agent/src/harness/session/jsonl/codec.ts index e0fdc04fd59..c2f970fef68 100644 --- a/packages/agent/src/harness/session/jsonl/codec.ts +++ b/packages/agent/src/harness/session/jsonl/codec.ts @@ -1,6 +1,7 @@ +import { err, ok, type Result } from "../../types.ts"; import type { SessionMutation } from "../state.ts"; import type { Entry, LaneRecord } from "../types.ts"; -import { invalidFile } from "./errors.ts"; +import { JsonlDecodeError } from "./errors.ts"; import type { JsonlSessionMetadata, JsonlV4Header } from "./types.ts"; const ENTRY_TYPES = new Set([ @@ -29,69 +30,84 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function parseObject(line: string, path: string, lineNumber: number): Record { +function parseObject(line: string): Record { let value: unknown; try { value = JSON.parse(line); } catch (error) { - throw invalidFile(path, lineNumber, "is not valid JSON", error instanceof Error ? error : undefined); + throw new JsonlDecodeError("syntax", "is not valid JSON", error instanceof Error ? error : undefined); } - if (!isObject(value)) throw invalidFile(path, lineNumber, "is not a JSON object"); + if (!isObject(value)) throw new JsonlDecodeError("schema", "is not a JSON object"); return value; } -function requireString(value: unknown, path: string, line: number, field: string): string { - if (typeof value !== "string") throw invalidFile(path, line, `has invalid ${field}`); +function requireString(value: unknown, field: string): string { + if (typeof value !== "string") throw new JsonlDecodeError("schema", `has invalid ${field}`); return value; } -function requireSequence(value: unknown, path: string, line: number): number { - if (!Number.isSafeInteger(value) || (value as number) <= 0) throw invalidFile(path, line, "has invalid seq"); +function requireSequence(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new JsonlDecodeError("schema", "has invalid seq"); + } return value as number; } -function requireTimestamp(value: unknown, path: string, line: number): number { - if (!Number.isSafeInteger(value) || (value as number) < 0) throw invalidFile(path, line, "has invalid timestamp"); +function requireTimestamp(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new JsonlDecodeError("schema", "has invalid timestamp"); + } return value as number; } -function requireNullableId(value: unknown, path: string, line: number, field: string): string | null { +function requireNullableId(value: unknown, field: string): string | null { if (value !== null && typeof value !== "string") { - throw invalidFile(path, line, `has invalid ${field}`); + throw new JsonlDecodeError("schema", `has invalid ${field}`); } return value as string | null; } -export function parseHeader(line: string, path: string): JsonlV4Header { - const value = parseObject(line, path, 1); - if (value.kind !== "header") throw invalidFile(path, 1, "is not a header"); - if (value.version !== 4) throw invalidFile(path, 1, "has unsupported session version"); +function decodeHeader(line: string): JsonlV4Header { + const value = parseObject(line); + if (value.kind !== "header") throw new JsonlDecodeError("schema", "is not a header"); + if (value.version !== 4) throw new JsonlDecodeError("schema", "has unsupported session version"); const parentSessionId = value.parentSessionId; if (parentSessionId !== undefined && typeof parentSessionId !== "string") { - throw invalidFile(path, 1, "has invalid parentSessionId"); + throw new JsonlDecodeError("schema", "has invalid parentSessionId"); } const legacyParentSessionPath = value.legacyParentSessionPath; if (legacyParentSessionPath !== undefined && typeof legacyParentSessionPath !== "string") { - throw invalidFile(path, 1, "has invalid legacyParentSessionPath"); + throw new JsonlDecodeError("schema", "has invalid legacyParentSessionPath"); } if (parentSessionId !== undefined && legacyParentSessionPath !== undefined) { - throw invalidFile(path, 1, "has both parentSessionId and legacyParentSessionPath"); + throw new JsonlDecodeError("schema", "has both parentSessionId and legacyParentSessionPath"); } const metadataValue = value.metadata; - if (metadataValue !== undefined && !isObject(metadataValue)) throw invalidFile(path, 1, "has invalid metadata"); + if (metadataValue !== undefined && !isObject(metadataValue)) { + throw new JsonlDecodeError("schema", "has invalid metadata"); + } const metadata = metadataValue as JsonlV4Header["metadata"]; return { kind: "header", version: 4, - id: requireString(value.id, path, 1, "id"), - createdAt: requireTimestamp(value.createdAt, path, 1), - cwd: requireString(value.cwd, path, 1, "cwd"), + id: requireString(value.id, "id"), + createdAt: requireTimestamp(value.createdAt), + cwd: requireString(value.cwd, "cwd"), parentSessionId, legacyParentSessionPath, metadata, }; } +export function parseHeader(line: string): Result { + try { + return ok(decodeHeader(line)); + } catch (error) { + if (error instanceof JsonlDecodeError) return err(error); + throw error; + } +} + export function encodeHeader(header: JsonlV4Header): string { return `${JSON.stringify(header)}\n`; } @@ -112,19 +128,16 @@ export function metadataFromHeader(header: JsonlV4Header, path: string, modified }; } -function parseEntryMutation( - value: Record, - path: string, - lineNumber: number, - seq: number, -): Extract { - const lane = value.lane === undefined ? undefined : requireString(value.lane, path, lineNumber, "lane"); - const id = requireString(value.id, path, lineNumber, "id"); - const type = requireString(value.type, path, lineNumber, "entry type"); - if (!ENTRY_TYPES.has(type as Entry["type"])) throw invalidFile(path, lineNumber, `has unknown entry type ${type}`); - const parentId = requireNullableId(value.parentId, path, lineNumber, "parentId"); - const timestamp = requireTimestamp(value.timestamp, path, lineNumber); - if (type === "custom") requireString(value.customType, path, lineNumber, "customType"); +function parseEntryMutation(value: Record, seq: number): Extract { + const lane = value.lane === undefined ? undefined : requireString(value.lane, "lane"); + const id = requireString(value.id, "id"); + const type = requireString(value.type, "entry type"); + if (!ENTRY_TYPES.has(type as Entry["type"])) { + throw new JsonlDecodeError("schema", `has unknown entry type ${type}`); + } + const parentId = requireNullableId(value.parentId, "parentId"); + const timestamp = requireTimestamp(value.timestamp); + if (type === "custom") requireString(value.customType, "customType"); const { kind: _kind, lane: _lane, ...entryFields } = value; const entry = { ...entryFields, id, type, parentId, seq, timestamp } as unknown as Entry; return lane === undefined ? { kind: "entry", entry } : { kind: "entry", lane, entry }; @@ -132,25 +145,23 @@ function parseEntryMutation( function parseRecordMutation( value: Record, - path: string, - lineNumber: number, seq: number, ): Extract { - const id = requireString(value.id, path, lineNumber, "id"); - const lane = requireString(value.lane, path, lineNumber, "lane"); - const type = requireString(value.type, path, lineNumber, "record type"); + const id = requireString(value.id, "id"); + const lane = requireString(value.lane, "lane"); + const type = requireString(value.type, "record type"); if (!RECORD_TYPES.has(type as LaneRecord["type"])) { - throw invalidFile(path, lineNumber, `has unknown record type ${type}`); + throw new JsonlDecodeError("schema", `has unknown record type ${type}`); } - const timestamp = requireTimestamp(value.timestamp, path, lineNumber); + const timestamp = requireTimestamp(value.timestamp); if (type === "operation_started") { - if (!isObject(value.intent)) throw invalidFile(path, lineNumber, "has invalid intent"); - const operationKind = requireString(value.intent.kind, path, lineNumber, "operation kind"); + if (!isObject(value.intent)) throw new JsonlDecodeError("schema", "has invalid intent"); + const operationKind = requireString(value.intent.kind, "operation kind"); if (!OPERATION_KINDS.has(operationKind)) { - throw invalidFile(path, lineNumber, `has unknown operation kind ${operationKind}`); + throw new JsonlDecodeError("schema", `has unknown operation kind ${operationKind}`); } } - if (type === "operation_finished") requireString(value.runId, path, lineNumber, "runId"); + if (type === "operation_finished") requireString(value.runId, "runId"); const { kind: _kind, ...recordFields } = value; return { kind: "record", @@ -158,58 +169,57 @@ function parseRecordMutation( }; } -function parseLaneMutation( - value: Record, - path: string, - lineNumber: number, - seq: number, -): Extract { +function parseLaneMutation(value: Record, seq: number): Extract { return { kind: "lane", seq, - lane: requireString(value.lane, path, lineNumber, "lane"), - leafId: requireNullableId(value.leafId, path, lineNumber, "leafId"), + lane: requireString(value.lane, "lane"), + leafId: requireNullableId(value.leafId, "leafId"), }; } -function parseFactMutation( - value: Record, - path: string, - lineNumber: number, - seq: number, -): Extract { +function parseFactMutation(value: Record, seq: number): Extract { if (value.fact === "name") { - return { kind: "fact", seq, fact: "name", name: requireString(value.name, path, lineNumber, "name") }; + return { kind: "fact", seq, fact: "name", name: requireString(value.name, "name") }; } if (value.fact === "label") { if (value.label !== undefined && typeof value.label !== "string") { - throw invalidFile(path, lineNumber, "has invalid label"); + throw new JsonlDecodeError("schema", "has invalid label"); } return { kind: "fact", seq, fact: "label", - targetId: requireString(value.targetId, path, lineNumber, "targetId"), + targetId: requireString(value.targetId, "targetId"), label: value.label, }; } - throw invalidFile(path, lineNumber, "has unknown fact type"); + throw new JsonlDecodeError("schema", "has unknown fact type"); } -export function parseMutation(line: string, path: string, lineNumber: number): SessionMutation { - const value = parseObject(line, path, lineNumber); - const seq = requireSequence(value.seq, path, lineNumber); +function decodeMutation(line: string): SessionMutation { + const value = parseObject(line); + const seq = requireSequence(value.seq); switch (value.kind) { case "entry": - return parseEntryMutation(value, path, lineNumber, seq); + return parseEntryMutation(value, seq); case "record": - return parseRecordMutation(value, path, lineNumber, seq); + return parseRecordMutation(value, seq); case "lane": - return parseLaneMutation(value, path, lineNumber, seq); + return parseLaneMutation(value, seq); case "fact": - return parseFactMutation(value, path, lineNumber, seq); + return parseFactMutation(value, seq); default: - throw invalidFile(path, lineNumber, "has unknown mutation kind"); + throw new JsonlDecodeError("schema", "has unknown mutation kind"); + } +} + +export function parseMutation(line: string): Result { + try { + return ok(decodeMutation(line)); + } catch (error) { + if (error instanceof JsonlDecodeError) return err(error); + throw error; } } diff --git a/packages/agent/src/harness/session/jsonl/errors.ts b/packages/agent/src/harness/session/jsonl/errors.ts index 86a8b6fcb3e..bc191554118 100644 --- a/packages/agent/src/harness/session/jsonl/errors.ts +++ b/packages/agent/src/harness/session/jsonl/errors.ts @@ -1,6 +1,16 @@ import type { FileError, Result } from "../../types.ts"; import { SessionError } from "../types.ts"; +export class JsonlDecodeError extends Error { + readonly kind: "syntax" | "schema"; + + constructor(kind: "syntax" | "schema", message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "JsonlDecodeError"; + this.kind = kind; + } +} + export function fileResult(result: Result, message: string): T { if (!result.ok) { throw new SessionError( @@ -12,6 +22,6 @@ export function fileResult(result: Result, message: string): T return result.value; } -export function invalidFile(path: string, line: number, message: string, cause?: Error): SessionError { - return new SessionError("invalid_entry", `Invalid JSONL v4 session ${path}: line ${line} ${message}`, cause); +export function invalidFile(path: string, line: number, cause: Error): SessionError { + return new SessionError("invalid_entry", `Invalid JSONL v4 session ${path}: line ${line} ${cause.message}`, cause); } diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index 46e7f006f51..98bb12ddde0 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -168,12 +168,9 @@ export class JsonlSessionRepo `Failed to read session header ${file.path}`, ); if (!firstLine) continue; - try { - metadata.push(metadataFromHeader(parseHeader(firstLine, file.path), file.path, file.mtimeMs)); - } catch (error) { - if (error instanceof SessionError && error.code === "invalid_entry") continue; - throw error; - } + const headerResult = parseHeader(firstLine); + if (!headerResult.ok) continue; + metadata.push(metadataFromHeader(headerResult.value, file.path, file.mtimeMs)); } } return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); diff --git a/packages/agent/src/harness/session/jsonl/storage.ts b/packages/agent/src/harness/session/jsonl/storage.ts index d9dca47af71..92f758dd2ae 100644 --- a/packages/agent/src/harness/session/jsonl/storage.ts +++ b/packages/agent/src/harness/session/jsonl/storage.ts @@ -17,7 +17,7 @@ import { type SessionStorage, } from "../types.ts"; import { encodeHeader, encodeMutation, metadataFromHeader, parseHeader, parseMutation } from "./codec.ts"; -import { fileResult, invalidFile } from "./errors.ts"; +import { fileResult, invalidFile, JsonlDecodeError } from "./errors.ts"; import type { JsonlSessionMetadata, JsonlSessionRepoFileSystem, JsonlV4Header } from "./types.ts"; /** @@ -70,25 +70,36 @@ export class JsonlSessionStorage implements SessionStorage const content = fileResult(await fs.readTextFile(path), `Failed to read session ${path}`); const physicalLines = content.split("\n"); if (physicalLines.at(-1) === "") physicalLines.pop(); - if (physicalLines.length === 0 || !physicalLines[0]) throw invalidFile(path, 1, "is missing a header"); - const header = parseHeader(physicalLines[0], path); + if (physicalLines.length === 0 || !physicalLines[0]) { + throw invalidFile(path, 1, new JsonlDecodeError("schema", "is missing a header")); + } + const headerResult = parseHeader(physicalLines[0]); + if (!headerResult.ok) throw invalidFile(path, 1, headerResult.error); const fileInfo = fileResult(await fs.fileInfo(path), `Failed to read session metadata ${path}`); - const storage = new JsonlSessionStorage(fs, metadataFromHeader(header, path, fileInfo.mtimeMs)); + const storage = new JsonlSessionStorage(fs, metadataFromHeader(headerResult.value, path, fileInfo.mtimeMs)); for (let index = 1; index < physicalLines.length; index++) { const line = physicalLines[index]!; - let mutation: SessionMutation; + const mutationResult = parseMutation(line); + if (!mutationResult.ok) { + const isTornTail = index === physicalLines.length - 1 && mutationResult.error.kind === "syntax"; + if (isTornTail) { + // Drop the unacknowledged partial append by atomically publishing the valid prefix. + const validPrefix = `${physicalLines.slice(0, index).join("\n")}\n`; + await publishFileAtomically(fs, path, async (tempPath) => { + fileResult(await fs.writeFile(tempPath, validPrefix), `Failed to stage torn-tail repair ${path}`); + }); + return storage; + } + throw invalidFile(path, index + 1, mutationResult.error); + } try { - mutation = parseMutation(line, path, index + 1); + storage.applyMutation(mutationResult.value); } catch (error) { - if (index !== physicalLines.length - 1 || !(error instanceof SessionError) || error.cause === undefined) - throw error; - const validPrefix = `${physicalLines.slice(0, index).join("\n")}\n`; - await publishFileAtomically(fs, path, async (tempPath) => { - fileResult(await fs.writeFile(tempPath, validPrefix), `Failed to stage torn-tail repair ${path}`); - }); - return storage; + if (error instanceof SessionError && error.code === "invalid_entry") { + throw invalidFile(path, index + 1, error); + } + throw error; } - storage.applyMutation(mutation, path, index + 1); } if (!content.endsWith("\n")) { fileResult(await fs.appendFile(path, "\n"), `Failed to repair unterminated session tail ${path}`); @@ -260,13 +271,7 @@ export class JsonlSessionStorage implements SessionStorage ); } - private applyMutation( - mutation: SessionMutation, - path = this.metadata.path, - line = this.state.nextSequence + 1, - ): void { - this.state.applyMutation(mutation, (message) => { - throw invalidFile(path, line, message); - }); + private applyMutation(mutation: SessionMutation): void { + this.state.applyMutation(mutation); } } diff --git a/packages/agent/test/harness/session/jsonl-codec.test.ts b/packages/agent/test/harness/session/jsonl-codec.test.ts index e06473cc6ef..4f7621c2134 100644 --- a/packages/agent/test/harness/session/jsonl-codec.test.ts +++ b/packages/agent/test/harness/session/jsonl-codec.test.ts @@ -6,19 +6,20 @@ import { parseHeader, parseMutation, } from "../../../src/harness/session/jsonl/codec.ts"; +import { JsonlDecodeError } from "../../../src/harness/session/jsonl/errors.ts"; import type { JsonlV4Header } from "../../../src/harness/session/jsonl/types.ts"; import type { SessionMutation } from "../../../src/harness/session/state.ts"; function expectHeaderRoundTrip(header: JsonlV4Header): void { const encoded = encodeHeader(header); expect(encoded.endsWith("\n")).toBe(true); - expect(parseHeader(encoded.trimEnd(), "/sessions/example.jsonl")).toEqual(header); + expect(parseHeader(encoded.trimEnd())).toEqual({ ok: true, value: header }); } function expectMutationRoundTrip(mutation: SessionMutation): void { const encoded = encodeMutation(mutation); expect(encoded.endsWith("\n")).toBe(true); - expect(parseMutation(encoded.trimEnd(), "/sessions/example.jsonl", 2)).toEqual(mutation); + expect(parseMutation(encoded.trimEnd())).toEqual({ ok: true, value: mutation }); } describe("JSONL v4 codec", () => { @@ -71,6 +72,19 @@ describe("JSONL v4 codec", () => { }); describe("mutation lines", () => { + it("returns syntax and schema errors", () => { + for (const [line, kind] of [ + ["{", "syntax"], + [JSON.stringify({ kind: "unknown", seq: 1 }), "schema"], + ] as const) { + const result = parseMutation(line); + expect(result.ok).toBe(false); + if (result.ok) throw new Error(`Expected ${kind} decode error`); + expect(result.error).toBeInstanceOf(JsonlDecodeError); + expect(result.error).toMatchObject({ kind }); + } + }); + it("round trips a lane-bound entry line", () => { expectMutationRoundTrip({ kind: "entry", @@ -161,7 +175,7 @@ describe("JSONL v4 codec", () => { }, }, ])("rejects $name", ({ mutation }) => { - expect(() => parseMutation(JSON.stringify(mutation), "/sessions/example.jsonl", 2)).toThrow(); + expect(parseMutation(JSON.stringify(mutation))).toMatchObject({ ok: false }); }); }); }); diff --git a/packages/agent/test/harness/session/jsonl.test.ts b/packages/agent/test/harness/session/jsonl.test.ts index 878cebcd6b1..6b3a7671a94 100644 --- a/packages/agent/test/harness/session/jsonl.test.ts +++ b/packages/agent/test/harness/session/jsonl.test.ts @@ -537,7 +537,7 @@ describe("JSONL v4 persistence", () => { const repository = createRepository(root); await expect(repository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry", - message: expect.stringContaining("references missing parent missing"), + message: `Invalid JSONL v4 session ${path}: line 2 Invalid session mutation: references missing parent missing`, }); }); From 1dd2354052f7dd9fcdcc3097b87cf4b377853a74 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 13:41:14 +0200 Subject: [PATCH 046/284] feat(agent): add buffered harness event watches This is incremental work towards I2 in the harness-v2.md plan. --- packages/agent/src/harness/events.ts | 66 +++++++++++++++++++++- packages/agent/test/harness/events.test.ts | 44 +++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/agent/test/harness/events.test.ts diff --git a/packages/agent/src/harness/events.ts b/packages/agent/src/harness/events.ts index 1c466ed478b..36777902bcc 100644 --- a/packages/agent/src/harness/events.ts +++ b/packages/agent/src/harness/events.ts @@ -1,7 +1,71 @@ +export interface RunStartEvent { + type: "run_start"; + lane: string; + runId: string; +} + +export interface RunEndEvent { + type: "run_end"; + lane: string; + runId: string; + outcome: "completed" | "aborted" | "failed"; + leafId: string; +} + +export type HarnessEvent = RunStartEvent | RunEndEvent; +export type HarnessEventType = HarnessEvent["type"]; +export type HarnessEventOfType = Extract; +export type HarnessEventListener = (event: TEvent) => void | Promise; + export interface Events { /** * Register a passive listener for future events and return its unsubscribe function. * Earlier events are not replayed and no current-state snapshot is provided; use a lane or session watch for both. */ - on(type: string, listener: (event: unknown) => void | Promise): () => void; + on( + type: TType, + listener: HarnessEventListener>, + ): () => void; +} + +export interface WatchHandle { + snapshot: TSnapshot; + start(listener: HarnessEventListener): void; + unsubscribe(): void; +} + +export class HarnessEventBus { + private readonly watchListeners = new Set<(event: HarnessEvent) => void>(); + + emit(event: HarnessEvent): void { + for (const listener of this.watchListeners) listener(event); + } + + watch(captureSnapshot: () => TSnapshot): WatchHandle { + let listener: HarnessEventListener | undefined; + let buffered: HarnessEvent[] = []; + const receive = (event: HarnessEvent): void => { + if (listener) void listener(event); + else buffered.push(event); + }; + this.watchListeners.add(receive); + const snapshot = captureSnapshot(); + + return { + snapshot, + start: (nextListener) => { + // Stay in buffering mode while flushing so reentrant emissions preserve order. + while (buffered.length > 0) { + const pending = buffered; + buffered = []; + for (const event of pending) void nextListener(event); + } + listener = nextListener; + }, + unsubscribe: () => { + this.watchListeners.delete(receive); + buffered = []; + }, + }; + } } diff --git a/packages/agent/test/harness/events.test.ts b/packages/agent/test/harness/events.test.ts new file mode 100644 index 00000000000..9f63f15b5c6 --- /dev/null +++ b/packages/agent/test/harness/events.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { type HarnessEvent, HarnessEventBus, type RunEndEvent, type RunStartEvent } from "../../src/harness/events.ts"; + +const runStartEvent: RunStartEvent = { + type: "run_start", + lane: "main", + runId: "run-1", +}; + +const runEndEvent: RunEndEvent = { + type: "run_end", + lane: "main", + runId: "run-1", + outcome: "completed", + leafId: "entry-1", +}; + +describe("HarnessEventBus", () => { + it("captures a snapshot without an event gap, then flushes and delivers live events", () => { + const events = new HarnessEventBus(); + const expectedSnapshot = { leafId: null }; + const watch = events.watch(() => { + const snapshot = expectedSnapshot; + events.emit(runStartEvent); + return snapshot; + }); + const received: HarnessEvent[] = []; + + expect(watch.snapshot).toBe(expectedSnapshot); + expect(received).toEqual([]); + + watch.start((event) => { + received.push(event); + }); + expect(received).toEqual([runStartEvent]); + + events.emit(runEndEvent); + expect(received).toEqual([runStartEvent, runEndEvent]); + + watch.unsubscribe(); + events.emit(runStartEvent); + expect(received).toEqual([runStartEvent, runEndEvent]); + }); +}); From d1a305613ea6f9e5d5ddc9e08f27393f9b73b33c Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 7 Aug 2026 14:14:22 +0200 Subject: [PATCH 047/284] feat(agent): add direct harness event listeners Part of I2, "passive events and watch buffering," in packages/agent/docs/harness-v2.md. --- packages/agent/src/harness/events.ts | 33 +++++++++++++++++++++- packages/agent/test/harness/events.test.ts | 21 ++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/harness/events.ts b/packages/agent/src/harness/events.ts index 36777902bcc..a457b2b014c 100644 --- a/packages/agent/src/harness/events.ts +++ b/packages/agent/src/harness/events.ts @@ -34,10 +34,41 @@ export interface WatchHandle { unsubscribe(): void; } -export class HarnessEventBus { +export class HarnessEventBus implements Events { + private readonly listeners = new Map>(); private readonly watchListeners = new Set<(event: HarnessEvent) => void>(); + /** + * Register a listener for future events of one type and return its unsubscribe function. + * Earlier events are not replayed, and no snapshot or event buffer is provided. + */ + on( + type: TType, + listener: HarnessEventListener>, + ): () => void { + // Reuse this event type's listener set, or create its first set. + const listeners = this.listeners.get(type) ?? new Set(); + this.listeners.set(type, listeners); + + // Wrap this event-specific callback so it can be stored as a general HarnessEvent listener. + // Keep the wrapper reference so unsubscribe can remove that exact function from the set. + const receive: HarnessEventListener = (event) => { + if (event.type === type) return listener(event as HarnessEventOfType); + }; + listeners.add(receive); + return () => { + listeners.delete(receive); + if (listeners.size === 0) this.listeners.delete(type); + }; + } + + /** Publish an event to current event subscriptions and watch subscriptions. */ emit(event: HarnessEvent): void { + // Deliver only to direct listeners registered for this event type. + // Async results are not awaited because emit() is synchronous. + for (const listener of this.listeners.get(event.type) ?? []) void listener(event); + + // Deliver every event to each watcher; watch() handles buffering until start(). for (const listener of this.watchListeners) listener(event); } diff --git a/packages/agent/test/harness/events.test.ts b/packages/agent/test/harness/events.test.ts index 9f63f15b5c6..84cb5e52e64 100644 --- a/packages/agent/test/harness/events.test.ts +++ b/packages/agent/test/harness/events.test.ts @@ -16,6 +16,27 @@ const runEndEvent: RunEndEvent = { }; describe("HarnessEventBus", () => { + it("delivers matching events to direct listeners and watchers", () => { + const events = new HarnessEventBus(); + const direct: RunStartEvent[] = []; + const watchEvents: HarnessEvent[] = []; + const off = events.on("run_start", (event) => { + direct.push(event); + }); + const watch = events.watch(() => null); + watch.start((event) => { + watchEvents.push(event); + }); + + events.emit(runStartEvent); + events.emit(runEndEvent); + off(); + events.emit(runStartEvent); + + expect(direct).toEqual([runStartEvent]); + expect(watchEvents).toEqual([runStartEvent, runEndEvent, runStartEvent]); + }); + it("captures a snapshot without an event gap, then flushes and delivers live events", () => { const events = new HarnessEventBus(); const expectedSnapshot = { leafId: null }; From e7fb8eb2aca8a1126cc6f91b73ee38fd49ab7c59 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:13:16 +0200 Subject: [PATCH 048/284] fix: no ctes in sqlite, delete indexes --- packages/agent/docs/harness-v2.md | 7 ++ .../src/sqlite/migrations/001_initial.sql | 6 +- .../sqlite-node/src/sqlite/repo.ts | 9 ++- .../src/sqlite/storage/branch-entries.ts | 74 +++++++++++++------ .../sqlite-node/src/sqlite/storage/entries.ts | 20 ++++- .../sqlite-node/src/sqlite/storage/facts.ts | 24 +++--- .../sqlite-node/test/migrations.test.ts | 6 ++ 7 files changed, 103 insertions(+), 43 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index b88e5678b63..b27a5a83a81 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -1764,6 +1764,13 @@ Stale branches (no lane resolves through them) are kept. Every restore query is an index seek plus a bounded scan: a lane's open operation via `(lane, type, seq)`, its last run-kind start via `(lane, type, op_kind, seq)`, its records above the operation via the same index, its own entries via the read plan from its leaf. No query touches another lane's traffic. +SQLite implementation follow-ups: + +- Finish search backend work now in progress. +- Add limit and cursor support to search results. +- Route `findEntries` through indexed/search-backed query paths where possible instead of decoding and filtering all session entries. +- Re-audit SQLite query plans after search and `findEntries` changes to see whether further index or query-shape improvements are warranted. + ## 14. Agent-loop building blocks `agent-loop.ts` exposes building blocks that own no durable state and know nothing about sessions, records, or lanes. The harness composes them and inserts durability writes between their phases. diff --git a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql index 74cbf349065..706b0b04df9 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql +++ b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql @@ -8,7 +8,6 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_cwd_created_at ON sessions(cwd, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id); CREATE TABLE IF NOT EXISTS entries ( session_id TEXT NOT NULL, @@ -22,7 +21,6 @@ CREATE TABLE IF NOT EXISTS entries ( UNIQUE (session_id, seq) ); -CREATE INDEX IF NOT EXISTS idx_entries_session_seq ON entries(session_id, seq); CREATE INDEX IF NOT EXISTS idx_entries_session_parent ON entries(session_id, parent_id); CREATE INDEX IF NOT EXISTS idx_entries_session_type_seq ON entries(session_id, type, seq); @@ -79,7 +77,6 @@ CREATE TABLE IF NOT EXISTS records ( UNIQUE (session_id, seq) ) WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS idx_records_session_seq ON records(session_id, seq); CREATE INDEX IF NOT EXISTS idx_records_session_lane_seq ON records(session_id, lane, seq); CREATE INDEX IF NOT EXISTS idx_records_session_type_seq ON records(session_id, type, seq); CREATE INDEX IF NOT EXISTS idx_records_session_type_op_kind_seq ON records(session_id, type, op_kind, seq); @@ -95,7 +92,6 @@ CREATE TABLE IF NOT EXISTS lane_moves ( PRIMARY KEY (session_id, seq) ) WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS idx_lane_moves_session_lane_seq ON lane_moves(session_id, lane, seq); CREATE TABLE IF NOT EXISTS facts ( session_id TEXT NOT NULL, @@ -110,8 +106,8 @@ CREATE INDEX IF NOT EXISTS idx_facts_session_kind_key_seq ON facts(session_id, k CREATE TABLE IF NOT EXISTS branch_tips ( session_id TEXT NOT NULL, - tip_id TEXT NOT NULL, branch_id TEXT NOT NULL, + tip_id TEXT NOT NULL, PRIMARY KEY (session_id, tip_id), UNIQUE (session_id, branch_id) ) WITHOUT ROWID; diff --git a/packages/session-backends/sqlite-node/src/sqlite/repo.ts b/packages/session-backends/sqlite-node/src/sqlite/repo.ts index 5bd2f259119..da4b13c0125 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/repo.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/repo.ts @@ -532,7 +532,14 @@ class SqliteSessionStorage implements SessionStorage { } async findEntries(query: EntryQuery = {}): Promise { - const rows = readEntryRows(this.db, this.metadata.id, { order: query.order }); + const sqlType = query.type ?? (query.customType === undefined ? undefined : "custom"); + const sqlLimit = query.customType === undefined ? query.limit : undefined; + const rows = readEntryRows(this.db, this.metadata.id, { + cursor: query.cursor, + limit: sqlLimit, + order: query.order, + type: sqlType, + }); const entries = rows.map(decodeEntry).filter((entry) => matchesEntryQuery(entry, query)); return query.limit === undefined ? entries : entries.slice(0, query.limit); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts index 58807897d61..76016a71cdc 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts @@ -1,4 +1,4 @@ -import type { Entry } from "@earendil-works/pi-agent-core"; +import { type Entry, SessionError } from "@earendil-works/pi-agent-core"; import { joinSqlFragments, sql } from "../sql.ts"; import type { SqliteDatabase } from "../types.ts"; @@ -28,6 +28,14 @@ export interface CachedBranchQuery { limit?: number; } +interface BranchPathEntryRow { + id: string; + seq: number; + parent_id: string | null; + type: Entry["type"]; + payload: string; +} + export function readCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { const membership = sql`SELECT branch_id, entry_seq FROM branch_entries @@ -56,14 +64,12 @@ export function queryCachedBranchRows( const boundary = stopPredicates.length === 0 ? sql`` - : sql`WITH boundary AS ( - SELECT ${aggregate}(stop.entry_seq) AS entry_seq + : sql`SELECT ${aggregate}(stop.entry_seq) FROM branch_entries AS stop WHERE stop.session_id = ${sessionId} AND stop.branch_id = ${branch.branchId} AND stop.entry_seq <= ${branch.leafSeq} - AND (${joinSqlFragments(stopPredicates, " OR ")}) - )`; + AND (${joinSqlFragments(stopPredicates, " OR ")})`; const predicates = [ sql`b.session_id = ${sessionId}`, @@ -71,17 +77,16 @@ export function queryCachedBranchRows( sql`b.entry_seq <= ${branch.leafSeq}`, ]; if (stopPredicates.length > 0) { - predicates.push(sql`b.entry_seq ${boundaryComparison} COALESCE( - (SELECT entry_seq FROM boundary), ${oldestFirst ? branch.leafSeq : 0} - )`); + predicates.push( + sql`b.entry_seq ${boundaryComparison} COALESCE((${boundary}), ${oldestFirst ? branch.leafSeq : 0})`, + ); } if (query.cursor !== undefined) predicates.push(sql`b.entry_seq ${cursorComparison} ${query.cursor.afterSeq}`); if (query.type !== undefined) predicates.push(sql`b.entry_type = ${query.type}`); if (query.customType !== undefined) predicates.push(sql`b.custom_type = ${query.customType}`); const limit = query.limit === undefined ? sql`` : sql` LIMIT ${query.limit}`; - return sql`${boundary} - SELECT e.session_id, e.id, e.seq AS entry_seq, e.parent_id, e.type, e.timestamp, e.payload + return sql`SELECT e.session_id, e.id, e.seq AS entry_seq, e.parent_id, e.type, e.timestamp, e.payload FROM branch_entries AS b JOIN entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id WHERE ${joinSqlFragments(predicates, " AND ")} @@ -106,21 +111,44 @@ export function insertBranchEntry( VALUES (${sessionId}, ${branchId}, ${entryId}, ${entrySeq}, ${entryType}, ${customType})`.run(db); } +function customTypeFromPayload(row: BranchPathEntryRow): string | null { + if (row.type !== "custom") return null; + try { + const payload = JSON.parse(row.payload) as unknown; + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throw new Error("Payload is not an object"); + } + const customType = (payload as { customType?: unknown }).customType; + if (typeof customType !== "string") throw new Error("Invalid custom payload"); + return customType; + } catch (error) { + throw new SessionError( + "invalid_entry", + `Invalid SQLite session entry ${row.id}: failed to decode entry ${row.id}`, + error instanceof Error ? error : undefined, + ); + } +} + export function insertBranchEntriesForPath(db: SqliteDatabase, sessionId: string, branchId: string, leafId: string) { - sql`WITH RECURSIVE path(id, entry_seq, parent_id, type, custom_type) AS ( - SELECT id, seq, parent_id, type, - CASE WHEN type = 'custom' THEN json_extract(payload, '$.customType') ELSE NULL END + const path: BranchPathEntryRow[] = []; + const seen = new Set(); + let entryId: string | null = leafId; + + while (entryId !== null) { + if (seen.has(entryId)) throw new SessionError("invalid_entry", `Entry parent cycle at ${entryId}`); + seen.add(entryId); + const row: BranchPathEntryRow | undefined = sql`SELECT id, seq, parent_id, type, payload FROM entries - WHERE session_id = ${sessionId} AND id = ${leafId} - UNION ALL - SELECT parent.id, parent.seq, parent.parent_id, parent.type, - CASE WHEN parent.type = 'custom' THEN json_extract(parent.payload, '$.customType') ELSE NULL END - FROM entries AS parent - JOIN path AS child ON child.parent_id = parent.id - WHERE parent.session_id = ${sessionId} - ) - INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - SELECT ${sessionId}, ${branchId}, id, entry_seq, type, custom_type FROM path`.run(db); + WHERE session_id = ${sessionId} AND id = ${entryId}`.get(db); + if (!row) throw new SessionError("invalid_entry", `Entry ${entryId} not found`); + path.push(row); + entryId = row.parent_id; + } + + for (const row of path.reverse()) { + insertBranchEntry(db, sessionId, branchId, row.id, row.seq, row.type, customTypeFromPayload(row)); + } } export function readBranchContainingEntry(db: SqliteDatabase, sessionId: string, entryId: string) { diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts index c2a8b625e0b..e76ef3cf6bb 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts @@ -42,14 +42,28 @@ export function readEntryRow(db: SqliteDatabase, sessionId: string, entryId: str export function readEntryRows( db: SqliteDatabase, sessionId: string, - options: { afterSeq?: number; order?: EntryOrder; limit?: number } = {}, + options: { + afterSeq?: number; + cursor?: { afterSeq: number }; + type?: Entry["type"]; + order?: EntryOrder; + limit?: number; + } = {}, ) { + const oldestFirst = options.order === "oldestFirst"; const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; - const direction = options.order === "oldestFirst" ? sql`ASC` : sql`DESC`; + const cursor = + options.cursor === undefined + ? sql`` + : oldestFirst + ? sql` AND seq > ${options.cursor.afterSeq}` + : sql` AND seq < ${options.cursor.afterSeq}`; + const type = options.type === undefined ? sql`` : sql` AND type = ${options.type}`; + const direction = oldestFirst ? sql`ASC` : sql`DESC`; const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; return sql`SELECT session_id, seq, id, parent_id, type, timestamp, payload FROM entries - WHERE session_id = ${sessionId}${after} + WHERE session_id = ${sessionId}${after}${cursor}${type} ORDER BY seq ${direction}${limit}`.all(db); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts index a9654aededb..19fa76e751b 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts @@ -31,17 +31,19 @@ export function readLatestFact(db: SqliteDatabase, sessionId: string, kind: stri } export function readLatestLabelFacts(db: SqliteDatabase, sessionId: string) { - return sql`WITH latest AS ( - SELECT key, MAX(seq) AS seq - FROM facts INDEXED BY idx_facts_session_kind_key_seq - WHERE session_id = ${sessionId} AND kind = 'label' - GROUP BY key - ) - SELECT latest.key, f.value - FROM latest - JOIN facts AS f ON f.session_id = ${sessionId} AND f.seq = latest.seq - WHERE f.value IS NOT NULL - ORDER BY latest.key`.all<{ key: string; value: string }>(db); + return sql`SELECT f.key, f.value + FROM facts AS f INDEXED BY idx_facts_session_kind_key_seq + WHERE f.session_id = ${sessionId} + AND f.kind = 'label' + AND f.value IS NOT NULL + AND f.seq = ( + SELECT MAX(candidate.seq) + FROM facts AS candidate INDEXED BY idx_facts_session_kind_key_seq + WHERE candidate.session_id = f.session_id + AND candidate.kind = f.kind + AND candidate.key IS f.key + ) + ORDER BY f.key`.all<{ key: string; value: string }>(db); } export function readFactRows( diff --git a/packages/session-backends/sqlite-node/test/migrations.test.ts b/packages/session-backends/sqlite-node/test/migrations.test.ts index ae8f4f35282..fb16311009f 100644 --- a/packages/session-backends/sqlite-node/test/migrations.test.ts +++ b/packages/session-backends/sqlite-node/test/migrations.test.ts @@ -36,8 +36,11 @@ describe("SQLite migrations", () => { expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id"); const sessionIndexes = db.prepare("PRAGMA index_list(sessions)").all<{ name: string }>(); expect(sessionIndexes.map((index) => index.name)).toContain("idx_sessions_cwd_created_at"); + expect(sessionIndexes.map((index) => index.name)).not.toContain("idx_sessions_parent"); const laneColumns = db.prepare("PRAGMA table_info(lanes)").all<{ name: string }>(); expect(laneColumns.map((column) => column.name)).toContain("open_operation_id"); + const entryIndexes = db.prepare("PRAGMA index_list(entries)").all<{ name: string }>(); + expect(entryIndexes.map((index) => index.name)).not.toContain("idx_entries_session_seq"); const branchEntryIndexes = db.prepare("PRAGMA index_list(branch_entries)").all<{ name: string }>(); expect(branchEntryIndexes.map((index) => index.name)).toContain("idx_branch_entries_session_entry"); const recordIndexes = db.prepare("PRAGMA index_list(records)").all<{ name: string }>(); @@ -48,6 +51,9 @@ describe("SQLite migrations", () => { "idx_records_session_type_op_kind_seq", ]), ); + expect(recordIndexes.map((index) => index.name)).not.toContain("idx_records_session_seq"); + const laneMoveIndexes = db.prepare("PRAGMA index_list(lane_moves)").all<{ name: string }>(); + expect(laneMoveIndexes.map((index) => index.name)).not.toContain("idx_lane_moves_session_lane_seq"); } finally { db.close(); } From 541ed488d89dbe11395e4c108f448e1e253ae4c1 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 17:07:01 +0200 Subject: [PATCH 049/284] docs(agent): remove legacy leaf entry requirements --- packages/agent/docs/harness-v2.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index b27a5a83a81..cd40e577a56 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -688,7 +688,7 @@ The same rules run live: during normal execution the harness updates this state Recovery appends are ordinary appends with one extra rule: skip any provisioned id that already exists. A crash during recovery therefore leaves less to recover; re-running recovery is always safe. Recovery repeats an unknown effect only when its policy permits it: a retryable step starts a new durable attempt, and a tool replays only when both replay declarations say `safe`. Interrupted hook handlers follow the section 11 replay table. -Old v3 sessions contain no records. Every lane question answers "idle"; section 12 normalization restores `main` at the final retained logical entry (v3 `leaf` entries and discarded fact-like entries resolve through their nearest retained ancestor). +Old v3 sessions contain no records. Every lane question answers "idle"; section 12 normalization restores `main` at the final retained logical entry after discarded fact-like entries resolve through their nearest retained ancestor. # Part III — API and implementation @@ -1441,12 +1441,12 @@ A harness-written assistant `MessageEntry` always contains a `SettledAssistantMe Every v4 compaction — generated or hook-supplied — stores the complete `retainedTail`; an empty tail is `[]`, never omission. The compaction entry is a self-contained checkpoint: context builds never read past it. Entry `usage` fields — on assistant messages, tool results, compactions, and branch summaries — are immutable display snapshots of the response(s) that produced that entry: a message entry matches its one producing record; a compaction or branch-summary entry carries its successful attempt's request(s), never failed attempts. The durable ledger is the `usage` records; effective cost including later adjustments is a read-time ledger query by `entryId` (sections 5, 13). -v3 files additionally contain `custom_message`, `label`, `session_info`, and `leaf` entries, plus old compaction entries that use `firstKeptEntryId`. Load normalizes them before exposing the v4 tree: +v3 files additionally contain `custom_message`, `label`, and `session_info` entries, plus old compaction entries that use `firstKeptEntryId`. Load normalizes them before exposing the v4 tree: - `custom_message` becomes a custom agent message. - `label` and `session_info` become global facts (latest by file position wins) and disappear from the logical tree. A label targets its nearest retained parent. -- `leaf` entries disappear; `main`'s leaf resolves through the last `leaf` entry, then to the nearest retained ancestor if that target was discarded. - Each retained child of a discarded entry is reparented to the discarded entry's nearest retained ancestor. +- `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. - An old compaction resolves `firstKeptEntryId` against its own branch and materializes that range as `retainedTail`. V4 never exposes or persists `firstKeptEntryId`. - v3 entry timestamps are ISO strings and convert to Unix milliseconds. @@ -1679,7 +1679,7 @@ One file per session: a header line, then one JSON object per line, in `seq` ord - The optional `lane` on an entry line is envelope metadata and dies at decode. When present, the line atomically appends the entry and advances that lane; replay requires `parentId` to equal its current leaf. When absent, the line imports a fork entry without moving a lane. Entries expose `seq` but no lane. - Torn tail: a malformed final line is the append that died mid-write. Open truncates it; the write was never acknowledged, nothing is lost. A malformed line anywhere else is corruption; open rejects. - Durability is process-crash level: a resolved append call. No fsync promise; if power-loss durability is ever needed, it becomes an explicit capability. -- v3 files: entries only, no `kind` tags. Open builds the normalized logical tree from section 12; every entry belongs to `main`, and `main`'s leaf resolves through the last `leaf` entry to its nearest retained ancestor. Before the first v4 append, the file is rewritten once with a v4 header (write temp, rename). This is the single conversion the compatibility policy allows. Read-only opens never rewrite. +- v3 files: entries only, no `kind` tags. Open builds the normalized logical tree from section 12; every entry belongs to `main`, and `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. Before the first v4 append, the file is rewritten once with a v4 header (write temp, rename). This is the single conversion the compatibility policy allows. Read-only opens never rewrite. ### SQLite @@ -3162,7 +3162,7 @@ Gate invariants, asserted across Tier C: - Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`. - Ledger completeness and the match invariant: every provider request leaves exactly one `usage` record per physical request (split-turn: two per attempt; a pending deferred fetch that reports no usage writes none); failed compaction series and discarded overflow responses lose no recorded cost; each usage-bearing entry's snapshot equals the newest non-adjustment record(s) bound to its id; a replayed tool records both executions; adjustments never alter entries and sum into read-time effective cost; `getStats()` token and cost fields equal the ledger sum and the `usage` event's totals after every commit; fork token and cost fields start at zero while `messageCount` includes all copied message entries; v3 conversion preserves totals through the aggregate import adjustment. - Overflow classification against the reported provider shapes: prompt 268,009 of a 272,000 window and 81,217 of 84,500 (recoverable), non-zero reasoning-only output, cache-write-heavy usage, a Codex-style provider that rejects `max_output_tokens`, a genuine 1,024-token cap fully used (not recoverable), and `length → length` stopping after exactly one recovery per conversational input. -- v3 fixtures: labels, session info, and `leaf` entries mid-chain and at end of file, old `firstKeptEntryId` compactions — all open as one normalized idle `main` lane. +- v3 fixtures: labels and session info mid-chain and at end of file, plus old `firstKeptEntryId` compactions — all open as one normalized idle `main` lane. ## 20. Implementation status and work packages @@ -3276,7 +3276,7 @@ These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete ` - Add torn-tail truncation, malformed-interior rejection, missing-reference rejection, and lifecycle/concurrency edge cases. - Acceptance: acknowledged writes survive reopen and malformed non-tail data is never silently repaired. - [ ] **J4 — read-only v3 normalization.** Dependencies: J3. - - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, leaf resolution, discarded-entry reparenting, old compactions, timestamps, parent mapping, and idle `main`. + - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, discarded-entry reparenting, old compactions, timestamps, parent mapping, and idle `main` at the final retained logical entry. - A read-only open must not modify the physical file. No coding-agent source or test is changed. - Acceptance: fixture tests cover every normalization rule in section 12 and malformed v3 input. - [ ] **J5 — first-write v3 conversion.** Dependencies: J4. From c3e7bc60a13b0a6fb80f7e8a867112ee4d10c92a Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 18:03:12 +0200 Subject: [PATCH 050/284] feat(ai): preserve Codex end_turn for debugging (#7766) --- packages/ai/src/api/openai-codex-responses.ts | 14 ++++++++++---- packages/ai/src/types.ts | 5 +++++ packages/ai/test/openai-codex-stream.test.ts | 10 ++++++++-- packages/server/src/protocol.ts | 1 + 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 52e1becfbde..e72ec06aaef 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -662,7 +662,7 @@ async function processStream( grammarToolInputProperties: ReadonlyMap, options?: OpenAICodexResponsesOptions, ): Promise { - await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, { + await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal), output), output, stream, model, { serviceTier: options?.serviceTier, grammarToolInputProperties, resolveServiceTier: resolveCodexServiceTier, @@ -719,7 +719,10 @@ function extractCodexEventError(event: Record): { code?: string }; } -async function* mapCodexEvents(events: AsyncIterable>): AsyncGenerator { +async function* mapCodexEvents( + events: AsyncIterable>, + output: AssistantMessage, +): AsyncGenerator { for await (const event of events) { const type = typeof event.type === "string" ? event.type : undefined; if (!type) continue; @@ -740,7 +743,10 @@ async function* mapCodexEvents(events: AsyncIterable>): } if (type === "response.done" || type === "response.completed" || type === "response.incomplete") { - const response = (event as { response?: { status?: unknown } }).response; + const response = (event as { response?: { status?: unknown; end_turn?: unknown } }).response; + if (typeof response?.end_turn === "boolean") { + output.endTurn = response.end_turn; + } const normalizedResponse = response ? { ...response, status: normalizeCodexStatus(response.status) } : response; @@ -1504,7 +1510,7 @@ async function processWebSocketStream( socket.send(JSON.stringify({ type: "response.create", ...requestBody })); await processResponsesStream( startWebSocketOutputOnFirstEvent( - mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs)), + mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs), output), onStart, ), output, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 75e1d6f3cea..5bf6c9fa6ea 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -424,6 +424,11 @@ export interface AssistantMessage { deferred?: DeferredHandle; errorMessage?: string; rawStopReason?: string; + /** + * Provider indication of whether the model explicitly ended its turn. + * Preserved for debugging and does not currently affect agent control flow. + */ + endTurn?: boolean; timestamp: number; // Unix timestamp in milliseconds } diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index a8423c887d9..ccd7f51c62c 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -49,9 +49,11 @@ function decodeCodexRequestBody(body: RequestInit["body"] | undefined): Record { process.env.PI_CODING_AGENT_DIR = tempDir; const token = mockToken(); const encoder = new TextEncoder(); - const sse = buildSSEPayload({ status: "completed", includeDone: true }); + const sse = buildSSEPayload({ status: "completed", includeDone: true, endTurn: false }); const stream = new ReadableStream({ start(controller) { @@ -263,6 +266,7 @@ describe("openai-codex streaming", () => { expect(result.content.find((c) => c.type === "text")?.text).toBe("Hello"); expect(result.stopReason).toBe("stop"); + expect(result.endTurn).toBe(false); }); it("maps response.incomplete to stopReason length even when the SSE body stays open", async () => { @@ -1273,6 +1277,7 @@ describe("openai-codex streaming", () => { type: "response.completed", response: { status: "completed", + end_turn: false, usage: { input_tokens: 5, output_tokens: 3, @@ -1317,12 +1322,13 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: 1 }], }; - await streamSimpleOpenAICodexResponses(model, context, { + const result = await streamSimpleOpenAICodexResponses(model, context, { apiKey: token, sessionId: "session-auto", transport: "auto", }).result(); + expect(result.endTurn).toBe(false); expect(sentBodies).toHaveLength(1); expect(capturedWebSocketHeaders?.["session-id"]).toBe("session-auto"); expect(capturedWebSocketHeaders?.session_id).toBeUndefined(); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 99b5a948e8a..6180cf407e1 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -94,6 +94,7 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "deferred" | "errorMessage" | "rawStopReason" + | "endTurn" | "timestamp" > >; From ac4ac9eaf69f2b01ca3af984a5c48f3b99b84278 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 18:29:56 +0200 Subject: [PATCH 051/284] feat(coding-agent): configure fullscreen exit output --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/docs/settings.md | 1 + packages/coding-agent/docs/usage.md | 2 +- .../coding-agent/src/core/settings-manager.ts | 12 ++++++ packages/coding-agent/src/index.ts | 1 + .../components/settings-selector.ts | 13 ++++++ .../src/modes/interactive/interactive-mode.ts | 18 +++++---- .../coding-agent/test/interactive-tui.test.ts | 12 +++--- .../test/settings-manager.test.ts | 19 ++++++--- .../test/settings-selector.test.ts | 40 +++++++++++-------- 10 files changed, 86 insertions(+), 36 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 18a37327887..1bf3b77b055 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. + ## [0.84.1] - 2026-08-07 ### New Features diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 761a1941e24..ec8aa26cc35 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -66,6 +66,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | | `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | | `tuiMode` | string | `"regular"` | Interactive TUI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--tui-mode` overrides this setting at startup | +| `fullscreenExitOutput` | string | `"transcript"` | Fullscreen exit output: `"transcript"` prints the final transcript and resume hint, while `"resume-hint"` restores the previous screen and prints only the resume hint. Has no effect in regular TUI mode | | `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling, `"always"` reserves the rightmost column and keeps it visible, and `"hidden"` hides it. Has no effect in regular TUI mode | For VS Code, include `--wait` so pi resumes after the editor exits: diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index d09e37f8330..5104b879044 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -250,7 +250,7 @@ pi --no-extensions -e ./my-extension.ts In `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally. -Set **TUI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. +Set **TUI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. **Fullscreen exit output** controls whether exiting fullscreen prints the final transcript or restores the previous screen and prints only the session resume hint. ### File Arguments diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4a8c8bc4782..d18916a5c6d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -34,6 +34,7 @@ export interface RetrySettings { } export type TuiMode = RendererTuiMode; +export type FullscreenExitOutput = "transcript" | "resume-hint"; export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) @@ -133,6 +134,7 @@ export interface Settings { httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it tuiMode?: TuiMode; // default: "regular" + fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode } @@ -1135,6 +1137,16 @@ export class SettingsManager { this.save(); } + getFullscreenExitOutput(): FullscreenExitOutput { + return this.settings.fullscreenExitOutput === "resume-hint" ? "resume-hint" : "transcript"; + } + + setFullscreenExitOutput(output: FullscreenExitOutput): void { + this.globalSettings.fullscreenExitOutput = output; + this.markModified("fullscreenExitOutput"); + this.save(); + } + getFullscreenScrollbar(): ScrollViewScrollbar { const mode = this.settings.fullscreenScrollbar; return mode === "always" || mode === "hidden" ? mode : "auto"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3d05c2af462..6d64d70c87c 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -252,6 +252,7 @@ export { export { type CompactionSettings, type DefaultProjectTrust, + type FullscreenExitOutput, type ImageSettings, type PackageSource, type RetrySettings, diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index c0c49f544f5..20c6b7586c7 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -16,6 +16,7 @@ import { import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; import type { DefaultProjectTrust, + FullscreenExitOutput, MermaidRenderingMode, TuiMode, WarningSettings, @@ -87,6 +88,7 @@ export interface SettingsConfig { clearOnShrink: boolean; showTerminalProgress: boolean; tuiMode: TuiMode; + fullscreenExitOutput: FullscreenExitOutput; fullscreenScrollbar: ScrollViewScrollbar; warnings: WarningSettings; } @@ -121,6 +123,7 @@ export interface SettingsCallbacks { onClearOnShrinkChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void; onTuiModeChange: (mode: TuiMode) => void; + onFullscreenExitOutputChange: (output: FullscreenExitOutput) => void; onFullscreenScrollbarChange: (mode: ScrollViewScrollbar) => void; onWarningsChange: (warnings: WarningSettings) => void; onCancel: () => void; @@ -636,6 +639,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.tuiMode, values: ["regular", "fullscreen"], }, + { + id: "fullscreen-exit-output", + label: "Fullscreen exit output", + description: "Print the transcript or only a session resume hint when exiting fullscreen mode", + currentValue: config.fullscreenExitOutput, + values: ["transcript", "resume-hint"], + }, { id: "fullscreen-scrollbar", label: "Fullscreen scrollbar", @@ -858,6 +868,9 @@ export class SettingsSelectorComponent extends Container { case "tui-mode": callbacks.onTuiModeChange(newValue as TuiMode); break; + case "fullscreen-exit-output": + callbacks.onFullscreenExitOutputChange(newValue as FullscreenExitOutput); + break; case "fullscreen-scrollbar": callbacks.onFullscreenScrollbarChange(newValue as ScrollViewScrollbar); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 720f9b0f9f7..943b9edbdab 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -92,7 +92,7 @@ import { DefaultPackageManager } from "../../core/package-manager.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts"; -import type { TuiMode } from "../../core/settings-manager.ts"; +import type { FullscreenExitOutput, TuiMode } from "../../core/settings-manager.ts"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; @@ -776,13 +776,13 @@ export class InteractiveMode { } } - private stopInteractiveTui(): void { - if (this.renderer.mode === "fullscreen") { + private stopInteractiveTui(fullscreenExitOutput: FullscreenExitOutput): void { + if (this.renderer.mode === "fullscreen" && fullscreenExitOutput === "transcript") { while (this.renderer.hasOverlayEntries) this.renderer.hideOverlay(); this.switchTuiMode("regular", false, false); this.renderer.renderNow(); } - this.ui.stop(); + this.ui.stop({ preserveScreen: this.renderer.mode === "fullscreen" }); } private switchTuiMode(mode: TuiMode, restoreProgress = true, startRenderer = true): boolean { @@ -1950,7 +1950,7 @@ export class InteractiveMode { const message = error instanceof Error ? error.message : String(error); this.showError(`${prefix}: ${message}`); stopThemeWatcher(); - this.stop(); + this.stop("transcript"); process.exit(1); } @@ -4409,6 +4409,7 @@ export class InteractiveMode { clearOnShrink: this.settingsManager.getClearOnShrink(), showTerminalProgress: this.settingsManager.getShowTerminalProgress(), tuiMode: this.ui.mode, + fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(), fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), warnings: this.settingsManager.getWarnings(), }, @@ -4565,6 +4566,9 @@ export class InteractiveMode { if (!this.activeStatusIndicator) this.statusContainer.clear(); this.showStatus(`TUI mode: ${mode}`); }, + onFullscreenExitOutputChange: (output) => { + this.settingsManager.setFullscreenExitOutput(output); + }, onFullscreenScrollbarChange: (mode) => { this.settingsManager.setFullscreenScrollbar(mode); this.applyFullscreenScrollbarSetting(); @@ -6373,7 +6377,7 @@ export class InteractiveMode { } } - stop(): void { + stop(fullscreenExitOutput = this.settingsManager.getFullscreenExitOutput()): void { this.disposeActiveSelector(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); @@ -6387,7 +6391,7 @@ export class InteractiveMode { this.unsubscribe(); } if (this.isInitialized) { - this.stopInteractiveTui(); + this.stopInteractiveTui(fullscreenExitOutput); this.isInitialized = false; } this.unregisterSignalHandlers(); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index 82a29edc664..a4a0408d8c8 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -2,7 +2,7 @@ import type { Component, Terminal, TUI } from "@earendil-works/pi-tui"; import { Container, isViewportTUI, Text } from "@earendil-works/pi-tui"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; -import type { TuiMode } from "../src/core/settings-manager.ts"; +import type { FullscreenExitOutput, TuiMode } from "../src/core/settings-manager.ts"; import { createInteractiveTui, createInteractiveTuiReference, @@ -68,7 +68,7 @@ describe("createInteractiveTui", () => { altTui.stop(); }); - it("replaces the renderer while preserving components and focus", async () => { + it("replaces the renderer and restores the previous screen for resume-hint exits", async () => { const terminal = new RecordingTerminal(40, 8); const renderer = createInteractiveTui({ tuiMode: "regular", @@ -105,7 +105,7 @@ describe("createInteractiveTui", () => { stableUi = createInteractiveTuiReference(() => context.renderer); context.ui = stableUi; const { stopInteractiveTui, switchTuiMode } = InteractiveMode.prototype as unknown as { - stopInteractiveTui(this: SwitchContext): void; + stopInteractiveTui(this: SwitchContext, fullscreenExitOutput: FullscreenExitOutput): void; switchTuiMode(this: SwitchContext, mode: TuiMode, restoreProgress?: boolean): boolean; }; @@ -121,10 +121,10 @@ describe("createInteractiveTui", () => { expect(invalidatedModes).toEqual(["fullscreen"]); expect([terminal.startCount, terminal.stopCount]).toEqual([2, 1]); - stopInteractiveTui.call(context); + stopInteractiveTui.call(context, "resume-hint"); - expect(stableUi.mode).toBe("regular"); - expect([terminal.startCount, terminal.stopCount]).toEqual([2, 3]); + expect(stableUi.mode).toBe("fullscreen"); + expect([terminal.startCount, terminal.stopCount]).toEqual([2, 2]); }); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 799da20283b..6a223fd00dd 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -428,16 +428,25 @@ describe("SettingsManager", () => { }); }); - it("validates and persists the fullscreen scrollbar mode", async () => { + it("validates and persists fullscreen settings", async () => { const manager = SettingsManager.create(projectDir, agentDir); + expect(manager.getFullscreenExitOutput()).toBe("transcript"); expect(manager.getFullscreenScrollbar()).toBe("auto"); + manager.setFullscreenExitOutput("resume-hint"); manager.setFullscreenScrollbar("hidden"); await manager.flush(); - expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).fullscreenScrollbar).toBe("hidden"); - - writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ fullscreenScrollbar: "sometimes" })); - expect(SettingsManager.create(projectDir, agentDir).getFullscreenScrollbar()).toBe("auto"); + const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + expect(savedSettings.fullscreenExitOutput).toBe("resume-hint"); + expect(savedSettings.fullscreenScrollbar).toBe("hidden"); + + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ fullscreenExitOutput: "nothing", fullscreenScrollbar: "sometimes" }), + ); + const reloadedManager = SettingsManager.create(projectDir, agentDir); + expect(reloadedManager.getFullscreenExitOutput()).toBe("transcript"); + expect(reloadedManager.getFullscreenScrollbar()).toBe("auto"); }); describe("outputPad", () => { diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index d8f3c1e22c0..e658223b9a1 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -14,24 +14,30 @@ describe("SettingsSelectorComponent", () => { setKeybindings(new KeybindingsManager()); }); - it("cycles through fullscreen scrollbar modes", () => { - const onChange = vi.fn(); - const selector = new SettingsSelectorComponent( - { - fullscreenScrollbar: "auto", - warnings: {}, - availableThinkingLevels: [], - availableThemes: [], - } as unknown as SettingsConfig, - { onFullscreenScrollbarChange: onChange } as unknown as SettingsCallbacks, - ); - const settingsList = selector.getSettingsList(); + it("cycles through fullscreen settings", () => { + const onExitOutputChange = vi.fn(); + const onScrollbarChange = vi.fn(); + const config = { + fullscreenExitOutput: "transcript", + fullscreenScrollbar: "auto", + warnings: {}, + availableThinkingLevels: [], + availableThemes: [], + } as unknown as SettingsConfig; + const callbacks = { + onFullscreenExitOutputChange: onExitOutputChange, + onFullscreenScrollbarChange: onScrollbarChange, + } as unknown as SettingsCallbacks; - for (const character of "Fullscreen scrollbar") settingsList.handleInput(character); - settingsList.handleInput("\r"); - settingsList.handleInput("\r"); - settingsList.handleInput("\r"); + const cycle = (label: string, count: number) => { + const list = new SettingsSelectorComponent(config, callbacks).getSettingsList(); + for (const character of label) list.handleInput(character); + for (let i = 0; i < count; i++) list.handleInput("\r"); + }; - expect(onChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]); + cycle("Fullscreen exit output", 2); + expect(onExitOutputChange.mock.calls.flat()).toEqual(["resume-hint", "transcript"]); + cycle("Fullscreen scrollbar", 3); + expect(onScrollbarChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]); }); }); From 5cfa30cc2c9efe107e322302f612c7cae56eaec2 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 7 Aug 2026 21:58:51 +0200 Subject: [PATCH 052/284] docs(agent): add required fromHook to v4 summary entries --- packages/agent/docs/harness-v2.md | 35 +++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index cd40e577a56..7c20fb70485 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -1382,11 +1382,13 @@ after_tool: { // — records cannot distinguish them, and neither needs the hook again). before_compaction: { event: { reason: "manual" | "threshold" | "overflow"; preparation: CompactionPreparation; customInstructions? }; + /** A supplied compaction persists as a CompactionEntry with fromHook: true. */ result: { decline?: boolean; compaction?: CompactResult } | undefined; } before_navigation: { event: { targetId; preparation: NavigationPreparation }; + /** A supplied summary persists as a BranchSummaryEntry with fromHook: true. */ result: { decline?: boolean; summary?: { summary: string; details?; usage? } } | undefined; } ``` @@ -1428,9 +1430,9 @@ interface ThinkingLevelEntry extends EntryBase { type: "thinking_level_chang interface ActiveToolsEntry extends EntryBase { type: "active_tools_change"; activeToolNames: string[] } interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; retainedTail: AgentMessage[]; - tokensBefore: number; details?; usage? } + tokensBefore: number; details?; usage?; fromHook: boolean } interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; summary: string; - details?; usage? } + details?; usage?; fromHook: boolean } interface CustomEntry extends EntryBase { type: "custom"; customType: string; data? } type Entry = MessageEntry | ModelChangeEntry | ThinkingLevelEntry | ActiveToolsEntry @@ -1439,6 +1441,8 @@ type Entry = MessageEntry | ModelChangeEntry | ThinkingLevelEntry | ActiveToolsE A harness-written assistant `MessageEntry` always contains a `SettledAssistantMessage`; `pending` is rejected before any durable write. A v4 tool-result `MessageEntry` additionally persists the finalized batch-control decision as `terminate?: true` beside `message`. It is orchestration state for the reduction (section 7), never model context; the projection to provider messages ignores it. `AgentToolResult.terminate` exists at the tool API level but `ToolResultMessage` does not carry it, so the entry field is the durable form. +For compaction and branch-summary entries, `fromHook: true` means the summary was supplied by `before_compaction` or `before_navigation`; `false` means the harness generated it. The field is required on every v4 entry. This durable provenance is also an ownership boundary for `details`: harness-generated summaries may use a harness-owned shape that later summary preparation can interpret (for example, cumulative file tracking), while hook-supplied details are opaque and must never be interpreted by the harness. + Every v4 compaction — generated or hook-supplied — stores the complete `retainedTail`; an empty tail is `[]`, never omission. The compaction entry is a self-contained checkpoint: context builds never read past it. Entry `usage` fields — on assistant messages, tool results, compactions, and branch summaries — are immutable display snapshots of the response(s) that produced that entry: a message entry matches its one producing record; a compaction or branch-summary entry carries its successful attempt's request(s), never failed attempts. The durable ledger is the `usage` records; effective cost including later adjustments is a read-time ledger query by `entryId` (sections 5, 13). v3 files additionally contain `custom_message`, `label`, and `session_info` entries, plus old compaction entries that use `firstKeptEntryId`. Load normalizes them before exposing the v4 tree: @@ -1448,6 +1452,7 @@ v3 files additionally contain `custom_message`, `label`, and `session_info` entr - Each retained child of a discarded entry is reparented to the discarded entry's nearest retained ancestor. - `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. - An old compaction resolves `firstKeptEntryId` against its own branch and materializes that range as `retainedTail`. V4 never exposes or persists `firstKeptEntryId`. +- Existing `details` and `usage` on compaction and branch-summary entries are preserved unchanged. Existing `fromHook` provenance is preserved; an absent v3 value normalizes to `false`. - v3 entry timestamps are ISO strings and convert to Unix milliseconds. Read-only opens keep the physical v3 file unchanged; the first v4 write persists the normalized form (section 13). @@ -2370,7 +2375,7 @@ async function assistantStep(): Promise { `isRecoverableOverflow(final, state)` is `isContextOverflow(final)` — overflow-pattern errors and silent overflow — or `isRecoverableLength(final, desiredMaxOutput(state))` from section 6, where `desiredMaxOutput(state)` is the caller-supplied `maxTokens` when set, else the lane model's `maxTokens`. The check runs before the retryable-error branch: an overflow-form error compacts instead of retrying the same oversized request. -`summaryStep(step, reason, resultEntryId)` has the same shape: `step_attempt` before each attempt (`compactionReason` for compaction steps) carrying the step's single result id, `before_request`, one or two non-deferred requests — each followed by its `usage` record bound to that id — durable cap. It returns the summary value; the caller appends the result entry under that id. A hook-supplied summary makes no request and no request record; if it carries usage the hook measured itself, the appending procedure writes a `hook` usage record beside the entry. For reason `overflow` the appending procedure also writes the compaction `step_attempt`, so the once-per-input guard counts the recovery (section 6). +`summaryStep(step, reason, resultEntryId)` has the same shape: `step_attempt` before each attempt (`compactionReason` for compaction steps) carrying the step's single result id, `before_request`, one or two non-deferred requests — each followed by its `usage` record bound to that id — durable cap. It returns the summary value; the caller appends the result entry under that id. A hook-supplied summary makes no request and no request record; its entry persists `fromHook: true`, and if it carries usage the hook measured itself, the appending procedure writes a `hook` usage record beside the entry. For reason `overflow` the appending procedure also writes the compaction `step_attempt`, so the once-per-input guard counts the recovery (section 6). ### Deferred redemption @@ -2504,18 +2509,20 @@ async function compactionProcedure(): Promise { if (op.aborting) return await abortStructural(); if (!op.targets.result) { let result: CompactResult | undefined; + let fromHook = false; if (!op.step) { // no attempt yet: the decision hook may still run const hook = await fx.runHook("before_compaction", { reason: "manual", preparation: preparation(state), customInstructions: op.intent.customInstructions }); if (hook?.decline) return await finishStructural("declined"); result = hook?.compaction; + fromHook = result !== undefined; if (result?.usage) { await fx.appendRecord(hookUsageRecord(op.id, op.intent.resultEntryId, result.usage)); } } result ??= await summaryStep("compaction", "manual", op.intent.resultEntryId); - await appendIfMissing(compactionEntry(op.intent.resultEntryId, result)); + await appendIfMissing(compactionEntry(op.intent.resultEntryId, result, fromHook)); } return await finishStructural("completed"); } catch (e) { return await handleStructuralSignal(e); } @@ -2548,12 +2555,12 @@ async function autoCompact(reason: "threshold" | "overflow"): Promise { if (hook.compaction.usage) { await fx.appendRecord(hookUsageRecord(op.id, resultEntryId, hook.compaction.usage)); } - await appendIfMissing(compactionEntry(resultEntryId, hook.compaction)); + await appendIfMissing(compactionEntry(resultEntryId, hook.compaction, true)); return; } } const result = await summaryStep("compaction", reason, resultEntryId); - await appendIfMissing(compactionEntry(resultEntryId, result)); + await appendIfMissing(compactionEntry(resultEntryId, result, false)); } async function navigationProcedure(): Promise { @@ -2561,6 +2568,7 @@ async function navigationProcedure(): Promise { if (op.aborting) return await abortStructural(); const moved = state.leafId === op.intent.targetId; // acceptance rejected target == source let summary: SummaryValue | undefined; + let fromHook = false; if (op.intent.summarize && !op.targets.summary) { if (!moved && !op.step) { // decision hook: once, pre-move @@ -2570,6 +2578,7 @@ async function navigationProcedure(): Promise { // intent.sourceLeafId — valid pre- and post-move if (hook?.decline) return await finishStructural("declined"); summary = hook?.summary; + fromHook = summary !== undefined; if (summary?.usage) { await fx.appendRecord(hookUsageRecord(op.id, op.intent.summaryEntryId!, summary.usage)); } @@ -2580,7 +2589,7 @@ async function navigationProcedure(): Promise { if (!moved) await fx.moveLane(op.intent.targetId); // the commit point (section 6) if (op.intent.summarize && !op.targets.summary) { - await appendIfMissing(summaryEntry(op.intent.summaryEntryId!, summary!)); // chains to the target + await appendIfMissing(summaryEntry(op.intent.summaryEntryId!, summary!, fromHook)); // chains to the target } if (op.intent.label !== undefined) { await fx.setFact(labelFact(op.intent.targetId, op.intent.label)); // idempotent @@ -3159,10 +3168,10 @@ Gate invariants, asserted across Tier C: - Runtime telemetry tests use the in-memory reference to assert exact schema-conforming span trees and independently valid start/end/event bags on every status path. End attributes remain optional. Content and secret fixtures assert absence, not merely redaction. - The existing `agent-loop` and `agent` suites pass unchanged — the section 14 compatibility criterion. - Event ordering per section 10, including `message_end` after commit. -- Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`. +- Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`, durable summary `fromHook` provenance, and no harness interpretation of hook-owned summary details. - Ledger completeness and the match invariant: every provider request leaves exactly one `usage` record per physical request (split-turn: two per attempt; a pending deferred fetch that reports no usage writes none); failed compaction series and discarded overflow responses lose no recorded cost; each usage-bearing entry's snapshot equals the newest non-adjustment record(s) bound to its id; a replayed tool records both executions; adjustments never alter entries and sum into read-time effective cost; `getStats()` token and cost fields equal the ledger sum and the `usage` event's totals after every commit; fork token and cost fields start at zero while `messageCount` includes all copied message entries; v3 conversion preserves totals through the aggregate import adjustment. - Overflow classification against the reported provider shapes: prompt 268,009 of a 272,000 window and 81,217 of 84,500 (recoverable), non-zero reasoning-only output, cache-write-heavy usage, a Codex-style provider that rejects `max_output_tokens`, a genuine 1,024-token cap fully used (not recoverable), and `length → length` stopping after exactly one recovery per conversational input. -- v3 fixtures: labels and session info mid-chain and at end of file, plus old `firstKeptEntryId` compactions — all open as one normalized idle `main` lane. +- v3 fixtures: labels and session info mid-chain and at end of file, old `firstKeptEntryId` compactions, and preserved `fromHook` provenance on compaction and branch-summary entries — all open as one normalized idle `main` lane. ## 20. Implementation status and work packages @@ -3276,9 +3285,9 @@ These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete ` - Add torn-tail truncation, malformed-interior rejection, missing-reference rejection, and lifecycle/concurrency edge cases. - Acceptance: acknowledged writes survive reopen and malformed non-tail data is never silently repaired. - [ ] **J4 — read-only v3 normalization.** Dependencies: J3. - - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, discarded-entry reparenting, old compactions, timestamps, parent mapping, and idle `main` at the final retained logical entry. + - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, discarded-entry reparenting, old compactions, summary `fromHook` provenance, timestamps, parent mapping, and idle `main` at the final retained logical entry. - A read-only open must not modify the physical file. No coding-agent source or test is changed. - - Acceptance: fixture tests cover every normalization rule in section 12 and malformed v3 input. + - Acceptance: fixture tests cover every normalization rule in section 12, including `fromHook` true and false plus absent v3 values normalizing to false, and malformed v3 input. - [ ] **J5 — first-write v3 conversion.** Dependencies: J4. - Rewrite through a temporary format-4 file on the first mutation, preserve metadata/facts/tree and resolved or legacy parent linkage, and add the aggregate v3 usage adjustment. - Acceptance: crash-safe conversion tests cover failure before rename, successful reopen, statistics preservation, unresolved legacy parent paths, and no second conversion. @@ -3379,7 +3388,7 @@ These packages also own `agent-harness.ts` and merge after H8, in order C1 → C - [ ] **C1 — manual compaction operation.** Dependencies: H8. - Add acceptance, hook decision, durable summary attempts/usage, complete `retainedTail`, result entry, abort/failure, and structural resume. - - Acceptance: exact manual-compaction traces and every crash boundary; hook-supplied summaries obey the same persisted entry contract. + - Acceptance: exact manual-compaction traces and every crash boundary; hook-supplied summaries obey the same persisted entry contract and persist `fromHook: true`. - [ ] **C2 — threshold auto-compaction.** Dependencies: C1, H4. - Run compaction inside the active run at checkpoints without a nested operation and continue the assistant loop. - Acceptance: append-only context holds except at the compaction boundary; repeated compaction retains the previous checkpoint tail. @@ -3388,7 +3397,7 @@ These packages also own `agent-harness.ts` and merge after H8, in order C1 → C - Acceptance: every provider shape and crash row from sections 6 and 20, including hook decline and `length → length`. - [ ] **N1 — move-first navigation.** Dependencies: C3. - Add acceptance, abandoned-branch preparation, hook/generated summary, move commit, post-move summary/fact writes, abort/failure, and structural resume. - - Acceptance: every navigation crash row, including regeneration after a post-move crash and target/source validation. + - Acceptance: every navigation crash row, including regeneration after a post-move crash and target/source validation; hook-supplied summaries persist `fromHook: true`. ### Track O — observability and core completion From 4fbdc63ca364844c0166d3d4eabafb5cf8e7c2f3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 7 Aug 2026 22:55:48 +0200 Subject: [PATCH 053/284] docs: require clear concrete explanations --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index fb6446da53a..09eb963f0de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,9 @@ - No emojis in commits, issues, PR comments, or code - No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!") - Technical prose only, be direct +- Use concise, clear, simple language. Define unavoidable jargon before using it. +- Explain non-trivial designs and problems as: problem, concrete example or short trace, then solution. State why the solution is necessary and distinguish it from optional complexity. +- Prefer concrete behavior and small illustrations over abstract summaries, dense terminology, or unexplained lists of changes. - When the user asks a question, answer it first before making edits or running implementation commands. - When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed. From 02bd2d1c628d0e01c71b7088443d3cb117495d2d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 22:59:56 +0200 Subject: [PATCH 054/284] fix(ai): preserve Responses tool-call namespaces closes #7709 --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/proxy.ts | 3 +- packages/agent/test/proxy.test.ts | 79 ++++++ packages/ai/CHANGELOG.md | 1 + .../ai/src/api/openai-responses-shared.ts | 19 +- packages/ai/src/types.ts | 2 + .../test/openai-responses-namespace.test.ts | 224 ++++++++++++++++++ packages/server/src/protocol.ts | 2 +- 8 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 packages/agent/test/proxy.test.ts create mode 100644 packages/ai/test/openai-responses-namespace.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7911fbc60c4..81e894d8c8b 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `streamProxy()` dropping finalized tool-call metadata such as OpenAI Responses namespaces ([#7709](https://github.com/earendil-works/pi/issues/7709)). + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 2e4cc8f4876..678bc7abfe7 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -43,7 +43,7 @@ export type ProxyAssistantMessageEvent = | { type: "thinking_end"; contentIndex: number; contentSignature?: string } | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } | { type: "toolcall_delta"; contentIndex: number; delta: string } - | { type: "toolcall_end"; contentIndex: number } + | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall } | { type: "done"; reason: Extract; @@ -338,6 +338,7 @@ function processProxyEvent( case "toolcall_end": { const content = partial.content[proxyEvent.contentIndex]; if (content?.type === "toolCall") { + Object.assign(content, proxyEvent.toolCall); delete (content as any).partialJson; return { type: "toolcall_end", diff --git a/packages/agent/test/proxy.test.ts b/packages/agent/test/proxy.test.ts new file mode 100644 index 00000000000..05f59b11f09 --- /dev/null +++ b/packages/agent/test/proxy.test.ts @@ -0,0 +1,79 @@ +import type { AssistantMessage, AssistantMessageEvent, Model } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type ProxyAssistantMessageEvent, streamProxy } from "../src/proxy.ts"; + +const model: Model<"openai-responses"> = { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, +}; + +const usage: AssistantMessage["usage"] = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("streamProxy", () => { + it("preserves tool-call metadata received only on toolcall_end", async () => { + const proxyEvents: ProxyAssistantMessageEvent[] = [ + { type: "start" }, + { type: "toolcall_start", contentIndex: 0, id: "call_test|fc_test", toolName: "lookup" }, + { type: "toolcall_delta", contentIndex: 0, delta: '{"value":"hello"}' }, + { + type: "toolcall_end", + contentIndex: 0, + toolCall: { + type: "toolCall", + id: "call_test|fc_test", + name: "lookup", + arguments: { value: "hello" }, + namespace: "dynamic_tools", + }, + }, + { type: "done", reason: "toolUse", usage }, + ]; + const body = proxyEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(body, { status: 200 })), + ); + + const stream = streamProxy( + model, + { systemPrompt: "", messages: [] }, + { + authToken: "test-token", + proxyUrl: "https://proxy.example.com", + }, + ); + const events: AssistantMessageEvent[] = []; + for await (const event of stream) events.push(event); + const result = await stream.result(); + const endEvent = events.find((event) => event.type === "toolcall_end"); + + expect(endEvent).toMatchObject({ + type: "toolcall_end", + toolCall: { namespace: "dynamic_tools" }, + }); + expect(result.content[0]).toMatchObject({ + type: "toolCall", + arguments: { value: "hello" }, + namespace: "dynamic_tools", + }); + }); +}); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 86fc38efef1..b18b16821dc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed upstream request buffer limit failures to trigger automatic assistant retries. +- Fixed OpenAI Responses function and custom tool calls to preserve namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). ## [0.84.1] - 2026-08-07 diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index ad24c8d463c..801c9cfcd7e 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -210,10 +210,9 @@ export function convertResponsesMessages( } else if (msg.role === "assistant") { const output: ResponseInput = []; const assistantMsg = msg as AssistantMessage; - const isDifferentModel = - assistantMsg.model !== model.id && - assistantMsg.provider === model.provider && - assistantMsg.api === model.api; + const isSameProviderAndApi = assistantMsg.provider === model.provider && assistantMsg.api === model.api; + const isSameModel = isSameProviderAndApi && assistantMsg.model === model.id; + const isDifferentModel = isSameProviderAndApi && assistantMsg.model !== model.id; let textBlockIndex = 0; for (const block of msg.content) { @@ -261,6 +260,8 @@ export function convertResponsesMessages( itemId = undefined; } + const canReplayNamespace = isSameModel || options?.deferredTools?.has(toolCall.name) === true; + if (customInputProperty !== undefined) { output.push({ type: "custom_tool_call", @@ -270,6 +271,9 @@ export function convertResponsesMessages( input: sanitizeSurrogates( getGrammarToolInput(toolCall.name, toolCall.arguments, customInputProperty), ), + ...(canReplayNamespace && toolCall.namespace !== undefined + ? { namespace: toolCall.namespace } + : {}), } satisfies ResponseOutputItem); } else { output.push({ @@ -278,6 +282,9 @@ export function convertResponsesMessages( call_id: callId, name: toolCall.name, arguments: JSON.stringify(toolCall.arguments), + ...(canReplayNamespace && toolCall.namespace !== undefined + ? { namespace: toolCall.namespace } + : {}), }); } } @@ -472,6 +479,7 @@ export async function processResponsesStream( id: `${item.call_id}|${item.id}`, name: item.name, arguments: {}, + ...(item.namespace !== undefined ? { namespace: item.namespace } : {}), partialJson: item.arguments || "", }; output.content.push(block); @@ -492,6 +500,7 @@ export async function processResponsesStream( id: `${item.call_id}|${item.id}`, name: item.name, arguments: { [inputProperty]: input }, + ...(item.namespace !== undefined ? { namespace: item.namespace } : {}), customInput: { property: inputProperty, jsonBuffer: { input: "", started: false, closed: false }, @@ -693,6 +702,7 @@ export async function processResponsesStream( slot.block.partialJson !== undefined ) { slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}"); + if (item.namespace !== undefined) slot.block.namespace = item.namespace; // Finalize in-place and strip the scratch buffer so replay only // carries parsed arguments. delete slot.block.partialJson; @@ -708,6 +718,7 @@ export async function processResponsesStream( slot, appendCustomToolCallInput(slot.block, item.input ?? getCustomToolCallInput(slot.block), true), ); + if (item.namespace !== undefined) slot.block.namespace = item.namespace; delete slot.block.customInput; stream.push({ type: "toolcall_end", diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 5bf6c9fa6ea..9b11136b79f 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -363,6 +363,8 @@ export interface ToolCall { name: string; arguments: Record; thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context + /** OpenAI Responses namespace for calls to dynamically loaded or namespaced tools. */ + namespace?: string; } export interface Usage { diff --git a/packages/ai/test/openai-responses-namespace.test.ts b/packages/ai/test/openai-responses-namespace.test.ts new file mode 100644 index 00000000000..75fc4eac19d --- /dev/null +++ b/packages/ai/test/openai-responses-namespace.test.ts @@ -0,0 +1,224 @@ +import type { ResponseStreamEvent } from "openai/resources/responses/responses.js"; +import { describe, expect, it } from "vitest"; +import { convertResponsesMessages, processResponsesStream } from "../src/api/openai-responses-shared.ts"; +import type { Api, AssistantMessage, Model, ToolCall } from "../src/types.ts"; +import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; + +const model: Model<"openai-responses"> = { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, +}; + +function createOutput(): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "pending", + timestamp: Date.now(), + }; +} + +async function* createFunctionCallEvents(): AsyncIterable { + yield { + type: "response.output_item.added", + sequence_number: 0, + output_index: 0, + item: { + type: "function_call", + id: "fc_test", + call_id: "call_test", + name: "lookup", + arguments: "", + }, + } as ResponseStreamEvent; + yield { + type: "response.output_item.done", + sequence_number: 1, + output_index: 0, + item: { + type: "function_call", + id: "fc_test", + call_id: "call_test", + name: "lookup", + arguments: '{"value":"hello"}', + namespace: "dynamic_tools", + }, + } as ResponseStreamEvent; + yield { + type: "response.completed", + sequence_number: 2, + response: { id: "resp_test", status: "completed" }, + } as ResponseStreamEvent; +} + +async function* createCustomToolCallEvents(): AsyncIterable { + yield { + type: "response.output_item.added", + sequence_number: 0, + output_index: 0, + item: { + type: "custom_tool_call", + id: "ctc_test", + call_id: "call_test", + name: "query", + input: "", + }, + } as ResponseStreamEvent; + yield { + type: "response.output_item.done", + sequence_number: 1, + output_index: 0, + item: { + type: "custom_tool_call", + id: "ctc_test", + call_id: "call_test", + name: "query", + input: "hello", + namespace: "dynamic_tools", + }, + } as ResponseStreamEvent; + yield { + type: "response.completed", + sequence_number: 2, + response: { id: "resp_test", status: "completed" }, + } as ResponseStreamEvent; +} + +function getToolCall(output: AssistantMessage): ToolCall { + const block = output.content[0]; + if (!block || block.type !== "toolCall") throw new Error("Expected toolCall block"); + return block; +} + +describe("OpenAI Responses tool-call namespaces", () => { + it("round-trips a function namespace received only on output_item.done", async () => { + const output = createOutput(); + await processResponsesStream(createFunctionCallEvents(), output, new AssistantMessageEventStream(), model); + + const toolCall = getToolCall(output); + expect(toolCall).toMatchObject({ + id: "call_test|fc_test", + name: "lookup", + arguments: { value: "hello" }, + namespace: "dynamic_tools", + }); + + const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"])).find( + (item) => item.type === "function_call", + ); + expect(replayed).toMatchObject({ + type: "function_call", + id: "fc_test", + call_id: "call_test", + name: "lookup", + arguments: '{"value":"hello"}', + namespace: "dynamic_tools", + }); + }); + + it("round-trips a custom-tool namespace received only on output_item.done", async () => { + const output = createOutput(); + const grammarToolInputProperties = new Map([["query", "input"]]); + await processResponsesStream(createCustomToolCallEvents(), output, new AssistantMessageEventStream(), model, { + grammarToolInputProperties, + }); + + const toolCall = getToolCall(output); + expect(toolCall).toMatchObject({ + id: "call_test|ctc_test", + name: "query", + arguments: { input: "hello" }, + namespace: "dynamic_tools", + }); + + const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"]), { + grammarToolInputProperties, + }).find((item) => item.type === "custom_tool_call"); + expect(replayed).toMatchObject({ + type: "custom_tool_call", + id: "ctc_test", + call_id: "call_test", + name: "query", + input: "hello", + namespace: "dynamic_tools", + }); + }); + + it("drops namespaces when the target cannot replay their load items", () => { + const output = createOutput(); + output.content.push( + { + type: "toolCall", + id: "call_function|fc_test", + name: "lookup", + arguments: { value: "hello" }, + namespace: "dynamic_tools", + }, + { + type: "toolCall", + id: "call_custom|ctc_test", + name: "query", + arguments: { input: "hello" }, + namespace: "dynamic_tools", + }, + ); + const targetModels: Model[] = [ + { ...model, id: "gpt-5.2", name: "GPT-5.2" }, + { ...model, provider: "azure-openai-responses" }, + { + ...model, + api: "openai-codex-responses", + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + }, + ]; + + for (const targetModel of targetModels) { + const replayed = convertResponsesMessages(targetModel, { messages: [output] }, new Set(["openai"]), { + grammarToolInputProperties: new Map([["query", "input"]]), + }); + const functionCall = replayed.find((item) => item.type === "function_call"); + const customToolCall = replayed.find((item) => item.type === "custom_tool_call"); + expect(functionCall).toBeDefined(); + expect(functionCall).not.toHaveProperty("namespace"); + expect(customToolCall).toBeDefined(); + expect(customToolCall).not.toHaveProperty("namespace"); + } + }); + + it("does not add a namespace to ordinary function calls", () => { + const output = createOutput(); + output.content.push({ + type: "toolCall", + id: "call_test|fc_test", + name: "lookup", + arguments: { value: "hello" }, + }); + + const replayed = convertResponsesMessages(model, { messages: [output] }, new Set(["openai"])).find( + (item) => item.type === "function_call", + ); + expect(replayed).toBeDefined(); + expect(replayed).not.toHaveProperty("namespace"); + }); +}); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 6180cf407e1..069828e590e 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -44,7 +44,7 @@ type _AiThinkingContentFieldsAccountedFor = Assert< >; type _AiImageContentFieldsAccountedFor = Assert>; type _AiToolCallFieldsAccountedFor = Assert< - ExactKeys + ExactKeys >; type _AiUsageFieldsAccountedFor = Assert< ExactKeys< From e47b8e37a6211ebd0b2942fa87059d64f81eec02 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 23:03:46 +0200 Subject: [PATCH 055/284] feat(ai): use additional_tools for deferred tools closes #7709 --- package-lock.json | 11 +-- packages/ai/CHANGELOG.md | 4 + packages/ai/package.json | 2 +- packages/ai/scripts/generate-models.ts | 10 +++ packages/ai/src/api/openai-codex-responses.ts | 8 +- .../ai/src/api/openai-responses-shared.ts | 11 ++- packages/ai/src/api/openai-responses.ts | 9 +- packages/ai/src/types.ts | 2 + packages/ai/test/deferred-tools.test.ts | 84 +++++++++++++++++-- .../install-lock/package-lock.json | 11 +-- packages/coding-agent/npm-shrinkwrap.json | 11 +-- .../coding-agent/src/core/model-config.ts | 1 + 12 files changed, 129 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48e638d5e8c..813d596151b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4191,13 +4191,10 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" @@ -5487,7 +5484,7 @@ "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b18b16821dc..f984fdf53b6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). + ### Fixed - Fixed upstream request buffer limit failures to trigger automatic assistant retries. diff --git a/packages/ai/package.json b/packages/ai/package.json index 50a06caab36..1b23d01d540 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -69,7 +69,7 @@ "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 90e6bbb2f4e..4c0d58e6c52 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -351,6 +351,12 @@ const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([ "gpt-5.6-terra", "gpt-5.6-luna", ]); +// Public OpenAI documents additional_tools for applications that load tools +// outside the normal tool-search flow. Codex currently uses the input item for +// its Responses Lite GPT-5.6 models. +// https://developers.openai.com/api/docs/guides/tools-tool-search#add-tools-at-a-specific-point-in-the-input +const OPENAI_ADDITIONAL_TOOLS_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS; +const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000; const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = new Set([ "gpt-5.4", @@ -755,8 +761,12 @@ function applyOpenAIToolSearchMetadata(model: Model): void { const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses"; const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses"; if (!(isOpenAIResponses || isOpenAICodex) || !OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) return; + const supportsAdditionalTools = + (isOpenAIResponses && OPENAI_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)) || + (isOpenAICodex && OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)); model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), + ...(supportsAdditionalTools ? { supportsAdditionalTools: true } : {}), supportsToolSearch: true, }; } diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index e72ec06aaef..863e2ef46d0 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -539,11 +539,17 @@ function buildRequestBody( ): RequestBody { const supportsStrictMode = model.compat?.supportsStrictMode ?? true; const supportsOpenAIGrammarTools = model.compat?.supportsOpenAIGrammarTools ?? false; - const toolPlacement = splitDeferredTools(context, model.compat?.supportsToolSearch ?? false); + const deferredToolsMode = model.compat?.supportsAdditionalTools + ? "additional-tools" + : model.compat?.supportsToolSearch + ? "tool-search" + : undefined; + const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined); const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, { includeSystemPrompt: false, grammarToolInputProperties, deferredTools: toolPlacement.deferred, + deferredToolsMode, toolOptions: { strict: null, supportsStrictMode, diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 801c9cfcd7e..8ed84468132 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -119,6 +119,7 @@ export interface ConvertResponsesMessagesOptions { includeSystemPrompt?: boolean; grammarToolInputProperties?: ReadonlyMap; deferredTools?: ReadonlyMap; + deferredToolsMode?: "additional-tools" | "tool-search"; toolOptions?: ConvertResponsesToolsOptions; } @@ -316,7 +317,13 @@ export function convertResponsesMessages( loadedToolNames.add(name); deferredTools.push(tool); } - if (deferredTools.length > 0) { + if (deferredTools.length > 0 && options?.deferredToolsMode === "additional-tools") { + messages.push({ + type: "additional_tools", + role: "developer", + tools: convertResponsesTools(deferredTools, options.toolOptions), + } satisfies ResponseInputItem); + } else if (deferredTools.length > 0 && options?.deferredToolsMode === "tool-search") { const names = deferredTools.map((tool) => tool.name); const searchCallId = `pi_tool_load_${shortHash(`${msg.toolCallId}:${names.join(",")}`)}`; messages.push({ @@ -332,7 +339,7 @@ export function convertResponsesMessages( execution: "client", status: "completed", tools: convertResponsesTools(deferredTools, { - ...options?.toolOptions, + ...options.toolOptions, deferLoading: true, }), } satisfies ResponseToolSearchOutputItemParam); diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index b90de3b768f..578398e8eb8 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -71,6 +71,7 @@ function getCompat(model: Model<"openai-responses">): Required; } +interface OpenAIAdditionalTools { + type: "additional_tools"; + role: "developer"; + tools: Array<{ type: string; name: string; defer_loading?: boolean }>; +} + interface OpenAIPayload { tools?: Array<{ name?: string; function?: { name: string } }>; - input?: Array; + input?: Array< + OpenAIAdditionalTools | OpenAIToolSearchCall | OpenAIToolSearchOutput | { type?: string; name?: string } + >; } interface KimiTool { @@ -394,9 +402,57 @@ describe("deferred tools", () => { expect(payload.messages.some((message) => message.tools !== undefined)).toBe(false); }); - it("loads an OpenAI Responses tool through client tool search", async () => { + it("loads an OpenAI Responses tool through additional_tools", async () => { const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); const payload = await capturePayload(getModel("openai", "gpt-5.4"), context); + const additionalTools = payload.input?.find( + (item): item is OpenAIAdditionalTools => item.type === "additional_tools", + ); + + expect(openAIToolNames(payload)).toEqual(["base_tool"]); + expect(additionalTools).toMatchObject({ role: "developer" }); + expect(additionalTools?.tools).toMatchObject([{ type: "function", name: "late_tool" }]); + expect(additionalTools?.tools.every((tool) => tool.defer_loading === undefined)).toBe(true); + expect(payload.input?.some((item) => item.type === "tool_search_call")).toBe(false); + expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false); + }); + + it("preserves an additional_tools marker after the loaded tool is used", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const lateCall: AssistantMessage = { + ...makeAssistantToolCall(), + content: [{ type: "toolCall", id: "call_late|fc_late", name: "late_tool", arguments: {} }], + api: "openai-responses", + provider: "openai", + model: "gpt-5.4", + }; + context.messages.splice(3, 0, lateCall, { + ...makeToolResult(["late_tool"]), + toolCallId: "call_late|fc_late", + toolName: "late_tool", + }); + + const payload = await capturePayload(getModel("openai", "gpt-5.4"), context); + const additionalToolIndexes = (payload.input ?? []).flatMap((item, index) => + item.type === "additional_tools" ? [index] : [], + ); + const lateCallIndex = (payload.input ?? []).findIndex( + (item) => item.type === "function_call" && item.name === "late_tool", + ); + + expect(additionalToolIndexes).toHaveLength(1); + expect(additionalToolIndexes[0]).toBeLessThan(lateCallIndex); + expect(openAIToolNames(payload)).toEqual(["base_tool"]); + }); + + it("falls back to client tool search when additional_tools is unsupported", async () => { + const model: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "openai-proxy", + compat: { supportsAdditionalTools: false, supportsToolSearch: true }, + }; + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(model, context); const searchCall = payload.input?.find((item): item is OpenAIToolSearchCall => item.type === "tool_search_call"); const searchOutput = payload.input?.find( (item): item is OpenAIToolSearchOutput => item.type === "tool_search_output", @@ -406,6 +462,7 @@ describe("deferred tools", () => { expect(searchCall).toMatchObject({ execution: "client", status: "completed" }); expect(searchOutput?.call_id).toBe(searchCall?.call_id); expect(searchOutput?.tools).toMatchObject([{ type: "function", name: "late_tool", defer_loading: true }]); + expect(payload.input?.some((item) => item.type === "additional_tools")).toBe(false); }); it.each(["gpt-5.2", "gpt-5.4-nano", "gpt-5.5-pro"] as const)( @@ -432,23 +489,32 @@ describe("deferred tools", () => { expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false); }); - it("uses tool search only for supported Codex models", async () => { + it("selects additional tools, tool search, or top-level tools for Codex models", async () => { const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); - const supported = await capturePayload( + const additionalTools = await capturePayload( + getModel("openai-codex", "gpt-5.6-sol"), + context, + makeCodexToken(), + ); + const toolSearch = await capturePayload( getModel("openai-codex", "gpt-5.4"), context, makeCodexToken(), ); - const unsupported = await capturePayload( + const topLevel = await capturePayload( getModel("openai-codex", "gpt-5.3-codex-spark"), context, makeCodexToken(), ); - expect(openAIToolNames(supported)).toEqual(["base_tool"]); - expect(supported.input?.some((item) => item.type === "tool_search_output")).toBe(true); - expect(openAIToolNames(unsupported)).toEqual(["base_tool", "late_tool"]); - expect(unsupported.input?.some((item) => item.type === "tool_search_output")).toBe(false); + expect(openAIToolNames(additionalTools)).toEqual(["base_tool"]); + expect(additionalTools.input?.some((item) => item.type === "additional_tools")).toBe(true); + expect(additionalTools.input?.some((item) => item.type === "tool_search_output")).toBe(false); + expect(openAIToolNames(toolSearch)).toEqual(["base_tool"]); + expect(toolSearch.input?.some((item) => item.type === "tool_search_output")).toBe(true); + expect(openAIToolNames(topLevel)).toEqual(["base_tool", "late_tool"]); + expect(topLevel.input?.some((item) => item.type === "additional_tools")).toBe(false); + expect(topLevel.input?.some((item) => item.type === "tool_search_output")).toBe(false); }); it("leaves providers without deferred loading unchanged", async () => { diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 32536f4a129..321dd5ec3f7 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -479,7 +479,7 @@ "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, @@ -1541,9 +1541,9 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", @@ -1556,9 +1556,6 @@ "zod": { "optional": true } - }, - "bin": { - "openai": "bin/cli" } }, "node_modules/p-retry": { diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index ea3af110df7..bd313840ed3 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -506,7 +506,7 @@ "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, @@ -1531,9 +1531,9 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", @@ -1546,9 +1546,6 @@ "zod": { "optional": true } - }, - "bin": { - "openai": "bin/cli" } }, "node_modules/p-retry": { diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 7f679a4947f..7ea12cbebdd 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -117,6 +117,7 @@ const OpenAIResponsesCompatSchema = Type.Object({ supportsLongCacheRetention: Type.Optional(Type.Boolean()), supportsStrictMode: Type.Optional(Type.Boolean()), supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()), + supportsAdditionalTools: Type.Optional(Type.Boolean()), supportsToolSearch: Type.Optional(Type.Boolean()), }); From 9dd90a49711d088b86fdd9b4aea575913a8328a8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 8 Aug 2026 09:31:50 +0200 Subject: [PATCH 056/284] fix(ai): replace Mistral SDK with native transport --- package-lock.json | 39 -- packages/ai/CHANGELOG.md | 1 + packages/ai/package.json | 1 - packages/ai/src/api/mistral-conversations.ts | 378 +++++++++++++--- packages/ai/test/lazy-module-load.test.ts | 8 +- .../ai/test/mistral-http-transport.test.ts | 427 ++++++++++++++++++ .../ai/test/mistral-raw-stop-reason.test.ts | 78 ++-- packages/ai/test/stream.test.ts | 8 +- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/custom-provider.md | 2 +- .../install-lock/package-lock.json | 48 -- packages/coding-agent/npm-shrinkwrap.json | 48 -- 12 files changed, 785 insertions(+), 257 deletions(-) create mode 100644 packages/ai/test/mistral-http-transport.test.ts diff --git a/package-lock.json b/package-lock.json index 813d596151b..b0f2e629190 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1495,26 +1495,6 @@ "node": ">= 10" } }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.40.0", - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -1593,15 +1573,6 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -5415,15 +5386,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "packages/agent": { "name": "@earendil-works/pi-agent-core", "version": "0.84.1", @@ -5479,7 +5441,6 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f984fdf53b6..638507227ad 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). +- Replaced the Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index 1b23d01d540..552876250f8 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -64,7 +64,6 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index 9bbb96b79f4..e29d1a13a33 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -1,11 +1,3 @@ -import { HTTPClient, Mistral } from "@mistralai/mistralai"; -import type { - ChatCompletionStreamRequest, - ChatCompletionStreamRequestMessage, - CompletionEvent, - ContentChunk, - FunctionTool, -} from "@mistralai/mistralai/models/components"; import { calculateCost, clampThinkingLevel } from "../models.ts"; import type { AssistantMessage, @@ -23,6 +15,7 @@ import type { } from "../types.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; +import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; @@ -43,8 +36,87 @@ export interface MistralOptions extends StreamOptions { reasoningEffort?: MistralReasoningEffort; } +type MistralContentChunk = + | { type: "text"; text: string } + | { type: "image_url"; imageUrl: string } + | { type: "thinking"; thinking: Array<{ type: "text"; text: string }> }; + +type MistralRequestToolCall = { + id: string; + type: "function"; + function: { name: string; arguments: string }; + index: number; +}; + +type MistralChatMessage = { + role: "system" | "user" | "assistant" | "tool"; + content?: string | MistralContentChunk[]; + toolCalls?: MistralRequestToolCall[]; + toolCallId?: string; + name?: string; + prefix?: boolean; +}; + +type MistralFunctionTool = { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + strict: boolean; + }; +}; + +type MistralChatPayload = { + [key: string]: unknown; + model: string; + stream: boolean; + messages: MistralChatMessage[]; + tools?: MistralFunctionTool[]; + temperature?: number; + maxTokens?: number; + toolChoice?: Exclude; + promptMode?: "reasoning"; + reasoningEffort?: MistralReasoningEffort; + promptCacheKey?: string; +}; + +type MistralStreamContentChunk = { + type: string; + text?: string; + thinking?: Array<{ text?: string }>; +}; + +type MistralStreamToolCall = { + id?: string; + index?: number; + function: { + name: string; + arguments: string | Record; + }; +}; + +type MistralCompletionEvent = { + data: { + id?: string; + usage?: { + [key: string]: unknown; + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + choices: Array<{ + finish_reason?: string | null; + delta: { + content?: string | MistralStreamContentChunk[] | null; + tool_calls?: MistralStreamToolCall[] | null; + }; + }>; + }; +}; + /** - * Stream responses from Mistral using `chat.stream`. + * Stream responses from the native Mistral Chat Completions endpoint. */ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = ( model: Model<"mistral-conversations">, @@ -62,22 +134,15 @@ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = ( throw new Error(`No API key for provider: ${model.provider}`); } - // Intentionally per-request: avoids shared SDK mutable state across concurrent consumers. - const mistral = new Mistral({ - apiKey, - serverURL: model.baseUrl, - ...(options?.fetch ? { httpClient: new HTTPClient({ fetcher: options.fetch }) } : {}), - }); - const normalizeMistralToolCallId = createMistralToolCallIdNormalizer(); const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id)); let payload = buildChatPayload(model, context, transformedMessages, options); const nextPayload = await options?.onPayload?.(payload, model); if (nextPayload !== undefined) { - payload = nextPayload as ChatCompletionStreamRequest; + payload = nextPayload as MistralChatPayload; } - const mistralStream = await mistral.chat.stream(payload, buildRequestOptions(model, options)); + const mistralStream = await requestMistralStream(model, payload, apiKey, options); stream.push({ type: "start", partial: output }); await consumeChatStream(model, output, stream, mistralStream); @@ -189,9 +254,9 @@ function deriveMistralToolCallId(id: string, attempt: number): string { function formatMistralError(error: unknown): string { if (error instanceof Error) { - const sdkError = error as Error & { statusCode?: unknown; body?: unknown }; - const statusCode = typeof sdkError.statusCode === "number" ? sdkError.statusCode : undefined; - const bodyText = typeof sdkError.body === "string" ? sdkError.body.trim() : undefined; + const httpError = error as Error & { statusCode?: unknown; body?: unknown }; + const statusCode = typeof httpError.statusCode === "number" ? httpError.statusCode : undefined; + const bodyText = typeof httpError.body === "string" ? httpError.body.trim() : undefined; if (statusCode !== undefined && bodyText) { return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`; } @@ -215,31 +280,219 @@ function safeJsonStringify(value: unknown): string { } } -function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions) { - const requestOptions: { - signal?: AbortSignal; - retries: { strategy: "none" }; - headers?: Record; - } = { - retries: { strategy: "none" }, - }; - if (options?.signal) requestOptions.signal = options.signal; +async function requestMistralStream( + model: Model<"mistral-conversations">, + payload: MistralChatPayload, + apiKey: string, + options?: MistralOptions, +): Promise> { + const baseUrl = new URL(model.baseUrl); + baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/u, "")}/`; + const url = new URL("v1/chat/completions", baseUrl); + const headers = buildMistralHeaders(model, apiKey, options); + const timeoutSignal = AbortSignal.timeout(options?.timeoutMs ?? 60_000); + const signal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; + const response = await (options?.fetch ?? globalThis.fetch)(url, { + method: "POST", + headers, + body: JSON.stringify(toMistralWirePayload(payload)), + signal, + }); - const headers: Record = {}; - if (model.headers) Object.assign(headers, model.headers); - if (options?.headers) Object.assign(headers, options.headers); + await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); - // Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching). - // Respect explicit caller-provided header values. - if (shouldUsePromptCaching(options) && !headers["x-affinity"]) { - headers["x-affinity"] = options.sessionId; + if (!response.ok) { + const body = await response.text(); + throw new MistralHttpError(response.status, body, response.statusText); + } + if (!response.body) { + throw new Error("Mistral response has no body"); } - if (Object.keys(headers).length > 0) { - requestOptions.headers = headers; + return readMistralEvents(response.body, signal); +} + +class MistralHttpError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, body: string, statusText: string) { + super(statusText || `Request failed with status ${statusCode}`); + this.name = "MistralHttpError"; + this.statusCode = statusCode; + this.body = body; } +} + +function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers { + const headers = new Headers({ + accept: "text/event-stream", + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }); + applyMistralHeaderOverrides(headers, model.headers); + applyMistralHeaderOverrides(headers, options?.headers); + + const hasExplicitAffinity = + hasMistralHeaderOverride(model.headers, "x-affinity") || hasMistralHeaderOverride(options?.headers, "x-affinity"); + if (shouldUsePromptCaching(options) && !hasExplicitAffinity) { + headers.set("x-affinity", options.sessionId); + } + + return headers; +} - return requestOptions; +function applyMistralHeaderOverrides(headers: Headers, overrides?: Record): void { + if (!overrides) return; + for (const [name, value] of Object.entries(overrides)) { + if (value === null) headers.delete(name); + else headers.set(name, value); + } +} + +function hasMistralHeaderOverride(overrides: Record | undefined, target: string): boolean { + return !!overrides && Object.keys(overrides).some((name) => name.toLowerCase() === target); +} + +function toMistralWirePayload(payload: MistralChatPayload): Record { + const wirePayload: Record = { ...payload }; + for (const [source, target] of [ + ["topP", "top_p"], + ["maxTokens", "max_tokens"], + ["randomSeed", "random_seed"], + ["responseFormat", "response_format"], + ["toolChoice", "tool_choice"], + ["presencePenalty", "presence_penalty"], + ["frequencyPenalty", "frequency_penalty"], + ["parallelToolCalls", "parallel_tool_calls"], + ["reasoningEffort", "reasoning_effort"], + ["promptMode", "prompt_mode"], + ["promptCacheKey", "prompt_cache_key"], + ["safePrompt", "safe_prompt"], + ] as const) { + remapMistralProperty(wirePayload, source, target); + } + wirePayload.messages = payload.messages.map((message) => toMistralWireMessage(message)); + + const responseFormat = wirePayload.response_format; + if (isMistralRecord(responseFormat)) { + const wireResponseFormat = { ...responseFormat }; + remapMistralProperty(wireResponseFormat, "jsonSchema", "json_schema"); + const jsonSchema = wireResponseFormat.json_schema; + if (isMistralRecord(jsonSchema)) { + const wireJsonSchema = { ...jsonSchema }; + remapMistralProperty(wireJsonSchema, "schemaDefinition", "schema"); + wireResponseFormat.json_schema = wireJsonSchema; + } + wirePayload.response_format = wireResponseFormat; + } + + return wirePayload; +} + +function toMistralWireMessage(message: MistralChatMessage): Record { + const wireMessage: Record = { ...message }; + remapMistralProperty(wireMessage, "toolCalls", "tool_calls"); + remapMistralProperty(wireMessage, "toolCallId", "tool_call_id"); + if (Array.isArray(message.content)) { + wireMessage.content = message.content.map((chunk) => toMistralWireContentChunk(chunk)); + } + return wireMessage; +} + +function toMistralWireContentChunk(chunk: MistralContentChunk): Record { + const wireChunk: Record = { ...chunk }; + for (const [source, target] of [ + ["imageUrl", "image_url"], + ["documentUrl", "document_url"], + ["documentName", "document_name"], + ["fileId", "file_id"], + ["referenceIds", "reference_ids"], + ["inputAudio", "input_audio"], + ] as const) { + remapMistralProperty(wireChunk, source, target); + } + return wireChunk; +} + +function remapMistralProperty(record: Record, source: string, target: string): void { + if (!(source in record)) return; + record[target] = record[source]; + delete record[source]; +} + +function isMistralRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const MISTRAL_STREAM_DONE = Symbol("mistral-stream-done"); + +async function* readMistralEvents( + body: ReadableStream, + signal: AbortSignal, +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const onAbort = () => { + void reader.cancel().catch(() => {}); + }; + signal.addEventListener("abort", onAbort, { once: true }); + + try { + while (true) { + if (signal.aborted) throw signal.reason; + const { done, value } = await reader.read(); + if (signal.aborted) throw signal.reason; + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + + let boundary = findMistralEventBoundary(buffer); + while (boundary) { + const event = parseMistralEvent(buffer.slice(0, boundary.index)); + buffer = buffer.slice(boundary.index + boundary.length); + if (event === MISTRAL_STREAM_DONE) return; + if (event) yield event; + boundary = findMistralEventBoundary(buffer); + } + + if (done) break; + } + + if (buffer.trim()) { + const event = parseMistralEvent(buffer); + if (event !== MISTRAL_STREAM_DONE && event) yield event; + } + } finally { + signal.removeEventListener("abort", onAbort); + try { + await reader.cancel(); + } catch {} + try { + reader.releaseLock(); + } catch {} + } +} + +function findMistralEventBoundary(buffer: string): { index: number; length: number } | undefined { + const match = /\r\n\r\n|\r\n\r|\r\n\n|\r\r\n|\n\r\n|\r\r|\n\r|\n\n/u.exec(buffer); + return match?.index === undefined ? undefined : { index: match.index, length: match[0].length }; +} + +function parseMistralEvent(raw: string): MistralCompletionEvent | typeof MISTRAL_STREAM_DONE | undefined { + const data = raw + .split(/\r\n|\r|\n/u) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n") + .trim(); + if (!data) return undefined; + if (data === "[DONE]") return MISTRAL_STREAM_DONE; + + const parsed: unknown = JSON.parse(data); + if (!isMistralRecord(parsed) || !Array.isArray(parsed.choices)) { + throw new Error("Invalid Mistral streaming event"); + } + return { data: parsed as MistralCompletionEvent["data"] }; } function buildChatPayload( @@ -247,8 +500,8 @@ function buildChatPayload( context: Context, messages: Message[], options?: MistralOptions, -): ChatCompletionStreamRequest { - const payload: ChatCompletionStreamRequest = { +): MistralChatPayload { + const payload: MistralChatPayload = { model: model.id, stream: true, messages: toChatMessages(messages, model.input.includes("image")), @@ -301,7 +554,7 @@ async function consumeChatStream( model: Model<"mistral-conversations">, output: AssistantMessage, stream: AssistantMessageEventStream, - mistralStream: AsyncIterable, + mistralStream: AsyncIterable, ): Promise { let currentBlock: TextContent | ThinkingContent | null = null; const blocks = output.content; @@ -336,15 +589,15 @@ async function consumeChatStream( output.responseId ||= chunk.id; if (chunk.usage) { - const promptTokens = chunk.usage.promptTokens || 0; + const promptTokens = chunk.usage.prompt_tokens || 0; const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens); output.usage.input = Math.max(0, promptTokens - cachedPromptTokens); - output.usage.output = chunk.usage.completionTokens || 0; + output.usage.output = chunk.usage.completion_tokens || 0; output.usage.cacheRead = cachedPromptTokens; output.usage.cacheWrite = 0; output.usage.totalTokens = - chunk.usage.totalTokens || + chunk.usage.total_tokens || output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(model, output.usage); } @@ -352,9 +605,9 @@ async function consumeChatStream( const choice = chunk.choices[0]; if (!choice) continue; - if (choice.finishReason) { - output.rawStopReason = choice.finishReason; - const stopReasonResult = mapChatStopReason(choice.finishReason); + if (choice.finish_reason) { + output.rawStopReason = choice.finish_reason; + const stopReasonResult = mapChatStopReason(choice.finish_reason); output.stopReason = stopReasonResult.stopReason; if (stopReasonResult.errorMessage) { output.errorMessage = stopReasonResult.errorMessage; @@ -384,8 +637,8 @@ async function consumeChatStream( } if (item.type === "thinking") { - const deltaText = item.thinking - .map((part) => ("text" in part ? part.text : "")) + const deltaText = (item.thinking ?? []) + .map((part) => part.text ?? "") .filter((text) => text.length > 0) .join(""); const thinkingDelta = sanitizeSurrogates(deltaText); @@ -407,7 +660,7 @@ async function consumeChatStream( } if (item.type === "text") { - const textDelta = sanitizeSurrogates(item.text); + const textDelta = sanitizeSurrogates(item.text ?? ""); if (!currentBlock || currentBlock.type !== "text") { finishCurrentBlock(currentBlock); currentBlock = { type: "text", text: "" }; @@ -425,7 +678,7 @@ async function consumeChatStream( } } - const toolCalls = delta.toolCalls || []; + const toolCalls = delta.tool_calls || []; for (const toolCall of toolCalls) { if (currentBlock) { finishCurrentBlock(currentBlock); @@ -492,7 +745,7 @@ async function consumeChatStream( } } -function toFunctionTools(tools: Tool[]): Array { +function toFunctionTools(tools: Tool[]): MistralFunctionTool[] { return tools.map((tool) => { const strict = resolveJsonSchemaStrictSampling(tool, true); return { @@ -523,8 +776,8 @@ function stripSymbolKeys(value: unknown): unknown { return value; } -function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompletionStreamRequestMessage[] { - const result: ChatCompletionStreamRequestMessage[] = []; +function toChatMessages(messages: Message[], supportsImages: boolean): MistralChatMessage[] { + const result: MistralChatMessage[] = []; for (const msg of messages) { if (msg.role === "user") { @@ -533,7 +786,7 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl continue; } const hadImages = msg.content.some((item) => item.type === "image"); - const content: ContentChunk[] = msg.content + const content: MistralContentChunk[] = msg.content .filter((item) => item.type === "text" || supportsImages) .map((item) => { if (item.type === "text") return { type: "text", text: sanitizeSurrogates(item.text) }; @@ -550,8 +803,8 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl } if (msg.role === "assistant") { - const contentParts: ContentChunk[] = []; - const toolCalls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = []; + const contentParts: MistralContentChunk[] = []; + const toolCalls: MistralRequestToolCall[] = []; for (const block of msg.content) { if (block.type === "text") { @@ -573,17 +826,18 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) }, + index: 0, }); } - const assistantMessage: ChatCompletionStreamRequestMessage = { role: "assistant" }; + const assistantMessage: MistralChatMessage = { role: "assistant", prefix: false }; if (contentParts.length > 0) assistantMessage.content = contentParts; if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls; if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage); continue; } - const toolContent: ContentChunk[] = []; + const toolContent: MistralContentChunk[] = []; const textResult = msg.content .filter((part) => part.type === "text") .map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : "")) @@ -651,7 +905,7 @@ function mapToolChoice( ): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined { if (!choice) return undefined; if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") { - return choice as any; + return choice; } return { type: "function", diff --git a/packages/ai/test/lazy-module-load.test.ts b/packages/ai/test/lazy-module-load.test.ts index dd5169625cf..cb0875c9ee7 100644 --- a/packages/ai/test/lazy-module-load.test.ts +++ b/packages/ai/test/lazy-module-load.test.ts @@ -8,13 +8,7 @@ const aiEntryUrl = new URL("../src/index.ts", import.meta.url).href; const compatEntryUrl = new URL("../src/compat.ts", import.meta.url).href; const providersAllUrl = new URL("../src/providers/all.ts", import.meta.url).href; -const SDK_SPECIFIERS = [ - "@anthropic-ai/sdk", - "openai", - "@google/genai", - "@mistralai/mistralai", - "@aws-sdk/client-bedrock-runtime", -] as const; +const SDK_SPECIFIERS = ["@anthropic-ai/sdk", "openai", "@google/genai", "@aws-sdk/client-bedrock-runtime"] as const; type ProbeResult = { loadedSpecifiers: string[]; diff --git a/packages/ai/test/mistral-http-transport.test.ts b/packages/ai/test/mistral-http-transport.test.ts new file mode 100644 index 00000000000..dde2d111ec7 --- /dev/null +++ b/packages/ai/test/mistral-http-transport.test.ts @@ -0,0 +1,427 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { stream as streamMistral } from "../src/api/mistral-conversations.ts"; +import { getModel } from "../src/compat.ts"; +import type { Context, FetchFunction, ProviderResponse } from "../src/types.ts"; + +function createSseResponse(events: unknown[], headers?: Record): Response { + const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`; + return new Response(body, { + headers: { "content-type": "text/event-stream", ...headers }, + }); +} + +function createBytewiseSseResponse(event: unknown): Response { + const bytes = new TextEncoder().encode(`data: ${JSON.stringify(event)}\r\n\r\ndata: [DONE]\r\n\r\n`); + return new Response( + new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); + controller.close(); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); +} + +function createTerminalEvent(finishReason = "stop") { + return { + id: "mistral-response-id", + model: "mistral-large-latest", + choices: [{ index: 0, finish_reason: finishReason, delta: {} }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; +} + +describe("Mistral HTTP transport", () => { + it("serializes SDK-style payloads to the Mistral wire format", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + systemPrompt: "Be precise", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + timestamp: 1, + }, + ], + tools: [ + { + name: "lookup", + description: "Look something up", + parameters: Type.Object({ query: Type.String() }), + }, + ], + }; + let requestUrl: string | undefined; + let requestInit: RequestInit | undefined; + let callbackPayload: Record | undefined; + let callbackResponse: ProviderResponse | undefined; + const fetch: FetchFunction = async (input, init) => { + requestUrl = String(input); + requestInit = init; + return createSseResponse([createTerminalEvent()], { "x-request-id": "request-1" }); + }; + + const message = await streamMistral(model, context, { + apiKey: "secret", + fetch, + headers: { "x-custom": "value" }, + maxTokens: 123, + promptMode: "reasoning", + reasoningEffort: "high", + toolChoice: { type: "function", function: { name: "lookup" } }, + sessionId: "session-1", + onPayload: (payload) => { + callbackPayload = payload as Record; + return { + ...callbackPayload, + topP: 0.9, + randomSeed: 42, + responseFormat: { + type: "json_schema", + jsonSchema: { + name: "result", + schemaDefinition: { + type: "object", + properties: { maxTokens: { type: "number" } }, + }, + }, + }, + presencePenalty: 0.1, + frequencyPenalty: 0.2, + parallelToolCalls: true, + safePrompt: true, + }; + }, + onResponse: (response) => { + callbackResponse = response; + }, + }).result(); + + expect(message.stopReason).toBe("stop"); + expect(requestUrl).toBe("https://api.mistral.ai/v1/chat/completions"); + const headers = new Headers(requestInit?.headers); + expect(headers.get("authorization")).toBe("Bearer secret"); + expect(headers.get("accept")).toBe("text/event-stream"); + expect(headers.get("x-affinity")).toBe("session-1"); + expect(headers.get("x-custom")).toBe("value"); + expect(callbackPayload?.maxTokens).toBe(123); + expect(callbackPayload?.promptMode).toBe("reasoning"); + expect(callbackPayload?.promptCacheKey).toBe("session-1"); + expect(callbackResponse).toEqual({ + status: 200, + headers: { "content-type": "text/event-stream", "x-request-id": "request-1" }, + }); + + const wirePayload = JSON.parse(String(requestInit?.body)) as Record; + expect(wirePayload.max_tokens).toBe(123); + expect(wirePayload.prompt_mode).toBe("reasoning"); + expect(wirePayload.reasoning_effort).toBe("high"); + expect(wirePayload.tool_choice).toEqual({ type: "function", function: { name: "lookup" } }); + expect(wirePayload.prompt_cache_key).toBe("session-1"); + expect(wirePayload.top_p).toBe(0.9); + expect(wirePayload.random_seed).toBe(42); + expect(wirePayload.presence_penalty).toBe(0.1); + expect(wirePayload.frequency_penalty).toBe(0.2); + expect(wirePayload.parallel_tool_calls).toBe(true); + expect(wirePayload.safe_prompt).toBe(true); + expect(wirePayload.response_format).toEqual({ + type: "json_schema", + json_schema: { + name: "result", + schema: { + type: "object", + properties: { maxTokens: { type: "number" } }, + }, + }, + }); + expect(wirePayload).not.toHaveProperty("maxTokens"); + expect(wirePayload).not.toHaveProperty("promptMode"); + expect(wirePayload).not.toHaveProperty("promptCacheKey"); + expect(wirePayload.messages).toEqual([ + { role: "system", content: "Be precise" }, + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image_url", image_url: "data:image/png;base64,aGVsbG8=" }, + ], + }, + ]); + }); + + it("serializes assistant thinking, tool calls, and tool results for replay", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [ + { + role: "assistant", + api: "mistral-conversations", + provider: "mistral", + model: model.id, + content: [ + { type: "thinking", thinking: "reason" }, + { type: "text", text: "answer" }, + { type: "toolCall", id: "abc123456", name: "lookup", arguments: { query: "pi" } }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "abc123456", + toolName: "lookup", + content: [ + { type: "text", text: "found" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + isError: false, + timestamp: 2, + }, + ], + }; + let requestInit: RequestInit | undefined; + const fetch: FetchFunction = async (_input, init) => { + requestInit = init; + return createSseResponse([createTerminalEvent()]); + }; + + const message = await streamMistral(model, context, { apiKey: "test", fetch }).result(); + + expect(message.stopReason).toBe("stop"); + const wirePayload = JSON.parse(String(requestInit?.body)) as { messages: unknown[] }; + expect(wirePayload.messages).toEqual([ + { + role: "assistant", + prefix: false, + content: [ + { type: "thinking", thinking: [{ type: "text", text: "reason" }] }, + { type: "text", text: "answer" }, + ], + tool_calls: [ + { + id: "abc123456", + type: "function", + function: { name: "lookup", arguments: '{"query":"pi"}' }, + index: 0, + }, + ], + }, + { + role: "tool", + tool_call_id: "abc123456", + name: "lookup", + content: [ + { type: "text", text: "found" }, + { type: "image_url", image_url: "data:image/png;base64,aGVsbG8=" }, + ], + }, + ]); + }); + + it("parses native thinking, text, tool calls, and cached-token usage", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const events = [ + { + id: "response-1", + model: model.id, + choices: [ + { + index: 0, + finish_reason: null, + delta: { content: [{ type: "thinking", thinking: [{ type: "text", text: "reason" }] }] }, + }, + ], + }, + { + id: "response-1", + model: model.id, + choices: [ + { + index: 0, + finish_reason: null, + delta: { content: [{ type: "text", text: "answer" }] }, + }, + ], + }, + { + id: "response-1", + model: model.id, + choices: [ + { + index: 0, + finish_reason: null, + delta: { + tool_calls: [ + { + id: "abc123456", + index: 0, + function: { name: "lookup", arguments: '{"query":' }, + }, + ], + }, + }, + ], + }, + { + id: "response-1", + model: model.id, + choices: [ + { + index: 0, + finish_reason: "tool_calls", + delta: { + tool_calls: [ + { + id: "abc123456", + index: 0, + function: { name: "lookup", arguments: '"pi"}' }, + }, + ], + }, + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 4, + total_tokens: 14, + prompt_tokens_details: { cached_tokens: 3 }, + }, + }, + ]; + const fetch: FetchFunction = async () => createSseResponse(events); + + const message = await streamMistral(model, context, { apiKey: "test", fetch }).result(); + + expect(message.stopReason).toBe("toolUse"); + expect(message.rawStopReason).toBe("tool_calls"); + expect(message.responseId).toBe("response-1"); + expect(message.content).toEqual([ + { type: "thinking", thinking: "reason" }, + { type: "text", text: "answer" }, + { type: "toolCall", id: "abc123456", name: "lookup", arguments: { query: "pi" } }, + ]); + expect(message.usage).toMatchObject({ input: 7, output: 4, cacheRead: 3, cacheWrite: 0, totalTokens: 14 }); + }); + + it("parses SSE and UTF-8 sequences split across transport chunks", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const fetch: FetchFunction = async () => + createBytewiseSseResponse({ + id: "response-bytewise", + model: model.id, + choices: [{ index: 0, finish_reason: "stop", delta: { content: "héllo 🌍" } }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }); + + const message = await streamMistral(model, context, { apiKey: "test", fetch }).result(); + + expect(message.stopReason).toBe("stop"); + expect(message.content).toEqual([{ type: "text", text: "héllo 🌍" }]); + }); + + it("honors case-insensitive header overrides and explicit affinity suppression", async () => { + const model = { + ...getModel("mistral", "mistral-large-latest"), + headers: { Authorization: "Bearer model-key", "X-Affinity": "model-affinity" }, + }; + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + let requestHeaders: Headers | undefined; + const fetch: FetchFunction = async (_input, init) => { + requestHeaders = new Headers(init?.headers); + return createSseResponse([createTerminalEvent()]); + }; + + await streamMistral(model, context, { + apiKey: "request-key", + fetch, + sessionId: "automatic-affinity", + headers: { authorization: null, "x-affinity": null }, + }).result(); + + expect(requestHeaders?.has("authorization")).toBe(false); + expect(requestHeaders?.has("x-affinity")).toBe(false); + }); + + it("aborts while waiting for an SSE chunk", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const controller = new AbortController(); + const fetch: FetchFunction = async () => + new Response( + new ReadableStream({ + start() {}, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + + const result = streamMistral(model, context, { + apiKey: "test", + fetch, + signal: controller.signal, + }).result(); + controller.abort(); + const message = await result; + + expect(message.stopReason).toBe("aborted"); + }); + + it("applies the request timeout while waiting for an SSE chunk", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const fetch: FetchFunction = async () => + new Response( + new ReadableStream({ + start() {}, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + + const message = await streamMistral(model, context, { + apiKey: "test", + fetch, + timeoutMs: 5, + }).result(); + + expect(message.stopReason).toBe("error"); + expect(message.errorMessage).toMatch(/timeout/i); + }); + + it("preserves HTTP status and response bodies in errors", async () => { + const model = getModel("mistral", "mistral-large-latest"); + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }; + const fetch: FetchFunction = async () => + new Response('{"message":"blocked by gateway"}', { status: 403, statusText: "Forbidden" }); + + const message = await streamMistral(model, context, { apiKey: "test", fetch }).result(); + + expect(message.stopReason).toBe("error"); + expect(message.errorMessage).toBe('Mistral API error (403): {"message":"blocked by gateway"}'); + }); +}); diff --git a/packages/ai/test/mistral-raw-stop-reason.test.ts b/packages/ai/test/mistral-raw-stop-reason.test.ts index 782e057a2c5..512d92c244e 100644 --- a/packages/ai/test/mistral-raw-stop-reason.test.ts +++ b/packages/ai/test/mistral-raw-stop-reason.test.ts @@ -1,54 +1,39 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mistralMock = vi.hoisted(() => ({ - finishReason: "stop" as string, -})); - -vi.mock("@mistralai/mistralai", () => { - class HTTPClient {} - - class Mistral { - chat = { - stream: async function* () { - yield { - data: { - id: "mistral-response-id", - choices: [ - { - finishReason: mistralMock.finishReason, - delta: {}, - }, - ], - usage: { - promptTokens: 1, - completionTokens: 0, - totalTokens: 1, - }, - }, - }; - }, - }; - } - - return { HTTPClient, Mistral }; -}); - +import { describe, expect, it } from "vitest"; import { stream as streamMistral } from "../src/api/mistral-conversations.ts"; import { getModel } from "../src/compat.ts"; -import type { Context } from "../src/types.ts"; +import type { Context, FetchFunction } from "../src/types.ts"; const model = getModel("mistral", "devstral-medium-latest"); const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; -describe("Mistral raw stop reasons", () => { - beforeEach(() => { - mistralMock.finishReason = "stop"; - }); +function createFetch(finishReason: string): FetchFunction { + return async () => + new Response( + `data: ${JSON.stringify({ + id: "mistral-response-id", + model: model.id, + choices: [ + { + index: 0, + finish_reason: finishReason, + delta: {}, + }, + ], + usage: { + prompt_tokens: 1, + completion_tokens: 0, + total_tokens: 1, + }, + })}\n\ndata: [DONE]\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ); +} +describe("Mistral raw stop reasons", () => { it("preserves raw Mistral finish reasons for successful stops", async () => { - const message = await streamMistral(model, context, { apiKey: "test" }).result(); + const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("stop") }).result(); expect(message.stopReason).toBe("stop"); expect(message.rawStopReason).toBe("stop"); @@ -56,9 +41,7 @@ describe("Mistral raw stop reasons", () => { }); it("preserves raw Mistral finish reasons for provider error stops", async () => { - mistralMock.finishReason = "error"; - - const message = await streamMistral(model, context, { apiKey: "test" }).result(); + const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("error") }).result(); expect(message.stopReason).toBe("error"); expect(message.rawStopReason).toBe("error"); @@ -66,9 +49,10 @@ describe("Mistral raw stop reasons", () => { }); it("treats unknown Mistral finish reasons as provider error stops", async () => { - mistralMock.finishReason = "unmapped_error"; - - const message = await streamMistral(model, context, { apiKey: "test" }).result(); + const message = await streamMistral(model, context, { + apiKey: "test", + fetch: createFetch("unmapped_error"), + }).result(); expect(message.stopReason).toBe("error"); expect(message.rawStopReason).toBe("unmapped_error"); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index cbfb5560638..d3e7a02119b 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -988,13 +988,13 @@ describe("Generate E2E Tests", () => { }); it("should handle thinking mode", { retry: 3 }, async () => { - const llm = getModel("mistral", "magistral-medium-latest"); - await handleThinking(llm, { promptMode: "reasoning" }); + const llm = getModel("mistral", "mistral-small-2603"); + await handleThinking(llm, { reasoningEffort: "high" }); }); it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - const llm = getModel("mistral", "magistral-medium-latest"); - await multiTurn(llm, { promptMode: "reasoning" }); + const llm = getModel("mistral", "mistral-small-2603"); + await multiTurn(llm, { reasoningEffort: "high" }); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1bf3b77b055..12e4349d4e8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. +### Changed + +- Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. + ## [0.84.1] - 2026-08-07 ### New Features diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 027aa6115c6..858b992eff6 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -227,7 +227,7 @@ The `api` field determines which streaming implementation is used: | `openai-responses` | OpenAI Responses API | | `azure-openai-responses` | Azure OpenAI Responses API | | `openai-codex-responses` | OpenAI Codex Responses API | -| `mistral-conversations` | Mistral SDK Conversations/Chat streaming | +| `mistral-conversations` | Native Mistral Chat Completions streaming | | `google-generative-ai` | Google Generative AI API | | `google-vertex` | Google Vertex AI API | | `bedrock-converse-stream` | Amazon Bedrock Converse API | diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 321dd5ec3f7..d55a533d66c 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -474,7 +474,6 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", @@ -772,26 +771,6 @@ ], "optional": true }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.40.0", - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -813,15 +792,6 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1853,24 +1823,6 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index bd313840ed3..bb64c682ddf 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -501,7 +501,6 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", @@ -762,26 +761,6 @@ ], "optional": true }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.40.0", - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -803,15 +782,6 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1843,24 +1813,6 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } From 18dee5f0a89f41466e876cbbbfe77635cd250882 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 8 Aug 2026 12:06:16 +0200 Subject: [PATCH 057/284] fix(tui): stop recompositing full-width rows in the alt-screen painter paintBox ran every visible text row through compositeTuiLine on every frame, rebuilding each row string via ANSI/grapheme segmentation even when nothing changed. Full-width boxes painting onto untouched rows now assign the source line reference directly; padding is unnecessary since rows are written with erase-line and the final width clamp still truncates over-wide lines. Cuts alt-screen per-frame allocation churn 9-18x (test/render-churn-bench.ts) and removes the fullscreen RSS penalty over regular mode on short sessions (previously +11-29 MiB loaded, +26-36 MiB peak). --- packages/tui/CHANGELOG.md | 4 + packages/tui/src/layout.ts | 12 +- packages/tui/test/render-churn-bench.ts | 203 +++++++++++++++++++++++ packages/tui/test/tui-alt-screen.test.ts | 6 +- 4 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 packages/tui/test/render-churn-bench.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 96c181613e1..1b30480b785 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Reduced alternate-screen per-frame allocation churn roughly 9-18x by painting full-width layout rows as direct line references instead of recompositing every visible row through ANSI/grapheme segmentation on each frame. + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index a139a2fff81..51f738ecded 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -316,8 +316,16 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { const visibleRows = Math.min(imageMetadata.rows, clipBottom - row); if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows); } - if (isImageLine(line) && box.rect.x === 0 && box.rect.width >= totalWidth) screen[row] = line; - else screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + // Fast path: a full-width box painting onto an untouched row can use the + // source line reference directly. Compositing here would rebuild the row + // string through ANSI/grapheme segmentation every frame; padding is + // unnecessary because rows are written with erase-line and the final + // width clamp still truncates over-wide lines. + if (box.rect.x === 0 && box.rect.width >= totalWidth && (isImageLine(line) || !screen[row])) { + screen[row] = line; + } else { + screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + } } } for (const child of box.children) paintBox(child, screen, totalWidth); diff --git a/packages/tui/test/render-churn-bench.ts b/packages/tui/test/render-churn-bench.ts new file mode 100644 index 00000000000..103c9561bf7 --- /dev/null +++ b/packages/tui/test/render-churn-bench.ts @@ -0,0 +1,203 @@ +/** + * Alt-screen render churn benchmark. + * + * Measures cumulative JS allocation and wall time for repeated TuiAltScreen + * frames on a layout mirroring pi's fullscreen interactive mode: + * VStack [ ScrollView(transcript), dock VStack [status, editor, footer] ]. + * + * Two scenarios: + * - static: nothing changes between frames (pure recomposite churn) + * - editor: one character appended to the editor per frame (doc scenario + * "30 editor updates") + * + * Allocation is estimated with the V8 sampling heap profiler including + * objects collected by minor/major GC, i.e. it measures churn, not retention. + * + * Run from packages/tui: node test/render-churn-bench.ts + */ + +import { Session } from "node:inspector/promises"; +import { performance } from "node:perf_hooks"; +import { ScrollView } from "../src/components/scroll-view.ts"; +import { Text } from "../src/components/text.ts"; +import { VStack } from "../src/components/v-stack.ts"; +import type { Terminal } from "../src/terminal.ts"; +import { type Component, Container, CURSOR_MARKER } from "../src/tui.ts"; +import { TuiAltScreen } from "../src/tui-alt-screen.ts"; + +const COLUMNS = 100; +const ROWS = 30; +const WARMUP_FRAMES = 20; +const FRAMES = 300; +const SAMPLING_INTERVAL = 4096; + +/** Terminal that discards output; keeps xterm parsing out of the measurement. */ +class NullTerminal implements Terminal { + bytesWritten = 0; + start(_onInput: (data: string) => void, _onResize: () => void): void {} + stop(): void {} + async drainInput(): Promise {} + write(data: string): void { + this.bytesWritten += data.length; + } + get columns(): number { + return COLUMNS; + } + get rows(): number { + return ROWS; + } + get kittyProtocolActive(): boolean { + return false; + } + moveBy(_lines: number): void {} + hideCursor(): void {} + showCursor(): void {} + clearLine(): void {} + clearFromCursor(): void {} + clearScreen(): void {} + setTitle(_title: string): void {} + setProgress(_active: boolean): void {} +} + +/** Editor stand-in: caches lines per (text, width), re-renders when text changes. */ +class EditorSim implements Component { + private text = ""; + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + append(char: string): void { + this.text += char; + } + + invalidate(): void { + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + const border = `\x1b[90m${"─".repeat(Math.max(1, width - 2))}\x1b[39m`; + const lines = [border, ` > ${this.text}${CURSOR_MARKER}`, border]; + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } +} + +function buildTranscript(): Container { + const container = new Container(); + for (let i = 0; i < 150; i++) { + const styled = + i % 3 === 0 + ? `\x1b[1m\x1b[36muser ${i}\x1b[39m\x1b[22m message with some \x1b[33mstyled\x1b[39m content padding padding` + : `assistant ${i} plain response line with enough text to be representative of a transcript row`; + container.addChild(new Text(styled, 1, 0)); + } + return container; +} + +interface SamplingNode { + selfSize: number; + children: SamplingNode[]; +} + +function sumProfile(node: SamplingNode): number { + let total = node.selfSize; + for (const child of node.children) total += sumProfile(child); + return total; +} + +interface ScenarioResult { + allocatedBytes: number; + elapsedMs: number; + bytesWritten: number; +} + +async function runScenario( + session: Session, + terminal: NullTerminal, + tui: TuiAltScreen, + frame: (index: number) => void, +): Promise { + const writtenBefore = terminal.bytesWritten; + await session.post("HeapProfiler.startSampling", { + samplingInterval: SAMPLING_INTERVAL, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + const start = performance.now(); + for (let i = 0; i < FRAMES; i++) { + frame(i); + tui.renderNow(); + } + const elapsedMs = performance.now() - start; + const { profile } = await session.post("HeapProfiler.stopSampling"); + return { + allocatedBytes: sumProfile(profile.head as SamplingNode), + elapsedMs, + bytesWritten: terminal.bytesWritten - writtenBefore, + }; +} + +function report(name: string, result: ScenarioResult): void { + const perFrameKiB = result.allocatedBytes / FRAMES / 1024; + const totalMiB = result.allocatedBytes / 1024 / 1024; + const msPerFrame = result.elapsedMs / FRAMES; + console.log( + `${name.padEnd(8)} allocated ${totalMiB.toFixed(1).padStart(7)} MiB total ` + + `${perFrameKiB.toFixed(1).padStart(8)} KiB/frame ` + + `${msPerFrame.toFixed(3).padStart(7)} ms/frame ` + + `${(result.bytesWritten / FRAMES).toFixed(0).padStart(6)} written bytes/frame`, + ); +} + +async function main(): Promise { + const terminal = new NullTerminal(); + const tui = new TuiAltScreen(terminal, false, "/tmp/pi-tui-bench"); + + const transcript = buildTranscript(); + const editor = new EditorSim(); + const scrollView = new ScrollView(transcript, { + follow: "end", + primary: true, + overscroll: "chain", + scrollbar: "auto", + }); + const status = new Text("\x1b[2mstatus: idle\x1b[22m", 1, 0); + const footer = new Text("\x1b[2m~/workspaces/pi main 100k tokens\x1b[22m", 1, 0); + const dock = new VStack([ + { component: status, shrink: 1, minSize: 0 }, + { component: editor, shrink: 1, minSize: 3 }, + { component: footer, shrink: 1, minSize: 1 }, + ]); + const root = new VStack([ + { component: scrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 }, + { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, + ]); + tui.setLayoutRoot(root); + tui.start(); + + for (let i = 0; i < WARMUP_FRAMES; i++) tui.renderNow(); + + const session = new Session(); + session.connect(); + + const staticResult = await runScenario(session, terminal, tui, () => {}); + const editorResult = await runScenario(session, terminal, tui, (i) => { + editor.append(String.fromCharCode(97 + (i % 26))); + }); + + session.disconnect(); + tui.stop(); + + console.log(`frames=${FRAMES} viewport=${COLUMNS}x${ROWS} transcript=${transcript.render(COLUMNS).length} lines`); + report("static", staticResult); + report("editor", editorResult); +} + +await main(); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index dc84f2fd5f6..d3d9df32f83 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -792,7 +792,7 @@ describe("TuiAltScreen", () => { it("selects visible text with the mouse and copies it with OSC 52", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); - tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.addChild(new Text("\x1b[1mal\x1b[0mpha\nbeta\ngamma\ndelta", 0, 0)); tui.start(); await terminal.waitForRender(); @@ -811,8 +811,8 @@ describe("TuiAltScreen", () => { ); assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m"))); assert.ok( - terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m\x1b[0m\x1b[7m")), - "selection inverse must be reapplied after layout segment resets", + terminal.events.some((event) => event.type === "write" && event.data.includes("al\x1b[0m\x1b[7mpha")), + "selection inverse must be reapplied after a reset inside the selection", ); assert.ok(terminal.getViewport().some((line) => line.includes("Copied!"))); From f8746767bc4627372e4c560ba7e28546a759f5ce Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 8 Aug 2026 12:06:19 +0200 Subject: [PATCH 058/284] fix: handle npm 12 pack --json object output in local release script npm 12 returns an object keyed by package name instead of an array, which broke packPackage with a TypeError. --- scripts/local-release.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 569a5d7ce94..739bffd1570 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -195,7 +195,9 @@ function packPackage(pkg, tarballDirectory) { capture: true, cwd: pkg.directory, }); - const packed = JSON.parse(output)[0]; + // npm <11.6 returns an array; newer npm returns an object keyed by package name. + const parsed = JSON.parse(output); + const packed = Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]; return join(tarballDirectory, packed.filename); } From 5ac9133655c78b600862eb22fc9a0e8be0cd66cc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 8 Aug 2026 12:10:13 +0200 Subject: [PATCH 059/284] fix: refresh nanoid to 3.3.17 in root lockfile --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b0f2e629190..ff7ce754b0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4015,9 +4015,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { From 6809fabbb4c7e17c256e850b3ea38ebf82613a54 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Sat, 8 Aug 2026 12:10:09 +0200 Subject: [PATCH 060/284] docs(agent): allow clearing session names --- packages/agent/docs/harness-v2.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 7c20fb70485..63e8135056d 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -1218,7 +1218,7 @@ Guarantees: // or message_end follows with the same id { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] } { type: "fact_update" } & ( - | { fact: "name"; name: string } + | { fact: "name"; name: string | undefined } | { fact: "label"; targetId: string; label: string | undefined }) // Configuration. Compact payloads; clients re-read via getters. @@ -1485,7 +1485,7 @@ interface SessionTree { // Global facts. Latest wins; not branch-scoped. "set", not "append": // append vocabulary is reserved for tree writes. getName(): Promise; - setName(name: string): Promise; + setName(name: string | undefined): Promise; getLabel(targetId: string): Promise; setLabel(targetId: string, label: string | undefined): Promise; @@ -1618,7 +1618,7 @@ interface SessionStorage { getLog(options?): Promise; // Global facts - getName(): Promise; setName(name: string): Promise; + getName(): Promise; setName(name: string | undefined): Promise; getLabel(id: string): Promise; setLabel(id, label): Promise; getStats(): Promise; } From 368e013dec766724d892cfa5cc247d2f2bee8795 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Sat, 8 Aug 2026 12:22:15 +0200 Subject: [PATCH 061/284] chore(docs): fix ascii alignment in compaction docs --- packages/coding-agent/docs/compaction.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index de9d02d470d..788348eeac7 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -1,6 +1,6 @@ # Compaction & Branch Summarization -LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization. +LLMs have limited context windows. When conversations grow too long, Pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization. **Source files** ([pi-mono](https://github.com/earendil-works/pi-mono)): - [`packages/coding-agent/src/core/compaction/compaction.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) - Auto-compaction logic @@ -48,7 +48,7 @@ You can also trigger manually with `/compact [instructions]`, where optional ins Before compaction: entry: 0 1 2 3 4 5 6 7 8 9 - ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐ + ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┬─────┐ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘ └────────┬───────┘ └──────────────┬──────────────┘ @@ -59,7 +59,7 @@ Before compaction: After compaction (new entry appended): entry: 0 1 2 3 4 5 6 7 8 9 10 - ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐ + ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┬─────┬─────┐ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘ └──────────┬──────┘ └──────────────────────┬───────────────────┘ @@ -102,7 +102,7 @@ Split turn (one huge turn exceeds budget): turnPrefixMessages = [usr, ass, tool, ass, tool, tool] ``` -For split turns, pi generates two summaries and merges them: +For split turns, Pi generates two summaries and merges them: 1. **History summary**: Previous context (if any) 2. **Turn prefix summary**: The early part of the split turn @@ -149,7 +149,7 @@ See [`prepareCompaction()`](https://github.com/earendil-works/pi-mono/blob/main/ ### When It Triggers -When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch. +When you use `/tree` to navigate to a different branch, Pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch. ### How It Works From c185d412382581860a489b4959737bad1d119492 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Sat, 8 Aug 2026 18:42:07 +0200 Subject: [PATCH 062/284] fix(ai): send max_tokens to DeepSeek APIs Auto-detect DeepSeek in generated and runtime compatibility metadata so built-in and custom models use the supported token limit field. --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 11 ++++- packages/ai/src/api/openai-completions.ts | 3 +- .../openai-completions-tool-choice.test.ts | 41 +++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 638507227ad..9e1b7a98515 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed upstream request buffer limit failures to trigger automatic assistant retries. - Fixed OpenAI Responses function and custom tool calls to preserve namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). +- Fixed built-in and custom DeepSeek API models to send output limits through the supported `max_tokens` field. ## [0.84.1] - 2026-08-07 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 4c0d58e6c52..62be5ac9b91 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -643,11 +643,18 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open isCloudflareAiGateway || isAntLing; + const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const useMaxTokens = - baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia || isAntLing || isZai; + baseUrl.includes("chutes.ai") || + isDeepSeek || + isMoonshot || + isCloudflareAiGateway || + isTogether || + isNvidia || + isAntLing || + isZai; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); - const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const isOpenRouterDeveloperRoleModel = isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); const cacheControlFormat = diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 20da4e05deb..248d7356290 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1475,8 +1475,10 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet isCloudflareAiGateway || isAntLing; + const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const useMaxTokens = baseUrl.includes("chutes.ai") || + isDeepSeek || isMoonshot || isCloudflareAiGateway || isTogether || @@ -1485,7 +1487,6 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet isZai; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); - const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const isOpenRouterDeveloperRoleModel = isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 3837799636d..a2a47b7ca67 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1429,6 +1429,47 @@ describe("openai-completions tool_choice", () => { } }); + it("sends max_tokens for built-in and custom DeepSeek API models", async () => { + const customModel = { + ...localOpenAICompletionsModel, + id: "custom-deepseek-model", + name: "Custom DeepSeek Model", + provider: "custom-deepseek", + baseUrl: "https://api.deepseek.com", + } satisfies Model<"openai-completions">; + const nativeModels = [ + getModel("deepseek", "deepseek-v4-flash")!, + getModel("deepseek", "deepseek-v4-pro")!, + ] as const; + const cases = [...nativeModels, customModel] as const; + + for (const model of nativeModels) { + expect(model.compat?.maxTokensField).toBe("max_tokens"); + } + + for (const model of cases) { + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + maxTokens: 123, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { max_tokens?: number; max_completion_tokens?: number }; + expect(params.max_tokens).toBe(123); + expect(params.max_completion_tokens).toBeUndefined(); + } + }); + it("sends max_tokens for Z.AI completions models", async () => { const cases = [getModel("zai", "glm-5-turbo")!, getModel("zai", "glm-5.2")!] as const; From 7bdb16c28d794a5ff8e7485479c8e37eccd9a8d8 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Sat, 8 Aug 2026 19:19:17 +0200 Subject: [PATCH 063/284] feat(agent): support clearing session names --- .../agent/src/harness/session/jsonl/codec.ts | 5 +++- .../src/harness/session/jsonl/storage.ts | 2 +- packages/agent/src/harness/session/memory.ts | 2 +- packages/agent/src/harness/session/session.ts | 2 +- packages/agent/src/harness/session/state.ts | 2 +- .../harness/session/testing/conformance.ts | 23 +++++++++++++++++++ packages/agent/src/harness/session/types.ts | 6 ++--- .../test/harness/session/jsonl-codec.test.ts | 5 ++-- .../sqlite-node/src/sqlite/repo.ts | 6 ++--- .../src/sqlite/storage/sessions.ts | 6 ++--- .../sqlite-node/test/repository.test.ts | 21 ++++++++++++++++- .../sqlite-node/test/search.test.ts | 21 ++++++----------- 12 files changed, 69 insertions(+), 32 deletions(-) diff --git a/packages/agent/src/harness/session/jsonl/codec.ts b/packages/agent/src/harness/session/jsonl/codec.ts index c2f970fef68..84dbeeda068 100644 --- a/packages/agent/src/harness/session/jsonl/codec.ts +++ b/packages/agent/src/harness/session/jsonl/codec.ts @@ -180,7 +180,10 @@ function parseLaneMutation(value: Record, seq: number): Extract function parseFactMutation(value: Record, seq: number): Extract { if (value.fact === "name") { - return { kind: "fact", seq, fact: "name", name: requireString(value.name, "name") }; + if (value.name !== undefined && typeof value.name !== "string") { + throw new JsonlDecodeError("schema", "has invalid name"); + } + return { kind: "fact", seq, fact: "name", name: value.name }; } if (value.fact === "label") { if (value.label !== undefined && typeof value.label !== "string") { diff --git a/packages/agent/src/harness/session/jsonl/storage.ts b/packages/agent/src/harness/session/jsonl/storage.ts index 92f758dd2ae..3d76cf92e61 100644 --- a/packages/agent/src/harness/session/jsonl/storage.ts +++ b/packages/agent/src/harness/session/jsonl/storage.ts @@ -224,7 +224,7 @@ export class JsonlSessionStorage implements SessionStorage return this.state.getName(); } - setName(name: string): Promise { + setName(name: string | undefined): Promise { return this.enqueue(async () => { const mutation: SessionMutation = { kind: "fact", seq: this.state.nextSequence, fact: "name", name }; await this.appendMutation(mutation); diff --git a/packages/agent/src/harness/session/memory.ts b/packages/agent/src/harness/session/memory.ts index 598d8dcf4fc..cccbd82096c 100644 --- a/packages/agent/src/harness/session/memory.ts +++ b/packages/agent/src/harness/session/memory.ts @@ -121,7 +121,7 @@ export class InMemorySessionStorage implements SessionStorage { return this.state.getName(); } - async setName(name: string): Promise { + async setName(name: string | undefined): Promise { this.state.applyMutation({ kind: "fact", seq: this.state.nextSequence, fact: "name", name }); } diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index f4be8adfacd..8027475dd74 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -147,7 +147,7 @@ export class Session implem return this.storage.getName(); } - async setName(name: string): Promise { + async setName(name: string | undefined): Promise { await this.storage.setName(name); } diff --git a/packages/agent/src/harness/session/state.ts b/packages/agent/src/harness/session/state.ts index edc16f85b63..c63bd5b93e0 100644 --- a/packages/agent/src/harness/session/state.ts +++ b/packages/agent/src/harness/session/state.ts @@ -18,7 +18,7 @@ export type SessionMutation = | { kind: "entry"; lane?: string; entry: Entry } | { kind: "record"; record: LaneRecord } | { kind: "lane"; seq: number; lane: string; leafId: string | null } - | { kind: "fact"; seq: number; fact: "name"; name: string } + | { kind: "fact"; seq: number; fact: "name"; name: string | undefined } | { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined }; type InvalidMutation = (message: string) => never; diff --git a/packages/agent/src/harness/session/testing/conformance.ts b/packages/agent/src/harness/session/testing/conformance.ts index be33b221308..9adc10bb947 100644 --- a/packages/agent/src/harness/session/testing/conformance.ts +++ b/packages/agent/src/harness/session/testing/conformance.ts @@ -640,6 +640,29 @@ export function createSessionBackendConformance( }, ), + createCase(factory, "queries and facts", "clears session names durably", async (repository) => { + const session = await repository.create({ id: "session" }); + await session.setName("Temporary"); + await session.setName(undefined); + + strictEqual(await session.getName(), undefined); + deepStrictEqual(await session.getLog(), [ + { kind: "fact", seq: 1, fact: "name", name: "Temporary" }, + { kind: "fact", seq: 2, fact: "name", name: undefined }, + ]); + + const metadata = await session.getMetadata(); + const reopened = await repository.open(metadata); + strictEqual(await reopened.getName(), undefined); + deepStrictEqual(await reopened.getLog(), [ + { kind: "fact", seq: 1, fact: "name", name: "Temporary" }, + { kind: "fact", seq: 2, fact: "name", name: undefined }, + ]); + + const fork = await repository.fork(metadata, { id: "fork" }); + strictEqual(await fork.getName(), undefined); + }), + createCase(factory, "validation and immutability", "returns immutable copies from reads", async (repository) => { const session = await repository.create({ id: "immutable" }); const metadata = await session.getMetadata(); diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 5df425f1574..1164698ff26 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -279,7 +279,7 @@ export type LogItem = | { kind: "entry"; seq: number; entry: Entry } | { kind: "record"; seq: number; record: LaneRecord } | { kind: "lane"; seq: number; lane: string; leafId: string | null } - | { kind: "fact"; seq: number; fact: "name"; name: string } + | { kind: "fact"; seq: number; fact: "name"; name: string | undefined } | { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined }; export interface LogOptions { @@ -319,7 +319,7 @@ export interface SessionStorage; - setName(name: string): Promise; + setName(name: string | undefined): Promise; getLabel(id: string): Promise; setLabel(id: string, label: string | undefined): Promise; getStats(): Promise; @@ -333,7 +333,7 @@ export interface SessionTree { // Global facts. Latest wins; not branch-scoped. "set", not "append": // append vocabulary is reserved for tree writes. getName(): Promise; - setName(name: string): Promise; + setName(name: string | undefined): Promise; getLabel(targetId: string): Promise; setLabel(targetId: string, label: string | undefined): Promise; diff --git a/packages/agent/test/harness/session/jsonl-codec.test.ts b/packages/agent/test/harness/session/jsonl-codec.test.ts index 4f7621c2134..b8176755017 100644 --- a/packages/agent/test/harness/session/jsonl-codec.test.ts +++ b/packages/agent/test/harness/session/jsonl-codec.test.ts @@ -134,11 +134,12 @@ describe("JSONL v4 codec", () => { expectMutationRoundTrip({ kind: "lane", seq: 1, lane: "thread", leafId: "entry-1" }); }); - it("round trips both fact line discriminants", () => { + it("round trips fact lines, including cleared values", () => { expectMutationRoundTrip({ kind: "fact", seq: 1, fact: "name", name: "Example" }); + expectMutationRoundTrip({ kind: "fact", seq: 2, fact: "name", name: undefined }); expectMutationRoundTrip({ kind: "fact", - seq: 2, + seq: 3, fact: "label", targetId: "entry-1", label: "checkpoint", diff --git a/packages/session-backends/sqlite-node/src/sqlite/repo.ts b/packages/session-backends/sqlite-node/src/sqlite/repo.ts index da4b13c0125..8135d7e3271 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/repo.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/repo.ts @@ -606,7 +606,7 @@ class SqliteSessionStorage implements SessionStorage { kind: "fact" as const, seq: row.seq, fact: "name" as const, - name: JSON.parse(row.value ?? "null") as string, + name: row.value === null ? undefined : (JSON.parse(row.value) as string), }; return { kind: "fact" as const, @@ -627,10 +627,10 @@ class SqliteSessionStorage implements SessionStorage { return row?.value === undefined || row.value === null ? undefined : (JSON.parse(row.value) as string); } - async setName(name: string): Promise { + async setName(name: string | undefined): Promise { return this.enqueueWrite(() => { const seq = getNextSequence(this.db, this.metadata.id); - appendFact(this.db, this.metadata.id, seq, "name", null, JSON.stringify(name)); + appendFact(this.db, this.metadata.id, seq, "name", null, name === undefined ? null : JSON.stringify(name)); advanceSequence(this.db, this.metadata.id, seq); }); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts index e25e8a80919..f4c54ecf59c 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts @@ -98,10 +98,8 @@ export function deleteSessionRow(db: SqliteDatabase, sessionId: string) { sql`DELETE FROM sessions WHERE id = ${sessionId}`.run(db); } -function parseSessionName(value: string | null, sessionId: string): string { - if (value === null) { - throw new SessionError("storage", `Invalid SQLite session ${sessionId}: name must be a string`); - } +function parseSessionName(value: string | null, sessionId: string): string | undefined { + if (value === null) return undefined; let parsed: unknown; try { parsed = JSON.parse(value); diff --git a/packages/session-backends/sqlite-node/test/repository.test.ts b/packages/session-backends/sqlite-node/test/repository.test.ts index 633c5d3c23b..1d179c684ec 100644 --- a/packages/session-backends/sqlite-node/test/repository.test.ts +++ b/packages/session-backends/sqlite-node/test/repository.test.ts @@ -275,7 +275,6 @@ END; it.each([ ["invalid JSON", "not json", "name is not valid JSON"], ["a non-string value", "{}", "name must be a string"], - ["a NULL value", null, "name must be a string"], ])("rejects stored session names containing %s", async (_case, stored, message) => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); @@ -302,6 +301,26 @@ END; }); }); + it("omits a cleared session name from metadata", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + await using repo = new SqliteSessionRepository({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const session = await repo.create({ cwd: root, id: "session-1" }); + await session.setName("Temporary"); + expect(await session.getMetadata()).toMatchObject({ name: "Temporary" }); + + await session.setName(undefined); + + expect(await session.getName()).toBeUndefined(); + expect(await session.getMetadata()).not.toHaveProperty("name"); + expect((await repo.list())[0]).not.toHaveProperty("name"); + }); + it("fails loudly when a stored entry is read and cannot be decoded", async () => { const root = createTempDir(); const databasePath = join(root, "sessions.sqlite"); diff --git a/packages/session-backends/sqlite-node/test/search.test.ts b/packages/session-backends/sqlite-node/test/search.test.ts index 7917dd52929..f9f5cec4ccc 100644 --- a/packages/session-backends/sqlite-node/test/search.test.ts +++ b/packages/session-backends/sqlite-node/test/search.test.ts @@ -42,7 +42,7 @@ describe("SQLite FTS5 session search", () => { ]); }); - it("rejects a stored NULL session name", async () => { + it("omits a cleared session name from search metadata", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); @@ -50,20 +50,13 @@ describe("SQLite FTS5 session search", () => { await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository, search } = fixture; const session = await repository.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("Find the auth defect")); - await session.setName("valid name"); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("UPDATE facts SET value = NULL WHERE session_id = ? AND kind = 'name'").run("session-1"); - } finally { - await db.close(); - } + const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); + await session.setName("Temporary"); + await session.setName(undefined); - await expect(search.search({ text: "auth" })).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("name must be a string"), - }); + const [result] = await search.search({ text: "auth" }); + expect(result).toMatchObject({ entryId, metadata: { id: "session-1" } }); + expect(result?.metadata).not.toHaveProperty("name"); }); it("handles quoted search text without exposing FTS syntax", async () => { From 025957c258fea91653ee73ecffdc4a659ed60945 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 8 Aug 2026 19:21:22 +0200 Subject: [PATCH 064/284] docs(agent): reconcile durable harness design --- packages/agent/docs/harness-v2.md | 2796 ++++++++++++++++++++--------- 1 file changed, 1982 insertions(+), 814 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 63e8135056d..8a7a9bad23b 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -8,10 +8,10 @@ flowchart TD Harness -->|snapshots + events| App Harness -->|hooks + events| Ext[Extensions] Harness --> Lanes[Lanes: main, ...
one operation each, parallel] - Lanes --> Loop[Step primitives
request / tools] + Lanes --> Loop[Stable steps + attempts
requests / planned tools] Loop --> Provider[LLM provider] Loop --> Tools[Tools] - Harness --> Session[Session
tree · lanes · operation logs · global facts] + Harness --> Session[Session
tree · lanes · lane records · global facts] Session --> Storage[(memory / JSONL / SQLite)] Harness -.->|telemetry| Obs[Observability] ``` @@ -22,7 +22,8 @@ The harness executes runs against one session. The session holds four kinds of s ## 1. Goals -- **Durable runs.** An accepted prompt is a durable operation. After a crash, a new process restores the session. It resumes the run from the last safe boundary. Every state that a crash can produce is recoverable. +- **Durable runs.** An accepted prompt is a durable operation. After a crash, a new process reconstructs the operation from its records and resumes from the last durable boundary. Every state that a crash can produce is recoverable. +- **Durable responses.** Partial stream state is process-local, but every settled assistant-generation and deferred-fetch response is appended completely before the harness decides what it means. Retryable errors, overflow responses, deferred responses, and aborted responses are durable outcomes of their attempts. An `aborted` response means operation abort only when an earlier `abort_requested` won its append race; without that marker it is an ordinary interrupted provider response. - **Lanes.** A session hosts one or more lanes. A lane is a named position in the conversation tree. Each lane runs at most one operation at a time. Lanes run in parallel. A run and its queued messages belong to the lane that accepted them. Example: a Slack channel is a session; each thread is a lane. Interactive pi uses one lane and does not show the concept in its UI. Extensions get the full harness API, including lanes. Example: a subagent tool runs on a second lane of its parent's session. - **No partial outcomes.** A crash inside any operation — run, compaction, navigation — leaves one of two states: the operation has not happened, or recovery can complete it. Nothing in between is observable. - **Harness API.** Events observe execution and cannot change it. Hooks intercept execution and can change it: context, requests, tools, run boundaries. Extensions build on events and hooks. @@ -35,7 +36,7 @@ The harness executes runs against one session. The session holds four kinds of s ## Non-goals - **Exactly-once hook side effects.** A hook result becomes durable when the record or entry that consumes it commits. A crash before that commit can run the hook again (section 11 replay table). Side effects a hook makes on its own are invisible to the harness: HTTP calls, file writes. A hook that needs crash-safe external effects must be idempotent, for example keyed by operation id. -- **Provider stream resumption.** Partial streams are never persisted. An interrupted streaming request is retried or abandoned. Deferred requests are different and in scope: the provider returns a handle at once and serves the result later (e.g. `background: true` on a Responses API, batch APIs). pi-ai returns an assistant message with stop reason `deferred` that carries the handle; it is persisted like any assistant message. Redeeming the handle appends a normal assistant message. Recovery sees the unredeemed handle and fetches instead of paying for a new request. +- **Provider stream resumption.** Partial streams are never persisted or resumed. If a process dies while streaming, the durable attempt intent has no response, so recovery treats the provider effect as unknown and starts a later numbered attempt only when policy permits. If an abort marker exists, it instead settles that attempt once as synthetic `aborted` under the already-provisioned id and never repeats the provider effect. A stream that settles is different: its complete response is persisted before retry, overflow, or abort classification. Deferred requests are also different and are in scope: the provider returns a handle at once and serves the result later (e.g. `background: true` on a Responses API, batch APIs). pi-ai returns an assistant message with stop reason `deferred` that carries the handle; it is persisted like any assistant message. Redemption of that original response and every later pending response returned while polling it uses one stable deferred-fetch step. The step copies the original generation step's total configuration and normalized retry policy once; later polls need no repeated lookup of that generation step. Each `resume()` makes at most one numbered, check-once poll attempt and persists the returned assistant message, including another `deferred` message when the work is still pending. Recovery polls the newest persisted source entry instead of starting a replacement generation request. - **Multiple writers.** Two processes on one session are out of scope. The serving layer routes all traffic for a session to the process that holds its harness. Lanes cover the workloads that look like multi-writer: parallel threads over shared history. - **Replication.** A session lives in one place. Coordination-free sync of diverging copies is a different design. Nothing forecloses it later. - **Coding-agent migration.** Migrating coding-agent to `AgentHarness` is out of scope. Compatibility means the new JSONL repository can read supported coding-agent v3 files. @@ -44,37 +45,38 @@ The harness executes runs against one session. The session holds four kinds of s A session is durable state with four parts: -1. **The tree** — the conversation. Entries with `parentId` links: messages, model/thinking/tool-activation changes, compaction summaries, branch summaries, custom entries. The tree is shared and passive. It belongs to no lane. It only grows; entries are never changed or deleted. +1. **The tree** — the conversation. Entries with `parentId` links: messages, compaction summaries, branch summaries, custom entries. The tree is shared and passive. It belongs to no lane. It only grows; entries are never changed or deleted. 2. **Lanes** — where work happens. A lane is a name plus a leaf: the entry that future work extends. Every session has the lane `main`. Applications create more, keyed by external identity (a Slack thread id, an email thread id). -3. **Lane operation logs** — what happened and what must happen. One flat, chronological record sequence per lane: operation started, step attempted, tool started, message queued, operation finished. This is where durability is implemented: records exist so that a new process can continue a lane's work after a crash. Nothing reads them during normal execution. -4. **Global facts** — session-scoped values where the latest write wins: the session name, entry labels. Not part of the tree. Kept as append-only history; readers see the newest value. +3. **Lane records** — the lane's current total configuration plus what happened and what must happen. One flat, chronological record sequence per lane contains total `lane_config` replacements and operation records such as operation started, step started, attempt started, tool batch planned, message queued, and operation finished. Commits update live state; after a crash, the same records are the authority for reconstructing it. +4. **Global facts** — session-scoped values where the latest write wins: the built-in session name and entry labels, plus string-keyed application facts in a separate custom namespace. They are not part of the tree and are kept as append-only history. Setting a name, label, or custom fact to `undefined` appends a deletion; JSON `null` remains a custom-fact value. The built-in and custom namespaces never overlap. -All writes across the four parts share one monotonic sequence number. The sequence orders global-fact history and lets a lane's operation log refer to tree positions. +All writes across the four parts share one monotonic sequence number. The sequence orders global-fact history and lets a lane's records refer to tree positions. ```text tree (shared, append-only) lanes -a ── b ── c ── d main → d (op log: …) - └── e ── f slack:171943… → f (op log: …) +a ── b ── c ── d main → d (total config; op log: …) + └── e ── f slack:171943… → f (total config; op log: …) -global facts: name = "Refactor auth", label(b) = "checkpoint-1" +global facts: name = "Refactor auth", label(b) = "checkpoint-1", + custom("extension.example/state") = { "reviewed": true } ``` ### Active and passive The tree and the global facts are passive: shared data, readable by anything. -A lane is active. It owns its leaf, its operation log (at most one open operation), its queues, and its pending writes. Two lanes never share any of these. Every action of a lane produces entries chained to its leaf, or records in its own operation log. +A lane is active. It owns its leaf, current total configuration, operation log (at most one open operation), queues, and pending writes. Two lanes never share any of these. Every durable action of a lane produces entries chained to its leaf or records in its own record sequence. ### Invariants -- The tree is conversation only. No lane state, no orchestration state, no pointers live in it. +- The tree is conversation only. No lane configuration, orchestration state, or pointers live in it. - An entry's parent chain never changes. Branches share prefixes; nothing is copied. - A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that entry), or the lane navigates (leaf jumps to an existing entry). -- Operation-log records never affect the tree. Deleting every operation log leaves a complete, valid conversation. +- Configuration and operation records never enter the tree. Deleting every operation log leaves a complete, valid conversation. - At most one operation is open per lane. A state where one lane has two open operations is corruption. -- Entries are shared; records are not. Two lanes may have the same entry on their paths. A record belongs to exactly one lane. +- Entries are shared; lane records are not. Two lanes may have the same entry on their paths. A record belongs to exactly one lane. -Records are not tree entries because they describe execution, not conversation: they must never enter model context, transcripts, branch queries, or forks, and within one lane their order is already their meaning — parent links would add nothing. +Lane records describe configuration or execution, not conversation. They never enter model context, transcripts, or branch queries, and source records are not copied into forks. Within one lane their order is already their meaning, so parent links would add nothing. ## 3. Lanes @@ -87,13 +89,13 @@ A lane owns: - **Its leaf.** New entries chain to it and move it. Navigation jumps it. - **Its operation log.** At most one open operation. A second operation on a busy lane is rejected; other lanes are unaffected. - **Its queues.** Steering, follow-ups, and next-run messages target one lane. -- **Its configuration view.** Model, thinking level, and active tools are entries on the path behind the lane's leaf. Two lanes can run different models without knowing of each other. Tool implementations, resources, and stream options are harness-global; only their activation is per-lane. +- **Its total configuration.** One value contains the model reference, thinking level, and active tool names. A `lane_config` record always replaces the whole value; it is never a patch or a tree entry. `main` and every new lane start from the same immutable seed captured from `AgentHarnessOptions`, not from their anchor or another lane. A setter immediately appends a total replacement on the lane's mutation line, even while an operation is open, and that replacement survives abort. Every generation step snapshots the current total configuration when its `step_started` record commits; all attempts of that step use the snapshot. Tool implementations, resources, and stream options are harness-global; only tool activation is per-lane. Rules: - Lanes run operations in parallel. The harness stays the single writer; lane records and entries interleave in the shared sequence. -- Creating a lane copies nothing. Lanes are not deleted or renamed. -- State-dependent mutations on one lane are linearized on that lane's mutation line: validation, at most one durable write, and the in-memory update complete before the next mutation starts (section 15). Provider, tool, hook, and retry work never occupies the mutation line. +- Creating a lane copies no tree content, operation history, or configuration from its anchor. Creation atomically establishes the pointer and the seed total configuration. Lanes are not deleted or renamed. +- State-dependent mutations on one lane are linearized on that lane's mutation line: validation, at most one atomic storage append, and the in-memory update complete before the next mutation starts (section 15). Provider, tool, hook, and retry work never occupies the mutation line. - Two lanes at the same leaf diverge on their next append. The tree handles this; no coordination exists between lanes. - A lane with an unfinished operation restores as suspended, independently of its siblings. Suspension has a reason: crash, or a deferred provider request (section 1). @@ -107,25 +109,44 @@ An operation is the unit of durable work on a lane. Three kinds: - **Compaction** — replaces old context with a summary entry. - **Navigation** — moves the lane's leaf to an existing entry, optionally with a branch summary. -An operation is accepted before it executes. Acceptance is durable: after a crash, an accepted operation is either completed by recovery or explicitly closed. Every accepted run ends `completed`, `failed`, or `aborted` (stopped by abort). Compaction and navigation may additionally end `declined` when their decision hook vetoes the accepted structural operation before its effect. +An operation is accepted before it executes. Acceptance is durable: after a crash, an accepted operation is either completed by recovery or explicitly closed. When an operation finishes, exactly one `operation_finished` record is its terminal durable state; no separate tree entry is universal. Every accepted run finishes `completed`, `failed`, or `aborted` (stopped by abort). Compaction and navigation may additionally finish `declined` when their decision hook vetoes the accepted structural operation before its effect. ### Runs, turns, and steps -A run is a sequence of turns. A turn is one assistant step plus the complete tool batch requested by that assistant message. +A run is a sequence of turns. A turn is one assistant generation step plus the complete tool batch requested by the accepted assistant response. -A step is a retryable unit of work inside an operation: produce an assistant message, a compaction summary, or a branch summary. A step may make zero, one, or several provider requests. A failed attempt retries the same step; the attempt count is durable and survives restarts. A deferred provider request ends an assistant step: the handle arrives inside a persisted assistant message that closes the step, the operation suspends, and redemption later appends the real result (section 1). +A **step** is a durable logical unit of work inside an operation. A **generation step** is a step whose task starts a new LLM generation request: produce an assistant response, a compaction summary, or a branch summary. Committing its `step_started` record assigns a stable `stepId` and snapshots the values shared by all its attempts: the lane's total configuration and the normalized retry policy. Provider executions are numbered attempts of that same step, and the durable count survives restarts. A deferred-fetch step is not a generation step because it polls provider work that already exists; it still has one stable `stepId` and numbered poll attempts, and copies the original generation step's total configuration and normalized retry policy once so interrupted polls are self-contained. -Each tool call that starts an effect is also a step. `tool_started` opens it; its tool-result entry closes it. A parallel batch holds several open tool steps at once; their effects run concurrently and finalize in source order (section 14). +Structural decision hooks create a step without generation when they supply the result themselves. That `step_started` stores the complete provisioned compaction or branch-summary entry and optional hook-usage intent, so recovery never reruns the decision. A generated compaction commits by appending its result entry directly; it has no prepared-result record. A generated branch summary must survive a later navigation move, so its complete payload becomes a dedicated durable prepared record before the move. Structural provider streams are internal and never appear as public assistant-message events. + +Before an assistant-generation or deferred-fetch attempt starts its provider effect, it provisions the id of its response entry. Once the stream settles, the complete response is appended under that id for every stop reason, before later orchestration. Classification may decide to retry, compact, suspend, fail, abort, or accept the response, but it does not erase it. If a crash leaves an attempt without its response, the external effect is unknown: without abort, recovery starts the next numbered attempt when policy permits or appends a synthetic interruption response under the already-provisioned id at the cap; with an abort marker, it appends a synthetic aborted response under that same id and never retries. + +For an assistant generation, `triggerMessageId` is the id of the newest consumed message that projects as user context and caused that generation. A prompt, consumed steer or follow-up, or another run-owned user-context message can supply it. It bounds overflow recovery to one compaction for that user input: + +```text +consume user message U1 +start assistant step A1, triggerMessageId = U1 +persist recoverable-overflow response R1 +compact once, linked to R1 and U1 +start assistant step A2, triggerMessageId = U1 +persist recoverable-overflow response R2 → fail; no second compaction +consume steering message U2 +start assistant step A3, triggerMessageId = U2 → one new overflow compaction is allowed +``` + +An accepted assistant response with tool calls gets one durable planned tool batch before call clearance — tool lookup, argument validation, and `before_tool` — or execution. The provider contract requires `toolCallId` to be unique within one assistant response. The plan assigns a result entry id to every source call index, including calls later blocked, invalid, interrupted, or aborted. The index exists only to preserve source ordering and locate that planned result; it is not exposed publicly. A real call then writes `tool_started` immediately before its individual effect. Parallel effects may settle concurrently, but results finalize and append in source order (section 14). ### Queues and deferred writes Two mechanisms carry input into a running lane. They differ in abort behavior: - **Queues** carry conversational intent: `steer` corrects the current work, `followUp` adds work for when the model would stop, `nextRun` seeds the lane's next run. Steering and follow-ups die on abort; their payloads are returned to the caller. Next-run messages survive. -- **Deferred writes** carry facts: entries and configuration changes requested while a step is in flight. They survive abort and are applied even during cancellation. +- **Deferred writes** carry tree additions requested while a step is in flight. They survive abort and are applied even during cancellation. Both are durable at acceptance: the accepting call writes a record with the full payload to the lane's operation log, then resolves. The tree entry is written later, when the item is applied or consumed — the position where the model first sees it. If the process dies between acceptance and the tree write, recovery reads the record and performs the append. Accepted input is never lost. +Lane configuration updates do not use deferred writes. A setter commits an immediate total `lane_config` replacement on the mutation line, so later generation steps see it while an already-started step and all its retries retain their captured configuration. + ### Checkpoints Between turns, the lane passes a checkpoint: @@ -134,7 +155,7 @@ Between turns, the lane passes a checkpoint: 2. Consume queued steering messages. 3. Compact if the next request would not fit. -Compaction has a reactive trigger too: a provider response that reveals the request did not fit — an overflow-form error, or a `length` stop below the intended output cap. That response is discarded and the run compacts and retries once (section 6, "Context overflow at an assistant step"). +Compaction has a reactive trigger too: a durable provider response can reveal that its request did not fit — an explicit context-limit error, reported input plus cache-read tokens greater than the attempt's captured window, or a recoverable `length` stop. A response classified as recoverable overflow starts no tool batch. The run may start one overflow compaction linked to that exact response entry and its `triggerMessageId`; compaction preparation omits the linked response. A second recoverable overflow with the same trigger fails instead. Consuming a newer user-context message supplies a new trigger and permits one new compaction (section 6, "Context overflow at an assistant step"). A turn with tool calls forces another turn so the model sees its results — with one exception: a batch in which every finalized tool result persisted `terminate: true` suppresses automatic tool continuation (steering or follow-up input can still start another turn). Follow-up messages are consumed only when tool continuation and steering are exhausted. The run ends when a checkpoint finds nothing pending. @@ -142,7 +163,7 @@ A turn with tool calls forces another turn so the model sees its results — wit > Across the requests of a lane, provider context only grows at the tail. An insertion before the previous request's tail invalidates the provider's KV cache from that point on and multiplies token cost. -This invariant is why mid-turn writes defer to checkpoints: checkpoint application appends at the tail. Compaction is the one deliberate exception; it trades one full cache invalidation for a smaller context. +This invariant is why mid-turn writes defer to checkpoints: checkpoint application appends at the tail. Persistence and provider-context projection are separate. Assistant responses with stop reason `error`, `aborted`, or `deferred` project to no provider message. A genuine output-limit `length` response remains in context; a response classified as overflow is omitted through its exact compaction link. Compaction is the one deliberate cache invalidation; it trades that invalidation for a smaller context. ### Lane lifecycle @@ -160,12 +181,14 @@ stateDiagram-v2 ``` - States are per lane. One exception: a failed storage write faults the whole harness. A faulted harness stops all effects and rejects all calls; after the cause is fixed, reopening restores each lane from its records. -- **Suspended** means: an operation is open, nothing executes. Reached by restore after a crash, or deliberately when a deferred handle is persisted. `resume()` continues the operation; `abort()` closes it without further execution. -- **Abort** records the cancellation durably, signals running effects, and returns. Reconciliation follows: unresolved tool calls get synthetic results, and the transcript gets a closing assistant message. Automatic drive runs it in the background; manual drive leaves it parked at its next action. +- **Suspended** means: an operation is open, nothing executes. Reached by restore after a crash, or deliberately when a deferred handle is persisted. `resume()` continues the operation; `abort()` starts cancellation reconciliation instead of ordinary continuation. +- **Abort** first records the cancellation durably; that marker is the authority. It then signals running effects and returns. Reconciliation completes missing accepted initial messages, required planned tool results, and accepted deferred writes. There is no universal assistant closure, and the harness never starts a request or appends an assistant message solely to manufacture one. `operation_finished` with outcome `aborted` is the universal terminal marker. Repeating `abort()` while that marker is open returns the same drained steer/follow-up payloads without writing another marker or signaling again. Automatic drive reconciles in the background; manual drive leaves reconciliation parked at its next action. ### Resume -Resume continues the open operation. It never starts a new one. The entry point is wherever the records end: retry an unfinished step, redeem a deferred handle, reconcile a half-finished tool batch, or continue at the next checkpoint. Queued messages and deferred writes accepted before the crash are still pending and apply normally. +Resume continues the open operation. It never starts a new one and never relies on a persisted program counter. The harness reduces the lane's durable records and planned entry ids, then re-enters the ordinary operation procedure at the state they describe: settle an attempt whose response is missing, classify a durable response whose next transition is missing, reconcile a planned tool batch, perform at most one numbered deferred-fetch poll, or continue at the next checkpoint. Every deferred poll response is durable, including another pending `deferred` response, so a later resume polls from the newest persisted source entry. Queued messages and deferred writes accepted before the crash are still pending and apply normally. + +For deferred polling, the **source lineage** is the response-entry chain that identifies what each poll redeems: the original deferred response is the first source, each pending poll response becomes the next source, and an interrupted or unknown poll retains its existing source. **Complete handle equality** means every required field, every optional field's presence and value, and the JSON value in `data` match; section 16 defines the field-level comparison. # Part II — How execution is recorded @@ -175,9 +198,9 @@ Part II is backend-neutral. It defines the records a lane writes, when it writes ### The durability rule -> Before an effect: write an intent record that names what will happen and the ids it will produce. After the effect: append the result as an entry with exactly those ids. +> Before an effect: write an intent record that names what will happen and every durable id settlement will use. After an assistant/fetch effect: append the complete response entry, then its preplanned usage record. -There is no multi-record atomicity and none is needed. Each record and each entry is durable alone. A crash between intent and result leaves the intent unfulfilled; recovery decides per intent type: complete it, retry it, or close it with a synthetic result. An intent is fulfilled if and only if an entry with its provisioned id exists. The entry can itself name the next durable state: an assistant entry with `stopReason: "deferred"` fulfills its attempt's provisioned append and closes the step; what stays outstanding is the operation — the persisted handle awaits redemption (section 6). A provisioned id that exists with different content is corruption. +Each procedure boundary below uses a separate storage append unless the contract explicitly requires one atomic multi-write append: configured lane creation and labeled navigation completion. A single append may contain several logical mutations, which commit all-or-none with consecutive sequence positions; there is no crash prefix inside it. Assistant attempts, responses, and usage remain separate appends. A crash between an assistant/fetch attempt and its response therefore leaves the external effect unknown; without abort, recovery starts a later numbered attempt or closes at the cap under the existing response id. An abort marker instead closes that attempt as synthetic `aborted` under its existing response id. A crash between response and usage reconstructs the exact usage record before classification. A structural `step_started` provisions one typed result id. A generated compaction closes with its result entry or `step_failed`; a generated branch summary stops requesting once its complete `branch_summary_prepared` is durable and closes when that exact entry is appended or the step fails before preparation. A hook source stores its complete result on `step_started` and closes when that entry appends. An unknown generated attempt advances only under the captured policy. An assistant entry with `stopReason: "deferred"` fulfills its generation attempt and closes that generation step; what stays outstanding is the operation — the persisted handle awaits redemption (section 6). A provisioned id that exists with different content is corruption. ### Provisioned ids @@ -193,7 +216,7 @@ type ProvisionedEntry = ### Record catalog -Every record belongs to one lane's operation log. Records that belong to an operation carry `runId`: the id of that operation's `operation_started` record. Next-run queue records (`queue_enqueued` and their `queue_cancelled`) and standalone `adjustment` usage records carry no `runId`. +Every record belongs to one lane's record sequence. Records that belong to an operation carry `runId`: the id of that operation's `operation_started` record. Total configuration records, next-run queue records (`queue_enqueued` and their `queue_cancelled`), and standalone `adjustment` usage records carry no `runId`. ```ts interface RecordBase { @@ -203,6 +226,28 @@ interface RecordBase { timestamp: number; // Unix ms } +interface ModelReference { + provider: string; + modelId: string; +} + +/** The complete durable configuration of one lane. Arrays are copied on + input and output. Tool implementations remain harness-global runtime + capabilities; only their names persist here. */ +interface LaneConfiguration { + model: ModelReference; + thinkingLevel: ThinkingLevel; + activeToolNames: string[]; +} + +// A total replacement, never a patch. The newest record is the lane's +// current configuration. It is independent of operations and survives +// abort. Every configured format-4 lane has at least one such record. +interface LaneConfigRecord extends RecordBase { + type: "lane_config"; + configuration: LaneConfiguration; +} + // Acceptance boundary of an operation. Everything decided before acceptance // is persisted here. This record's own id IS the runId that all other // records of the operation carry. @@ -219,7 +264,7 @@ interface OperationStartedRecord extends RecordBase { injections. Full payloads, provisioned ids. Capture happens in the acceptance mutation (section 15): items present when it runs belong to this run; later items belong to the next. */ - initialMessages: ProvisionedEntry[]; + initialMessages: ProvisionedEntry[]; /** Present only when a hook overrode the system prompt; fixed for the whole run. Absent: the systemPrompt callback runs per request. */ systemPromptOverride?: string; @@ -232,62 +277,164 @@ interface OperationStartedRecord extends RecordBase { customInstructions?: string; resultEntryId: string; // provisioned compaction entry } - | { + | ({ kind: "navigation"; - targetId: string | null; // destination entry; null = root - summarize: boolean; customInstructions?: string; - label?: string; // global fact, written at completion - summaryEntryId?: string; // provisioned branch-summary entry - }; + } & ( + | { targetId: null; label?: never } // root has no label fact + | { targetId: string; label?: string } // written at completion + ) & ( + | { summarize: false; summaryEntryId?: never } + | { summarize: true; summaryEntryId: string } // provisioned branch-summary entry + )); } -// Written when abort() resolves. A request marker, not a terminal state: -// reconciliation follows, then operation_finished with outcome "aborted". -// Kills this operation's steer/follow-up queue items; next-run items survive. +// Operation acceptance does not copy lane configuration. A generation +// step captures it when that step starts; retries use that captured value. + +// Written exactly once before the first abort() resolves. A request marker, +// not a terminal state: reconciliation follows, then operation_finished with +// outcome "aborted" unless a structural commit had already won. Kills this +// operation's steer/follow-up queue items; next-run items survive. Repeated +// abort() calls while the operation remains open return the same killed items +// without another record. interface AbortRequestedRecord extends RecordBase { type: "abort_requested"; runId: string; } // Closes the operation. failed = orderly durable failure (for example, -// retries exhausted). aborted = closed by abort. declined = vetoed by a -// hook before any effect. -interface OperationFinishedRecord extends RecordBase { +// retries exhausted). aborted = closed only by an earlier abort_requested. +// declined = vetoed by a hook before any effect. +type OperationFinishedRecord = RecordBase & { type: "operation_finished"; runId: string; - outcome: "completed" | "aborted" | "failed" | "declined"; - error?: { code: string; message: string }; -} - -// Written before each attempt at a retryable step. Marks: we are about to -// do this, for the n-th time. Steps are logged because they are -// retryable: the durable count caps retries across restarts — a -// crash-restart loop cannot reset it. One record per attempt; one attempt -// may make zero or several provider requests (split-turn compaction -// makes two). Deferred results need no extra -// record: the handle lives in the persisted assistant entry (section 1). -interface StepAttemptRecord extends RecordBase { +} & ( + | { outcome: "failed"; error: { code: string; message: string } } + | { outcome: "completed" | "aborted" | "declined"; error?: never } +); + +// Starts one durable logical step. This record's own id IS the stepId. +// Generation sources copy configuration arrays and persist the normalized +// retry policy once, so every attempt, including one started after reopen, +// uses the same cap and backoff. A hook source instead persists the complete +// provisioned result; it has no provider attempts. hookUsageRecordId is +// present exactly when that result reports usage, so recovery can write the +// exact accounting record before the result entry without rerunning the hook. +type StructuralStepSource = + | { source: "generated"; + configuration: LaneConfiguration; + retryPolicy: RetryPolicy; + hookResult?: never; + hookUsageRecordId?: never } + | { source: "hook"; + configuration?: never; + retryPolicy?: never; + hookResult: ProvisionedEntry; + hookUsageRecordId?: string }; + +type StepStartedRecord = RecordBase & { type: "step_started"; runId: string } & ( + | { step: "assistant"; + configuration: LaneConfiguration; + retryPolicy: RetryPolicy; // normalized total value + triggerMessageId: string } + | { step: "deferred_fetch"; + /** Exact copies from the assistant generation step that persisted the + original deferred response. Copied active tools govern a ready + response's tool calls; the source handle supplies fetch identity. */ + configuration: LaneConfiguration; + /** Normalized copy. maxAttempts applies to attempts naming one source; + a successful pending response creates a new source. */ + retryPolicy: RetryPolicy } + | ({ step: "compaction"; + resultEntryId: string; // always a CompactionEntry + } & StructuralStepSource & ( + | { compactionReason: "manual" | "threshold"; + supersededResponseEntryId?: never; triggerMessageId?: never } + | { compactionReason: "overflow"; + /** Exact durable assistant response omitted by this recovery. */ + supersededResponseEntryId: string; + /** Copied from the assistant step that produced that response. */ + triggerMessageId: string } + )) + | ({ step: "branch_summary"; + resultEntryId: string } // always a BranchSummaryEntry + & StructuralStepSource) +); + +// Written immediately before one attempt's provider work. attempt is 1-based +// and consecutive within stepId. Assistant and deferred-fetch attempts each +// contain one provider effect and provision both objects settlement must +// produce: first the complete response entry, then its usage record. Their +// response ids are fresh per attempt. +// Generated structural attempts use the one typed result id on step_started; +// an attempt may make several provider requests (split-turn compaction makes +// two). Hook structural sources have no attempts. +type StepAttemptRecord = RecordBase & { type: "step_attempt"; runId: string; - step: "assistant" | "compaction" | "branch_summary"; - attempt: number; // 1-based within this step - /** The entry this attempt produces if it succeeds. Assistant attempts - provision a fresh id each; all attempts of one structural step reuse - one id (manual: the intent's; auto: the first attempt's). The give-up - error entry fulfills the last attempt's id. */ - resultEntryId: string; - /** Required exactly for compaction steps. Persists why the summary is - being generated so resume re-enters the same structural work without - re-deriving context pressure. */ - compactionReason?: "manual" | "threshold" | "overflow"; -} -// The model of a resumed request is not read from records: the lane's -// effective model is derived from its path, and a deferred handle's model -// is in the persisted assistant entry. - -// Written after before_tool and validation pass, before the tool executes. -// assistantEntryId + toolIndex is the durable invocation identity. + stepId: string; + attempt: number; +} & ( + | { step: "assistant"; + responseEntryId: string; + usageRecordId: string; + /** Request-specific intended limit before context clamping. */ + intendedOutputLimit: number; + /** Context-window size used by this request. */ + contextWindow: number } + | { step: "deferred_fetch"; + /** Exact deferred response entry whose complete handle this poll + redeems. Pending responses advance this lineage even when the + complete handle is unchanged; interrupted responses do not. */ + sourceEntryId: string; + responseEntryId: string; + usageRecordId: string } + | { step: "compaction" | "branch_summary" } +); + +// A generated branch summary must be durable before navigation moves. This +// record stores the complete payload produced by one successful attempt. The +// later entry append uses it byte-for-byte; compaction has no corresponding +// prepared record because its result-entry append is its commit point. +interface BranchSummaryPreparedRecord extends RecordBase { + type: "branch_summary_prepared"; + runId: string; + stepId: string; + attempt: number; + result: ProvisionedEntry; +} + +// Structural generation has no assistant error entry to represent terminal +// failure. After the durable attempt cap is exhausted, this record closes a +// generated step. Assistant and deferred-fetch failures are complete response +// entries; hook-sourced structural steps cannot fail after their start record. +interface StepFailedRecord extends RecordBase { + type: "step_failed"; + runId: string; + stepId: string; + step: "compaction" | "branch_summary"; + error: { code: string; message: string }; +} + +// A resumed generation request uses the total configuration and retry policy +// captured by step_started, not the lane's current replacements. A deferred +// step copies both from the original generation step; each attempt additionally +// uses the provider/model carried by its exact source entry. + +// Written once after an assistant response is accepted and before clearance +// of any call. The array has exactly one item per source tool call, in source +// order. toolIndex preserves that order and locates the call's planned result; +// resultEntryId is the one destination for every real or synthetic outcome. +interface ToolBatchStartedRecord extends RecordBase { + type: "tool_batch_started"; + runId: string; + assistantEntryId: string; + calls: { toolIndex: number; resultEntryId: string }[]; // 0..tool-call count - 1 +} + +// Written after before_tool and validation pass, immediately before this +// call's individual effect. The result id is already fixed by the batch plan. interface ToolStartedRecord extends RecordBase { type: "tool_started"; runId: string; @@ -296,7 +443,6 @@ interface ToolStartedRecord extends RecordBase { toolCallId: string; toolName: string; effectiveArgs: Record; // after before_tool - resultEntryId: string; // provisioned /** The tool's declared replay safety, snapshotted at execution time. Recovery re-executes an unfinished call only when this field AND the current tool declaration both say "safe"; otherwise it writes a @@ -306,12 +452,13 @@ interface ToolStartedRecord extends RecordBase { // Queue acceptance. The payload travels here; the entry appears at the // consumption point. -interface QueueEnqueuedRecord extends RecordBase { +type QueueEnqueuedRecord = RecordBase & { type: "queue_enqueued"; - queue: "steer" | "followUp" | "nextRun"; - runId?: string; // absent for nextRun - target: ProvisionedEntry; -} + target: ProvisionedEntry; +} & ( + | { queue: "steer" | "followUp"; runId: string } + | { queue: "nextRun"; runId?: never } +); // Durable retraction of a pending queue item, before consumption. Without // this record a crash would resurrect the item: recovery treats a @@ -322,27 +469,27 @@ interface QueueCancelledRecord extends RecordBase { entryId: string; // the enqueued target's provisioned id } -// Deferred-write acceptance: an entry or configuration change requested -// while a step was in flight. Applied at the next checkpoint. +// Deferred-write acceptance: a tree entry requested while a step was in +// flight. Applied at the next checkpoint. Configuration never uses this +// record; its total replacement commits immediately. interface WriteDeferredRecord extends RecordBase { type: "write_deferred"; runId: string; - target: ProvisionedEntry; + target: ProvisionedEntry; } -// The cost ledger. Written whenever usage is reported or adjusted, -// whatever happens to the response. Pure accounting: the reduction, -// recovery, and validity checks never read it, so it adds no recovery -// states and no crash-matrix rows. It records reported usage; a transport -// death mid-stream can bill tokens no one reported, and a crash between -// settle and this write loses that one item — the irreducible window. +// The cost ledger. Written whenever usage is reported or adjusted, whatever +// happens to later orchestration. Assistant and deferred-fetch settlement +// writes the complete response entry first, then the preplanned usage record; +// recovery checks that one presence bit and reconstructs a missing record from +// the immutable response usage before classification. Other usage remains pure +// accounting. A transport death mid-stream can still bill unreported tokens. type UsageRecord = RecordBase & { type: "usage"; usage: Usage } & ( - // A provider request settled, whatever the outcome. Written before any - // classification, retry decision, or discard. Split-turn compaction - // writes two records sharing one attempt. A pending deferred fetch that - // reports no usage writes no record. + // A provider request settled. Split-turn compaction writes two records + // sharing one structural attempt and result entry id. | { cause: "assistant" | "compaction" | "branch_summary" | "deferred_fetch"; - runId: string; entryId: string; attempt: number; stopReason: TerminalStopReason } + runId: string; stepId: string; entryId: string; attempt: number; + stopReason: TerminalStopReason } // A finalized tool result reported nested LLM work; skipped when it // reports none. A safe replay writes a second record for the second // execution: both were billed. @@ -354,46 +501,68 @@ type UsageRecord = RecordBase & { type: "usage"; usage: Usage } & ( | { cause: "adjustment"; runId?: string; entryId?: string; details?: JsonValue } ); -type LaneRecord = OperationStartedRecord | AbortRequestedRecord | OperationFinishedRecord - | StepAttemptRecord | ToolStartedRecord | QueueEnqueuedRecord | QueueCancelledRecord +type LaneRecord = LaneConfigRecord | OperationStartedRecord | AbortRequestedRecord + | OperationFinishedRecord | StepStartedRecord | StepAttemptRecord + | BranchSummaryPreparedRecord | StepFailedRecord | ToolBatchStartedRecord + | ToolStartedRecord | QueueEnqueuedRecord | QueueCancelledRecord | WriteDeferredRecord | UsageRecord; type NewRecord = T extends LaneRecord ? Omit : never; ``` -Blocked or invalid tool calls write no `tool_started`. No effect starts, so no intent is needed: the block is durable as a tool-result entry with `isError: true` and the block reason as content. A crash before that entry loses only the decision, and recovery makes it again — `before_tool` runs again for a call with no `tool_started` and no result. +The batch plan is the intent for every call outcome, including calls that never execute. Blocked and invalid calls write no `tool_started`; they append an `isError: true` tool-result entry under the id already assigned to their source index. A crash before that entry reruns clearance — including `before_tool` — against the same planned id. A genuine output-limit `length`, abort before a call starts, or recovery of an unsafe started call likewise appends its explanatory, aborted, or interrupted synthetic result under that id. -A tool step needs no outcome record. Its result entry is the complete durable outcome, including the batch-control decision: the tool-result entry persists `terminate` (section 12). A crash after execution but before the result entry follows the replay policy (section 6); re-finalization runs `after_tool` again, which the section 1 non-goal explicitly permits. +A tool batch needs no outcome record. Its result entries are its complete durable outcomes, including each finalized `terminate` decision (section 12). A real call that reports usage writes a `usage` record bound to its planned result id before appending the result entry. That real result keeps only the finalized execution's own `AgentToolResult.usage` as its immutable display snapshot. A crash after execution or after that usage record but before the result follows the replay policy (section 6); re-finalization runs `after_tool` again after a safe replay, which the section 1 non-goal explicitly permits, and another real execution may write another usage record. Earlier execution and replay records remain separate in the durable ledger and effective cost sums them. A synthetic result has no usage snapshot, even when an earlier lost execution left a usage record. Public hooks, events, snapshots, and tool execution context continue to use provider `toolCallId` and tool name. Source index remains internal and is used only for source ordering and planned-result lookup. -Cost is the one concern where an outcome record exists: **cost durability must not depend on result durability**. Retryable steps are precisely the steps designed to produce responses that never become entries — failed attempts, exhausted series, discarded overflow responses — and their spend must not vanish with them. Every provider request therefore settles with a `usage` record before any classification, retry decision, or discard; tool-reported and hook-reported usage get records beside their entries; applications append `adjustment` records for anything the harness cannot see. +Cost is the one concern where settlement requires a second durable object: **cost durability must not depend on later classification**. Every settled assistant-generation and deferred-fetch response is first appended under its attempt's `responseEntryId`, then its preplanned `usage` record is appended before retry, overflow, suspension, failure, abort, or acceptance logic. A crash in between loses no cost: recovery rebuilds the exact record id and payload from the attempt and the response's immutable usage. Structural requests have no durable assistant response; each reported request usage is written immediately, before a generated compaction entry or `branch_summary_prepared`, and the provider-settle-to-usage-write crash window remains. A successful structural result's immutable usage snapshot is the sum of the successful attempt's request records; failed-attempt records remain ledger-only. Tool-reported usage is written before its planned result entry. A hook-reported structural usage record uses the id provisioned by the hook-sourced `step_started` and is repaired from its persisted result before that result entry commits, including when abort prevents the entry. Applications append `adjustment` records for anything the harness cannot see. -A harness-written `usage` record always binds `entryId` to the provisioned id of the entry its measurement belongs to; whether that entry exists is a separate question — a failed attempt's or a discarded response's id never materializes, which is the point. Three layers separate cleanly: an entry's `usage` field is an **immutable snapshot** of the response(s) that produced that entry, written once at append and never touched again; the **effective cost of an entry** is a read-time query — the sum of all lanes' `usage` records bound to its id, base plus adjustments; the **session's cost** is the sum of all `usage` records. Recovery can honestly bill twice — a retried step or a replayed tool writes one record per execution — and the entry snapshot equals the newest non-adjustment record(s) of its id (for compaction and branch summaries: the successful attempt's). +A harness-written `usage` record binds `entryId` to the entry its measurement belongs to. For assistant and deferred-fetch work that entry always exists before the usage record. Structural failed attempts can still bind usage to a typed result id that never materializes. Three layers separate cleanly: an entry's `usage` field is an **immutable display snapshot** of the response that produced that entry, written once at append and never touched again; the **effective cost of an entry** is a read-time query — the sum of all lanes' `usage` records bound to its id, base plus adjustments; the **session's cost** is the sum of all `usage` records. Recovery can honestly bill twice — a later numbered provider attempt or a replayed tool writes one record per execution. A real tool-result snapshot contains only its finalized execution's usage, not earlier lost executions or replays, while the ledger retains and sums every record bound to the planned result id. A synthetic tool result has no usage snapshot. ### Validity -Recovery rejects a lane's log as corrupt when: +These rules define valid writes and valid prefixes. Append-time validation applies the relationships available at each write. Restore applies them only to the indexed configuration, discovered open operation, independent next-run slice, and exact planned entries described in section 7; it never scans completed history merely to re-audit it. Within that bounded input, restore rejects corruption when: +- a configured format-4 lane has no `lane_config` record, or a `lane_config` payload is not total; - more than one operation is open; +- a navigation operation targets its own `sourceLeafId`, or has `targetId: null` and a label; root has no label fact; +- append/replay observes a lane create without its immediately following first total config, or completed labeled navigation without its immediately preceding accepted label fact in the same atomic append; +- an `operation_finished: failed` lacks `error`, or any other finish outcome carries `error`; +- one operation has more than one `abort_requested`, an `operation_finished: aborted` has no earlier abort marker, or an assistant/fetch response appended after the marker kept another stop reason; an `aborted` response without an earlier marker is valid provider interruption, not corruption; +- a `step_started`, `step_attempt`, `branch_summary_prepared`, `step_failed`, `tool_batch_started`, or `tool_started` follows its operation's abort marker; reconciliation may append only settlements already intended before the marker, accepted initial/deferred writes, accounting, committed structural results, and the terminal record; +- a run with an abort marker finishes with an outcome other than `aborted`, or a compaction/navigation with a marker finishes non-aborted when its result-entry/move commit had not already won; - a record references an operation that does not exist, or follows its finish; -- attempt numbers are not consecutive within a step; -- `compactionReason` is absent from a compaction attempt or present on another step kind; +- a `step_attempt`, `branch_summary_prepared`, or `step_failed` references no earlier `step_started`, names a different operation or step kind, or follows that step's failure; +- a generation `step_started` has a non-total configuration or non-normalized retry policy; a deferred-fetch step has non-total copied configuration or non-normalized copied retry policy, or a second fetch step appears before the original deferred response and its later pending responses settle; an assistant step has no `triggerMessageId`; a structural step has the wrong typed result id; a manual structural step's result id disagrees with its accepted operation intent; a generated structural source lacks total configuration or normalized policy; or a hook structural source carries either of those generation fields; +- a hook structural source's complete provisioned result has the wrong id or type, has `fromHook !== true`, omits required compaction preparation fields, or disagrees with its accepted navigation source; its `hookUsageRecordId` is present exactly when the result has usage, and the matching `cause: "hook"` record, when present, must reproduce that usage and precede any result entry; +- a structural attempt or `step_failed` belongs to a hook source; a generated branch-summary source has more than one `branch_summary_prepared`, or its prepared record names a nonexistent/non-latest attempt, has the wrong result id/type or `fromHook !== false`, or follows the navigation move; a generated compaction has any prepared-result record; +- a generated structural result entry or prepared branch-summary payload with a usage snapshot has no matching successful-attempt usage records before it, or their sum differs from that snapshot; failed-attempt usage remains valid ledger history; +- a non-overflow compaction step carries overflow-link fields, or an overflow compaction step does not name both `supersededResponseEntryId` and `triggerMessageId`; the named entry must be the complete, accounted response of an earlier assistant attempt in the same run, its assistant step must carry the same trigger, and the durable response plus attempt fields must satisfy the overflow predicate; +- two overflow compaction steps use the same `triggerMessageId`; live execution gives a generation after newly consumed user-context input that newer message's id instead; +- attempt numbers are not consecutive within a `stepId`; +- two assistant/fetch attempts reuse a response or usage id, or their request-specific limits/source entry are invalid; a deferred-fetch source must be the original deferred response, the newest equal-handle pending response, or the unchanged source of a below-cap interrupted or unknown poll, as applicable; a pending response without an abort marker whose complete handle differs from its source is invalid, and no fetch attempt may follow a ready, terminal, or capped interrupted response while redeeming that original deferred response; +- an assistant/fetch response id exists as anything other than its complete `MessageEntry`, its usage record exists before that response, or the preplanned usage id exists with fields that do not match the attempt and immutable response usage; +- a `step_failed` belongs to assistant/fetch or hook-sourced work, or a structural result entry or prepared branch-summary payload coexists with `step_failed` for one step; - a steer or follow-up `queue_enqueued` for a run follows its `abort_requested`; - a `queue_cancelled` targets an id with no `queue_enqueued`, or one whose entry exists; -- attempts in one structural step disagree on `resultEntryId`, or any attempts of one step disagree on `compactionReason`; -- `tool_started.toolIndex` does not identify the stored `toolCallId` and `toolName` in its original assistant entry; -- two `tool_started` records share an invocation identity; +- a `tool_batch_started` does not name a complete, accounted, accepted assistant response in the same run, more than one plan names the same response, or a recoverable-overflow response owns a plan; +- a batch plan's `calls` are not exactly the source response's tool calls by zero-based source index and order, its result ids are not unique, or a result id is reused by another provisioned object; +- a `tool_started` has no earlier batch plan for its assistant entry and source index, does not identify the stored `toolCallId` and `toolName` at that index, or duplicates a start for that planned source position; the started record never supplies or changes the planned result id; +- planned tool-result entries do not form a source-order prefix, a planned id exists as a non-tool-result entry, or a tool `usage` record does not bind a planned started call and its stored provider call id; when a real result's finalized execution reports usage, that record must precede the result and match its `AgentToolResult.usage`, while a synthetic result has no usage snapshot; - a provisioned id exists with different content. +For navigation, bounded validity also compares the current leaf with the accepted source, target, and summary id. Without summarization, only source-before-move and target-after-move are valid. With summarization, the move requires a durable payload first: a hook result on `step_started` or a generated `branch_summary_prepared`. The valid sequence is source with no payload, source with payload, target with payload and no result entry, then summary-entry leaf with that exact result. A target leaf without a payload, a result entry while the leaf is not its id, or any unrelated leaf is corruption. No branch walk is needed. + ## 6. What each action writes -Traces at the storage level. All traces show one lane. Legend: +Traces at the storage level. All traces show one already-configured lane; its initial `lane_config` precedes the shown operation. Legend: ```text E entry appended to the tree (chained to the lane's leaf) -R record appended to the lane's operation log +R record appended to the lane's record sequence L lane pointer move G global fact written +A one atomic append containing the bracketed logical mutations H hook (awaited; hooks are Part I concepts, their API is Part III) X crash site ``` @@ -405,95 +574,168 @@ X crash site H before_run may inject entries, override system prompt R operation_started kind run; initial messages with provisioned ids E user message the provisioned id from the intent -R step_attempt step assistant, attempt 1 -E assistant message [tool call] +R step_started assistant; config, retry policy, trigger = user id +R step_attempt attempt 1; provisioned response and usage ids +E assistant message [tool call] complete settled response, every stop reason +R usage preplanned id; before classification +R tool_batch_started c1 source index and provisioned result id H before_tool may change args or block -R tool_started effective args, provisioned result id, replay -H after_tool may patch result and terminate -E tool result the provisioned result id; persists the terminate decision -R step_attempt next turn's assistant step, attempt 1 +R tool_started c1 effective args and replay; result id already planned + execute tool c1's individually gated phase-two effect +H after_tool may patch result, usage, and terminate +R usage c1 tool usage, only when reported; before result +E tool result c1's planned result id; persists the terminate decision +R step_started next assistant step; new stable id and trigger +R step_attempt attempt 1; fresh response and usage ids E assistant message "done" +R usage H before_run_end nothing pending, returns nothing R operation_finished completed ``` -A crash between any two lines is recoverable. The general rule: an intent without its result entry is completed, retried, or closed with a synthetic result by recovery; a result entry without a consumed intent cannot exist. +A crash between any two lines is recoverable. An assistant/fetch attempt without its response is an unknown effect and never reuses that attempt's id; absent abort it advances under policy, while an abort marker settles it as synthetic `aborted` under that id. A response without usage gets its exact preplanned record before classification. A generated compaction without its typed result and a generated branch summary without its prepared payload continue under captured policy or close with `step_failed`; a hook source already contains its complete result. A provider response or generated structural result without its planning step/attempt cannot exist. ### Retry ```text -R step_attempt attempt 1 - request fails -R usage the failed attempt's cost — never lost -R step_attempt attempt 2 — durable count -R usage -E assistant message +R step_started assistant S; captured config and retry policy +R step_attempt S attempt 1; response R1, usage U1 +E assistant message R1 retryable error, complete and durable +R usage U1 reconstructed from R1 if this write was interrupted + retry delay +R step_attempt S attempt 2; fresh response R2, usage U2 +E assistant message R2 successful response +R usage U2 ``` -Every provider request settles with a `usage` record (section 5); the other traces omit them for brevity. Per-request hooks (`transform_context`, `before_request`, `after_response`) run inside every request and are omitted everywhere; Tier B records them (section 19). +Every settled assistant-generation and deferred-fetch response is appended before its usage record and before classification; the other traces omit those paired writes only where stated. Per-request hooks (`transform_context`, `before_request`, `after_response`) run inside every request and are omitted everywhere; Tier B records them (section 19). -Crash during backoff: restore counts two attempts; resume starts attempt 3. The count never resets. Retryable errors below the cap are never appended as entries. Attempts exhausted — or a non-retryable terminal error — appends an assistant message with the error, then `operation_finished` failed: +Crash during the first backoff: restore reads the response and usage for attempt 1, classifies that same durable response, and starts attempt 2 if the captured policy permits. The count never resets, and every attempt has a distinct response id. A retryable response at the cap — or a non-retryable terminal error — is already the durable assistant error that leads to `operation_finished` failed: ```text E assistant message stop reason error; the failure is durable +R usage exact preplanned record X crash operation still open R operation_finished recovery writes failed — never completed ``` -The error entry is the terminal-failure marker. Recovery that finds it drains accepted writes and queued input; unless consumed steering or follow-up input starts new work, it closes the run failed (section 7). A run whose newest own message is a step-produced error can never be completed by recovery. +The error entry is the terminal-failure marker. Recovery that finds it drains accepted writes and queued input; unless consumed steering or follow-up input starts new work, it closes the run failed (section 7). The same rule applies to an unmarked `aborted` response at its captured cap: a run whose newest own message is either terminal form can never be completed by recovery. + +Two settlement prefixes require explicit recovery: + +```text +R step_attempt N response RN, usage UN +X provider effect unknown RN absent +``` + +If `N` is below the persisted cap, recovery commits attempt `N+1` before repeating the provider effect; it never reuses `RN`. At the cap it appends a synthetic interruption error under `RN`, then appends `UN` from that response's zero usage. No id is invented after the attempt. + +```text +R step_attempt N +E assistant message RN +X usage UN missing +R usage UN recovery reconstructs it from RN before classification +``` + +An existing response without its preplanned usage record is a valid crash prefix, not usage loss. An existing usage record without its response is corruption because live settlement cannot produce that order. + +A transport timeout, harness-close signal, provider-side cancellation, or similar interruption can settle as `aborted` without `abort_requested`. That response follows the durable retry boundary rather than the abort path: + +```text +R step_started assistant S; captured policy +R step_attempt S attempt 1; response R1, usage U1 +E assistant message R1 stop reason aborted; no abort marker +R usage U1 + retry delay +R step_attempt S attempt 2; fresh response and usage ids +``` + +At the captured cap, that attempt's `aborted` response is the durable terminal interruption response and the run finishes failed after its normal drain. It remains omitted from provider context. It never produces `operation_finished: aborted` or `run_abort`. + +### Post-persistence assistant-response classification + +Classification begins only after both durable settlement objects exist: the complete `MessageEntry`, then its preplanned `usage` record. It is a pure, process-local decision, not another record. Its inputs are the immutable response, the assistant `step_started` and `step_attempt`, the current abort marker, and linked later records. It returns retry, overflow recovery, suspension, failure, abort, or acceptance. It never changes or deletes the response and needs no general attempt-outcome metadata. + +A linked later record means that an ordinary transition already won: a newer attempt of the same step represents retry, an overflow compaction must name this exact response and trigger, a durable tool-batch plan represents accepted tool calls, and `operation_finished` is terminal. Absent an abort marker, resume continues that represented transition instead of classifying into a second one. A deferred response is itself the durable suspension fact, so reclassification simply parks again. + +Every prefix around settlement and transition has one interpretation: + +| durable prefix | recovery before ordinary orchestration | +|---|---| +| `step_started`, no attempt | append attempt 1 before the provider effect | +| `step_attempt`, response absent, no abort | effect unknown; start a fresh numbered attempt below the cap, or append the synthetic interruption response under the provisioned id at the cap | +| `step_attempt`, response absent, abort present | append synthetic `aborted` under the provisioned response id and its preplanned usage; never retry | +| response present, usage absent | reconstruct the exact preplanned usage record from the response; then abort wins if its marker exists | +| usage present, response absent | corruption; live settlement cannot write in this order | +| response and usage present, no linked transition | run the pure classifier on that same response; an abort marker selects reconciliation | +| response and usage present, linked transition present | absent abort, resume the represented retry, overflow compaction, tool batch, suspension, or finish; with abort, preserve the response and reconcile instead | + +An existing abort marker selects abort before following or creating an ordinary transition, whatever stop reason a response that committed first retained. Otherwise overflow classification runs before interruption and retryable-error classification; a context-limit error must compact rather than retry the same oversized request. `deferred` suspends. An unmarked `aborted` response is an ordinary provider interruption: below the captured cap it retries, and at the cap it fails the run. A retryable `error` below the cap retries, any other `error` fails, and `stop`, `toolUse`, or a genuine output-limit `length` is accepted. The same function and ordering run live and after reopen. ### Context overflow at an assistant step -`length` is ambiguous: generation stopped at some output boundary, but that boundary is either the intended output limit — compaction cannot help — or a smaller context or provider limit, where it can. The classification compares actual output usage (reasoning tokens included) against the **intended** output cap: +Overflow classification has three explicit inputs, all durable on the response and attempt: + +1. **Explicit provider context-limit error.** The response has `stopReason: "error"`, and its durable `errorMessage` matches pi-ai's existing provider context-limit patterns after known non-overflow patterns such as throttling and rate limits are excluded. Examples include "prompt is too long", "exceeds the context window", and DashScope/Qwen's "Range of input length should be". No transient exception or HTTP object is needed after reopen. +2. **Reported input exceeds the captured window.** The response has `stopReason: "stop"`, `attempt.contextWindow > 0`, and `message.usage.input + message.usage.cacheRead > attempt.contextWindow`. This preserves the existing successful-response check without introducing a separate named condition or durable outcome. +3. **A recoverable `length`.** The response either matches the existing Xiaomi MiMo-compatible context-pressure signal — zero output and reported input plus cache-read tokens at least 99% of the captured non-zero window — or ended below the request's persisted intended output limit: ```ts -function isRecoverableLength(message: AssistantMessage, desiredMaxOutput: number): boolean { - if (message.stopReason !== "length") return false; - // Reaching the caller's or model's intended cap is a genuine output-limit stop. - if (desiredMaxOutput > 0 && message.usage.output >= desiredMaxOutput) return false; - // Stopped below the intended cap: context pressure or provider-side truncation. - return true; +function isRecoverableLength(message: AssistantMessage, intendedOutputLimit: number): boolean { + return message.stopReason === "length" + && intendedOutputLimit > 0 + && message.usage.output < intendedOutputLimit; } ``` -`desiredMaxOutput` is the caller-supplied `maxTokens` when set, else `model.maxTokens` — the intended limit **before** any context clamping. The value actually sent can never be the reference: some providers reject an explicit output cap outright (the OpenAI Codex backend returns HTTP 400 for `max_output_tokens`), and Pi clamps others to the remaining context. This covers a context-clamped request that returns 16 reasoning tokens against a 128k intent (recover), a Xiaomi/Qwen-style `length` with zero output (recover), and an explicit 1,024 cap fully used (genuine stop) — with no context-percentage heuristics. Overflow-form errors — a provider rejection matching the overflow patterns, or a silent success whose prompt exceeds the window — classify the same way and take the same path. +`usage.output` already includes reported reasoning tokens. `intendedOutputLimit` is the caller-supplied `maxTokens` when set, else `model.maxTokens`, captured before any context clamping. The value actually sent cannot be the reference: some providers reject an explicit output cap outright (the OpenAI Codex backend returns HTTP 400 for `max_output_tokens`), and pi clamps others to the remaining context. This covers a context-clamped request that returns 16 reasoning tokens against a 128k intent, a zero-output Xiaomi/Qwen-style response, and an explicit 1,024 cap fully used as a genuine stop. The Xiaomi compatibility signal is the only percentage check; there is no general context-percentage heuristic. -A recoverable response is **discarded**: like a retryable error, it never becomes an entry, so nothing has to be scrubbed from context on retry, live or after a crash. Its provisioned result id stays unfulfilled; its cost is already durable in the `usage` record written when the request settled (section 5). +A recoverable response remains a complete transcript entry. After its usage record commits, classification chooses overflow recovery and starts no tool-batch plan, even if the response contains tool calls. The exact response is then named by the overflow compaction and omitted from both summary preparation and retained-tail construction. **Compaction preparation** is the summary input and proposed retained tail given to `before_compaction` and, if needed, the structural provider request; the final `CompactionEntry.retainedTail` is built from that same filtered preparation. The response remains queryable in the tree; omission affects model and compaction context, not history. ```text -R step_attempt step assistant, attempt 1 - response: recoverable length below the intended cap, or overflow-form error -R usage the discarded response's cost — never lost - nothing else appended the response itself is discarded -H before_compaction reason overflow -R step_attempt step compaction, attempt 1 -E compaction entry -R step_attempt step assistant, attempt 1 — new step +R step_started assistant S1; trigger U; captured policy/config +R step_attempt S1 attempt 1; response R1, usage U1; request limits +E assistant message R1 recoverable length, context-limit error, or reported input > window +R usage U1 before overflow classification +H before_compaction reason overflow; preparation omits R1 +R step_started compaction C1; reason overflow; supersedes R1; trigger U +R step_attempt C1 attempt 1 +E compaction entry C1's stable result id; retained tail omits R1 +R step_started assistant S2; same trigger U, new stable step +R step_attempt S2 attempt 1; fresh response and usage ids E assistant message +R usage ``` -**One recovery per conversational input.** An overflow compaction may start only when no overflow-reason compaction `step_attempt` is newer than this run's newest consumed conversational message (prompt, steering, or follow-up). A second recoverable response inside that window appends the give-up error entry and fails the run through the drain path — a `length` response never resets the guard; only consumed conversational input does. This bounds the compact-and-retry loop at one attempt per user action. A `before_compaction` decline or an empty compaction preparation for reason `overflow` is equally terminal: without compaction the request cannot fit. A hook-supplied overflow compaction writes its compaction `step_attempt` before the entry so the guard counts it — the one hook-supplied summary that writes an attempt record. +**One recovery per conversational input.** An overflow compaction may commit only when no earlier overflow compaction in the run carries the same `triggerMessageId`. A second recoverable response with that trigger remains durable and accounted, starts no tool batch, and fails through the drain path. A `length` response never resets the guard. Consuming a newer prompt, steer, follow-up, or other user-context message gives the next assistant step a new trigger and permits one new overflow compaction. A `before_compaction` decline or an empty overflow preparation is equally terminal because the request cannot fit without compaction. Per crash site: | crash after | durable state | recovery | |---|---|---| -| `step_attempt` (assistant) | unfinished assistant step | resume retries; a recoverable response classifies again live | -| `step_attempt` (compaction, overflow) | unfinished compaction step | resume the compaction step with the recorded reason | -| compaction entry | step closed by its entry | checkpoint path; a fresh assistant step follows | +| assistant `step_started` | no attempt | commit attempt 1 before the provider effect | +| assistant `step_attempt` | response absent; provider effect unknown | start the next numbered attempt below the cap; at the cap append the synthetic interruption response under the attempt's id | +| assistant response entry | preplanned usage may be absent | append missing usage, then classify the durable response | +| assistant usage | response settled and accounted; no linked compaction yet | classify that response; if recoverable, run the omission-aware compaction decision before any new request or tool plan | +| overflow compaction `step_started` | exact response and trigger linked; stable typed result absent | continue that same compaction with preparation and retained tail omitting the linked response | +| compaction `step_attempt` | structural effect unknown | start the next numbered attempt below the cap; otherwise append `step_failed` | +| compaction entry | structural step closed by its typed result | checkpoint path; a fresh assistant step with the same trigger follows | -A genuine `length` stop — output at the intended cap — is appended and handled as before: with tool calls, the truncated batch fails every call without executing; without, the run proceeds to its normal finish. User-facing wording for any truncated response stays neutral ("response was truncated before completion") rather than claiming the configured output limit was reached. +A genuine output-limit `length` response follows the accepted-response path and remains in transcript **and provider context**. If it has no tool calls, the run reaches its normal checkpoint. If it has tool calls, the harness commits the ordinary durable tool-batch plan, executes none of them, and appends one planned `isError: true` result per source call in source order. Each result explains that the call was not executed because the response was truncated before completion and its arguments may be incomplete. These synthetic results do not terminate the batch, so another assistant turn follows and sees both the genuine `length` response and the explanatory results. A crash before the plan reclassifies the durable response and creates the plan; a crash after the plan resumes its missing planned results without executing tools. ### Steering while a tool runs ```text E assistant message [tool call] -R tool_started +R usage +R tool_batch_started all result ids planned +R tool_started immediately before this call's effect steer("focus on the tests") caller resolves here R queue_enqueued steer, full payload, provisioned id E tool result E user message checkpoint consumes the queue item; provisioned id -R step_attempt next request sees the steering message +R step_started next assistant step; trigger = steering message id +R step_attempt attempt 1; response and usage ids ``` Crash before `queue_enqueued`: the steer never happened; the caller's promise never resolved. Crash after: recovery finds the record without its entry and appends it at the same point the checkpoint would have. @@ -508,6 +750,19 @@ R queue_cancelled the entry will never be appended Crash between the two records: the item is still pending; the cancel promise never resolved. Cancellation and consumption are jobs on the lane mutation line, so `[cancel, consume]` and `[consume, cancel]` are the only histories (section 15). +### Configuration update during a generation step + +A setter changes one public property but writes the resulting total value. It never changes a generation step that already started: + +```text +R step_started S1 captures C1 +R lane_config setter commits total C2 and resolves + retry of S1 still uses C1 +R step_started S2 captures C2 +``` + +The setter and generation-step start are jobs on the lane mutation line, so the snapshot is either wholly before or wholly after the total replacement. The record survives abort. Tool activation follows the same rule: a tool batch from S1 uses S1's captured active names, resolved against the current harness-global tool implementations. + ### Input at the finish boundary Same-lane decisions have one order: the lane mutation line (section 15). The final pending-work check and the terminal append are one `tryFinishRun` mutation, so a concurrent steer has exactly two histories: @@ -526,51 +781,91 @@ Deferred writes and abort use the same ordering. A deferred write accepted befor ### Deferred write mid-turn ```text -R step_attempt request in flight, context ends at user message U +R step_started assistant; trigger U +R step_attempt response A and usage id; request in flight session.appendMessage(M) caller resolves here R write_deferred full payload, provisioned id E assistant message A provider cached [.., U, A] +R usage before classification E message M checkpoint applies the write; tail append ``` Appending M directly would produce [.., U, M, A]: a valid provider sequence that invalidates the KV cache from M on, and a transcript claiming A saw M when it did not. The checkpoint prevents both (append-only context, section 4). -### Abort during a tool +### Abort + +The abort marker and an active assistant response append race on the lane mutation line. The marker wins in this trace: ```text -E assistant message [tool call] -R tool_started - abort() caller resolves here +R step_started assistant S +R step_attempt S attempt 1; response R1, usage U1 + provider stream active + abort() first call resolves after the next record +R abort_requested queues drain; stream is signalled +E assistant message R1 same attempt id; stop reason normalized to aborted +R usage U1 measured usage from the settled stream +E pending deferred writes accepted run-owned writes still apply +R operation_finished aborted; no separate tree closure +``` + +Response settlement is one mutation-line job. If `abort_requested` commits first, that job keeps the settled message and usage but normalizes its stop reason to `aborted`, clearing any deferred-only handle field, before appending it under the attempt's existing `responseEntryId`. If a crash leaves that response absent, recovery appends a synthetic zero-usage `aborted` response under that same id, then the attempt's preplanned usage record. It never retries the attempt and never allocates another assistant id. If the response entry commits first, a later abort preserves its original stop reason; recovery repairs missing usage and the marker prevents retry, overflow recovery, tool planning, or any other ordinary transition. The stop reason alone is not abort authority: without an earlier marker, an `aborted` response follows ordinary interruption retry/failure classification and never finishes the operation aborted. + +An abort between assistant steps has no response id to settle and appends no assistant message. An abort during retry delay cancels the sleep and starts no later attempt; already-emitted retry events are not followed by events for an attempt that never starts. Repeating `abort()` while reconciliation is open writes no second marker, does not signal again, emits no second `run_abort`, and returns copies of the same drained steer/follow-up payloads. If `operation_finished` won first, the later call instead returns `NoActiveOperation`. + +During a planned tool batch, effects that already started are signalled and allowed to settle. Their real or error results, including `after_tool` finalization and reported usage, keep their planned ids. Calls whose effects never started get planned synthetic `aborted` results. After a crash, a started call with no result is an unknown effect and gets its planned synthetic `interrupted` result; abort-only recovery never replays it. For example: + +```text +E assistant message [tool calls c1, c2] +R usage +R tool_batch_started planned result ids for c1 and c2 +R tool_started(c1) immediately before c1's effect + abort() R abort_requested steer/follow-up queues die; payloads returned -E tool result synthetic "interrupted", or real if it finished -E assistant message closing message, stop reason aborted -R operation_finished aborted + signal c1 +E tool result c1 real/error result from the started effect +E tool result c2 planned synthetic aborted; c2 never started +E pending deferred writes +R operation_finished aborted; no assistant request is started ``` -Crash after `abort_requested`: recovery completes the same reconciliation. Pending deferred writes are applied even here; queued steer/follow-up items are not. +Crash after `abort_requested`: recovery completes the same planned results and pending deferred writes, then appends the terminal record. Queued steer/follow-up items are not applied. A response whose tool calls had not yet received a batch plan gets no plan after abort, so there are no promised tool results to manufacture. + +On a suspended deferred response, abort best-effort cancels the newest persisted handle, retains every deferred response entry, applies pending writes, and finishes aborted without another assistant message. A live deferred-fetch attempt follows the same existing-response-id settlement rule as an active assistant attempt. Cancellation failure is telemetry only and cannot block reconciliation; a crash may repeat the best-effort cancellation, but repeated `abort()` in one process does not. + +Compaction and navigation never write an assistant response for abort. Their commit points decide the race: the compaction result entry and the navigation lane move, respectively. A marker committed first signals any structural provider effect, discards an in-memory generated result, and finishes aborted without `step_failed`; persisted hook-result usage is still repaired from its `step_started`. If the structural commit happened first, the procedure completes that already-committed compaction or navigation, including the exact prepared summary and label writes, and finishes completed. All provider usage reported before cancellation remains in the ledger. Navigation never needs provider or hook work after its move because its complete summary payload is durable first. -### Tool execution crash sites +### Tool-batch ordering and crash sites + +The assistant response and its usage are durable before this trace begins. Planning is one record for the complete batch, before lookup, argument preparation, validation, or `before_tool` for any call: ```text -E assistant message, calls c1, c2 -X1 before before_tool nothing durable for c1 +X1 accepted response; no batch plan +R tool_batch_started c1 and c2, one result id per source index +X2 before clearance of c1 plan exists; no result or start for c1 H before_tool(c1) -X2 decision made, nothing written same as X1 -R tool_started(c1) -X3 tool executing +X3 clearance decided, nothing else same durable state as X2 +R tool_started(c1) effective args and replay declaration +X4 immediately before/during effect effect outcome unknown after a crash + execute tool c1 crosses fx.executeTool by itself H after_tool(c1) -X4 hook interrupted same durable state as X3 -E tool result c1 -X5 result durable c1 finished +X5 finalized result only in memory same durable state as X4 +R usage for c1 only when finalized result reports usage +X6 usage durable, result absent +E tool result c1 c1's planned id +X7 result durable c1 complete ``` | crash site | durable state | recovery | |---|---|---| -| X1, X2 | no record, no result | full normal path; `before_tool` runs (again) | -| X3, X4 | `tool_started`, no result | replay safe (record AND current declaration): re-execute persisted args, `after_tool` on the fresh result. Otherwise: synthetic "interrupted" result, no hooks | -| X5 | result entry exists | skip c1; c2 is at X1 | +| X1 | accepted, accounted assistant response; no plan | append one complete `tool_batch_started` with fresh ids, then continue; no clearance or effect can have occurred | +| X2, X3 | plan exists; this call has no `tool_started` and no result | ordinary call: rerun lookup, validation, and `before_tool`; blocked or invalid outcome uses the planned id. Genuine `length` skips clearance and writes its planned explanatory result. Aborting reconciliation writes the planned `aborted` result for a call that never started | +| X4, X5 | `tool_started`, no result | the effect is unknown. Without an abort marker, re-execute persisted args only when the record and current declaration both say `safe`, then run `after_tool`; otherwise append a planned synthetic `interrupted` result with no hooks. With an abort marker, never replay; append the interrupted result | +| X6 | `tool_started` and one or more tool-usage records, no result | keep all billed usage in the ledger, then take the same replay-or-interrupt action as X4. Without abort, a replay is another effect and may add another usage record; its real result snapshots only that replay's usage. An interrupted synthetic has no usage snapshot | +| X7 | planned result entry exists | skip the call; if its message reports usage, the matching usage record must already exist | + +Blocked and invalid calls go directly from X2/X3 to their planned error result and never write `tool_started`. Genuine output-limit `length` does the same for every source call without running clearance. During live abort, a started effect that settles keeps its real/error result; after a crash, any unresolved started call gets an `interrupted` result without replay. A planned call that never started gets an `aborted` result. Every synthetic result has `isError: true` and `terminate: false`. -Reconciliation handles each call of a batch at its own site, in source order. The step then ends normally. +Preparation is sequential in source order in both execution modes. Sequential mode then writes `tool_started`, executes, finalizes, writes usage when present, and appends the result before preparing the next call. Parallel mode prepares each call, writes `tool_started` for a real call, and dispatches its individual `fx.executeTool` call in source order without awaiting earlier effects. Effects may settle concurrently. Finalization, optional usage writes, and result appends await those dispatched effects in source order, so durable results always form a source-order prefix. Started records can be ahead of that prefix and can skip source positions occupied by blocked or invalid calls. Recovery reduces each planned call independently and settles missing results in source order. ### Auto-compaction at a checkpoint @@ -578,64 +873,108 @@ Reconciliation handles each call of a batch at its own site, in source order. Th E tool result step ends checkpoint: next request would not fit H before_compaction may decline or supply the summary -R step_attempt step compaction — skipped if hook supplied -E compaction entry -R step_attempt step assistant; run continues on compacted context +R step_started generated source; stable typed result id +R step_attempt attempt 1 — generated source only +R usage (one or two) successful request usage, before result +E compaction entry the generated commit point +R step_started assistant; config, policy, trigger snapshot +R step_attempt attempt 1; run continues on compacted context ``` -Auto-compaction writes no `operation_started`; it belongs to the run. Manual `compact()` is its own operation: `operation_started` (kind compaction, provisioned result id) → hook → attempt → compaction entry → `operation_finished`. +When the hook supplies the compaction, `step_started` instead stores the complete provisioned `CompactionEntry` with `fromHook: true` and no generation configuration or attempts. Its preplanned hook usage record, when present, is written next, then the exact stored entry commits. A crash after that start never reruns `before_compaction`. Generated compaction has no prepared-result record: a crash after provider settlement but before the result entry treats the attempt as unknown and advances under its captured policy. + +Auto-compaction writes no `operation_started`; it belongs to the run. Manual `compact()` is its own operation: `operation_started` (kind compaction, provisioned result id) → hook → hook- or generated-source `step_started` → optional numbered attempts → compaction entry → `operation_finished`. + +Structural terminal failure has no assistant message to carry it and applies only to generated sources: + +```text +R step_started generated compaction or branch_summary; stable typed result id +R step_attempt final allowed attempt +R usage (zero or more) any reported request cost +R step_failed terminal error; typed result id remains absent +R operation_finished failed (standalone structural operation) +``` + +For auto-compaction, `step_failed` instead enters the enclosing run's failure-drain path. A crash after `step_failed` never starts another structural provider request. A retryable generated failure below the captured cap uses the existing `retry_scheduled` → `retry_start` → `retry_end` lifecycle around the later numbered attempt; terminal failure closes with `step_failed`. Hook sources have no retry lifecycle. Structural provider streams are internal: none of these requests emits public `message_start`, `message_update`, or `message_end` events. ### Navigation ```text - navigateTree(target, { summarize: true, label: "before-refactor" }) -R operation_started kind navigation; target, provisioned summary id, label -H before_navigation may decline or supply the summary -R step_attempt step branch_summary — skipped if hook supplied - summary text generated in memory only + navigateTree(target, { summarize: true, label: "before-refactor", + customInstructions: "focus on API changes" }) +R operation_started kind navigation; target, summary id, label, instructions +H before_navigation receives preparation and custom instructions +R step_started generated source; stable typed result id +R step_attempt attempt 1 — generated source only +R usage (zero or more) reported request cost +R branch_summary_prepared complete generated BranchSummaryEntry payload L lane move → target one storage write; the commit point -E branch summary entry appends chain to the lane's leaf — now the target, - so the summary lands on the target branch -G label from the intent; latest-wins, idempotent -R operation_finished completed +E branch summary entry exact prepared payload; appends from target +A [G label, R operation_finished] one append; consecutive seq, completed ``` -The move commits first; every later write chains off durable state. No multi-object atomic write exists anywhere in the design. Acceptance rejects `target === sourceLeafId`, so "has the move happened" is always decidable: the lane's leaf equals `intent.targetId` if and only if the move committed. Per crash site: +When the hook supplies a summary, `step_started` stores the complete provisioned `BranchSummaryEntry` with `fromHook: true` and its optional preplanned hook-usage id; there is no `step_attempt` or `branch_summary_prepared`. Both sources therefore have one durable complete payload before the move. Generated payloads have `fromHook: false`. The move commits first among tree/fact effects, and the later entry append chains off the durable target. No multi-object atomic write exists. -| crash after | recovery sees | action | -|---|---|---| -| `operation_started` | leaf at `sourceLeafId` | rerun hook or summary step, then move | -| summary generated | nothing durable of the text | regenerate under the same attempt cap | -| lane move | leaf at `intent.targetId` | append summary if `summaryEntryId` missing | -| summary entry | entry exists | set label, finish | -| label | fact set (idempotent) | finish | +Acceptance returns `InvalidNavigation` before writing `operation_started` when `target === sourceLeafId` or when `target === null` and a label is present. Root is not an entry and has no label fact. With `summarize: true`, the durable states and actions are exhaustive: + +| current leaf and durable result state | action | +|---|---| +| source leaf, no structural step | run `before_navigation` again; decline, persist its complete result in `step_started`, or select generated work with `step_started` | +| source leaf, hook-source `step_started` | repair its preplanned usage when needed, then move using its stored payload | +| source leaf, generated step without `branch_summary_prepared` or `step_failed` | start or retry numbered attempts; on success write usage then the complete prepared record | +| source leaf, generated `step_failed` | finish failed; never move | +| source leaf, durable hook or generated payload | move to the target unless abort already won | +| target leaf, durable payload, summary entry absent | append that exact payload; never call the hook or provider | +| summary-entry leaf, exact summary entry present | atomically append the accepted label and completed finish when labeled; otherwise append only the finish | + +A target leaf without a durable payload, a summary entry whose id is not the leaf, or any other leaf is corruption. With `summarize: false`, only source-before-move and target-after-move are valid; no structural step or summary entry exists. A pre-move abort finishes aborted and leaves any prepared payload only in records. Once the move commits, abort cannot undo it: recovery appends the durable summary when required, writes the label, and finishes completed without model or hook lookup. -Between the move and `operation_finished`, readers see the lane at the target with an open navigation — a recoverable state, not an invalid one. The lane runs nothing else meanwhile; one operation per lane already guarantees that. +The accepted label and `operation_finished` are consecutive logical mutations in one atomic append. No write can interleave, and no crash prefix exists between them. The accepted label therefore wins over every label write ordered before navigation completion, while a write ordered after the terminal record remains newer. No fact id or operation-specific fact identity is needed. + +Between the move and `operation_finished`, readers see the target or summary leaf with an open navigation — a recoverable state, not an invalid one. The lane runs nothing else meanwhile; one operation per lane already guarantees that. ### Deferred provider request ```text -R step_attempt stream options request deferred execution -E assistant message stop reason deferred, carries the handle +R step_started assistant generation G +R step_attempt G attempt 1; response D1, usage U1 +E assistant message D1 stop reason deferred, complete handle H +R usage U1 lane suspends; prompt() resolves with outcome "suspended" ... hours pass, maybe a different process ... - resume() newest entry on the lane's path is a deferred - assistant message with no successor - → the handle is unredeemed, redeem it - fetchDeferred(model, handle) model and handle from that entry -E assistant message the real result - run continues normally + resume() #1 D1 is the newest unredeemed source +R step_started stable deferred-fetch step F; copies G config and policy once +R step_attempt F poll 1; source D1, response D2, usage U2 + fetchDeferred(model, H, wait: 0) provider/model and complete handle from D1 +E assistant message D2 still deferred; distinct entry, complete handle H unchanged +R usage U2 + lane suspends + resume() #2 D2, not D1, is now the newest source +R step_attempt F poll 2; source D2, response D3, usage U3 + fetchDeferred(model, H, wait: 0) +E assistant message D3 still deferred; another distinct entry with handle H +R usage U3 + lane suspends + resume() #3 the same stable step continues from D3 +R step_attempt F poll 3; source D3, response D4, usage U4 + fetchDeferred(model, H, wait: 0) +E assistant message D4 ready, interrupted, or terminal; always durable +R usage U4 + ready continues; interrupted suspends; terminal fails ``` -The suspended lane is indistinguishable from a crashed one in storage: an open operation whose newest entry is a deferred assistant message with no successor. Restore lists it as suspended; `resume()` checks the handle. Redemption writes no intent record: it starts no new model work, and a committed successor entry prevents another fetch. +The suspended lane is indistinguishable from a crashed one in storage: an open operation with an unredeemed deferred source. Restore lists it as suspended. The first `resume()` for this provider work creates one stable `deferred_fetch` step and copies the total configuration and normalized retry policy from G, the assistant generation step that persisted D1. Later polls read those copies from F and never look up G again. The exact source handle supplies the provider and model for each fetch. If a ready response contains tool calls, F's copied active tool names select the environmental implementations even when the lane configuration changed while suspended. + +Every poll attempt records its exact source entry plus fresh response and usage ids before fetching. Attempts are numbered consecutively across F. A pending response advances the source lineage to its own distinct entry even when its complete handle is unchanged; an unmarked interrupted response leaves its named source unredeemed. A committed response prevents the same attempt from fetching again. -Each `resume()` performs one fetch. Three outcomes: +Each `resume()` performs at most one fetch and always calls `fetchDeferred` with `wait: 0`; it checks once rather than long-polling. A caller schedules later resumes, using `pollAfterMs` when present. Deferred polling emits no `retry_scheduled`, `retry_start`, or `retry_end` events. Four outcomes: -- **pending** — the provider returns stop reason `deferred` again. Nothing but a possible `usage` record is written (section 15); the lane re-suspends. Poll cadence is application policy. -- **ready** — a normal assistant message. It is appended as the successor and the run continues. -- **terminal** — the provider returns stop reason `error` (expired, unknown, consumed), or the fetch itself rejects; the harness converts a rejection to the same error-message form. The message is appended and the run finishes failed. Redemption failure never starts an automatic replacement request; steering or follow-up input already accepted for this run can still start a later turn. +- **pending** — the provider returns stop reason `deferred` again. The complete response entry and its usage record are appended, its complete handle must equal the source handle, and the lane re-suspends on this new source entry. Repeated pending answers therefore produce durable D2, D3, and so on, and each later poll names the newest one instead of D1. +- **interrupted** — the provider returns stop reason `aborted` and no earlier `abort_requested` exists. The complete response and usage are durable and omitted from context. Count provider attempts that name this same `sourceEntryId`; below the copied policy cap, re-suspend on that exact source handle and let the next `resume()` wait the captured backoff before making one later numbered attempt. An intervening pending response creates a new source entry and resets this per-source attempt count. At the cap, fail the run. +- **ready** — a normal assistant message. It and its usage record are appended, then the run continues. Tool calls use the active names copied onto F; provider/model fetch identity still comes from the exact source handle. +- **terminal** — the provider returns stop reason `error` (expired, unknown, consumed), or the fetch itself rejects; the harness converts a rejection to the same durable error-message form. The response entry and usage record are appended, then the run finishes failed. Redemption failure never starts an automatic replacement generation request; steering or follow-up input already accepted for this run can still start a later turn. -`abort()` on a suspended lane: `abort_requested` record, best-effort cancellation of the handle at the provider, then normal reconciliation and `operation_finished` aborted. The deferred entry stays in the transcript. +`abort()` on a suspended lane writes `abort_requested`, best-effort cancels the newest handle at the provider, applies accepted deferred writes, then writes `operation_finished` aborted. Existing deferred entries stay in the transcript and no assistant closure is added. Missing provider/model implementations do not block this abort-only path: cancellation is best effort and the persisted handle identifies it when the capability exists. Deferred assistant messages carry a handle, not content; they project to nothing in provider context. @@ -643,52 +982,86 @@ Deferred assistant messages carry a handle, not content; they project to nothing ### Restore -Opening a session restores every lane independently. Restore reads; it never appends and never starts effects. +Opening a session restores every lane independently. After the one-time pre-restore initialization of an unconfigured `main` described in section 8, restore reads only; it never appends and never starts provider, tool, hook, or timer effects. -Recovery starts with indexed discovery, not a full log scan: +Recovery uses indexed, bounded reads. For each lane: -1. `findOpenOperations(lane, { limit: 2 })` returns unfinished `operation_started` records newest first. Zero means idle, one means suspended, and two means corruption. Backends must answer this from replayed/indexed operation state; callers cannot infer it from only the newest start. -2. For an idle lane, one indexed query finds the newest run-kind `operation_started`, then filtered `queue_enqueued` / `queue_cancelled` queries above it reconstruct pending `nextRun` items. With no prior run, the same type-filtered queries read only pre-run queue state; unrelated usage adjustments are never scanned. -3. For a suspended lane, the open operation selects two bounded payload reads: - - **The lane's records** since that `operation_started`. Everything after the finish of the previous operation is irrelevant history. - - **The lane's own entries**: the path from its leaf back to the operation's anchor (`sourceLeafId`). These are exactly the entries this operation appended. +1. Read the newest total `lane_config` through its latest-value index. An already-configured format-4 lane without one is corruption; configuration never comes from tree entries or harness-option fallback. +2. Call `findOpenOperations(lane, { limit: 2 })`. Zero results means idle, one means suspended, and two means corruption. Backends answer from replayed or indexed open-operation state rather than by scanning starts and finishes. +3. Independently find the newest run-kind `operation_started`. From its `seq` exclusively, or from the start of the lane when no run has started, read only `queue_enqueued` and `queue_cancelled` records needed to reduce `nextRun`. This query runs for every lane, including when a newer compaction or navigation is open. Structural operations never consume or hide next-run input. +4. If an operation is open, read its records by exact `runId`, oldest first. The slice begins with the discovered `operation_started` and contains only that operation's records; completed operation history is not read. +5. Build the lane's complete **entry plan** from those record slices, then make one `getEntries(ids)` call for the plan. The plan contains every entry id whose presence can affect reduction: run initial-message ids; structural result ids from the operation intent, `step_started`, and `branch_summary_prepared`; assistant/fetch response ids from `step_attempt`; tool-result ids from `tool_batch_started`; operation queue and deferred-write target ids; and next-run queue target ids. A hook result and generated prepared branch summary are inline record payloads, not extra entry reads. Source handles, overflow links, and queue cancellations must refer to ids already supplied by those records and add no unplanned lookup. Existing entries are returned in one immutable map; absent ids remain legitimate crash-prefix state. +6. Pass the lane pointer, indexed configuration, bounded records, plan, and returned entries to the pure reducer. -Reduction may additionally perform point lookups for provisioned entry ids and bounded branch lookups for effective model, thinking, and active-tool configuration at the operation anchor. These are indexed lookups, not extra history scans. Every scan is bounded by the open operation or the still-relevant idle queue, not by total session history or another lane's activity. +The planner performs only record-local checks needed to construct a safe exact-id query: discriminants agree, referenced plans exist, and provisioned ids are unique in the roles that require uniqueness. The reducer then applies the relevant section 5 relationships to the bounded prefix and returned entries. Restore does not walk from the lane leaf to `sourceLeafId`, read the operation's conversation branch, inspect a completed operation, scan unrelated adjustment records, or read another lane's traffic. Ordinary context or structural preparation may perform a branch query later, when the resumed procedure actually reaches that work; that is execution, not restore. -An idle lane's remaining state is pending next-run queue items. Next-run messages can be enqueued at any time; only the acceptance of a run consumes them — compaction and navigation pass over the queue. Pending items are the `queue_enqueued` records after the lane's most recent run-kind `operation_started` whose provisioned entries do not exist and that no `queue_cancelled` retracts. Items a run captured are listed in its intent's `initialMessages`, so a captured-but-unappended item is completed by that run's recovery and is never offered to the next run. +Next-run reduction is deliberately separate from the open-operation slice. A pending next-run item is an enqueue after the newest run-start boundary whose target entry is absent and which no matching cancellation retracts. Items before that boundary belong to the newest run's captured `initialMessages`, even if a later compaction or navigation is now open. This gives the structural-open trace one interpretation: + +```text +R operation_started run A captures all earlier nextRun items +R operation_finished run A completes +R queue_enqueued nextRun N, target entry absent +R operation_started manual compaction B remains open at crash +X crash + restore B is suspended; N is still pending for the next run +``` -### The reduction +### Entry-plan reduction -From those two reads, the lane's state: +Entry-plan construction and lane reduction are pure functions: they perform no storage reads, writes, effects, id allocation, or runtime model/tool lookup. Equal inputs produce equal outputs. Existing planned entries are indexed by id and ordered by their storage `seq` only where chronology matters; reduction never infers operation ownership from a tree path. -- **aborting** — an `abort_requested` record exists. -- **attempts used** — the newest `step_attempt`, when its `resultEntryId` has no entry, is the unfinished step; its `attempt` field is the durable count, its kind and `compactionReason` select the resume path. Closure is a point lookup, not adjacency inference: a step is closed exactly when the newest attempt's provisioned result exists. Earlier attempts' unfulfilled ids belong to finished work and need no inspection. -- **overflow recovery used** — a compaction `step_attempt` with reason `overflow` is newer than the newest consumed conversational message of this run (section 6, overflow guard). -- **tool batch** — the newest assistant entry with tool calls, each call matched against `tool_started` records and result entries (section 6, crash-site table). The assistant stop reason is retained: a `length` batch is truncated and never executes on recovery. Persisted `terminate` values on result entries decide whether the completed batch forces another turn. -- **deferred handle** — the newest own entry is a deferred assistant message with no successor. -- **newest own entry** — the last entry of the second read; the pure predicates (`needsAssistant()`, terminal failure, abort closure) read it. -- **pending queue items** — `queue_enqueued` records whose provisioned entry does not exist, excluding items retracted by `queue_cancelled` and steer/follow-up items killed by this run's `abort_requested`. -- **pending writes** — `write_deferred` records whose provisioned entry does not exist. -- **missing initial messages** — provisioned ids from the run intent without entries. -- **structural targets** — for compaction and navigation: does the provisioned result entry exist. +The reducer derives: -The same rules run live: during normal execution the harness updates this state in memory as it writes; restore recomputes it from storage. State and records cannot disagree, because the state is defined as their reduction. `usage` records are invisible here: they are accounting, never orchestration. +- **configuration and next-run state** — the newest indexed total replacement becomes the lane configuration. The independent next-run slice becomes `pendingNextRun`, whether the lane is idle or has any operation kind open. +- **abort request** — the sole `abort_requested` record, when present, plus the steer/follow-up items it killed. The killed payloads are derived from queue records before the marker and are stable across repeated `abort()` calls and restore. +- **steps and attempts** — each `step_started.id` defines one stable `stepId`; attempts group by that id and are consecutive. The start supplies captured configuration, policy, trigger, reason, and structural result id as applicable. A generated structural source has numbered attempts; a hook source instead contains the complete result and optional usage-record id and has none. A compaction step closes when its result entry or `step_failed` exists. A generated branch-summary step stops provider work when `branch_summary_prepared` exists and closes when its exact result entry appears; a hook branch-summary already has its payload on the start. +- **assistant/fetch settlement** — each attempt identifies its exact planned response and usage ids. The valid newest-attempt prefixes are attempt absent, attempt with response absent, response present with usage absent, and response plus usage. Usage without response is corruption. A durable response remains represented when a later attempt or transition exists. +- **post-response transition** — no general outcome record exists. A newer attempt of the same step, an overflow compaction naming the exact response and matching trigger, durable tool-plan/start/result state, deferred suspension implied by a pending response or below-cap unmarked fetch interruption, or `operation_finished` shows that the corresponding branch advanced. An unrelated later step cannot supersede a response. Response plus usage without a linked transition must be classified again. +- **overflow recovery used** — a compaction `step_started` with reason `overflow` carries the current assistant trigger and names the exact response omitted by that recovery. A compaction for an older trigger does not consume the current trigger's allowance. +- **tool batch** — `tool_batch_started` fixes one result id for every source call. The planned assistant response supplies each source-indexed call. An existing planned result is complete; a missing result with `tool_started` is an unknown real effect; a missing result without `tool_started` has not durably selected a real effect. Existing tool-usage records remain billed but do not complete a call. Stored result `terminate` values determine continuation. +- **deferred handle** — the newest unredeemed deferred response is the source. Before a fetch step exists, the original response's assistant step supplies the values that will be copied; after it exists, only the fetch step's copies are used. An equal-handle pending response advances the source to its own entry. A below-cap unmarked `aborted` fetch response retains its exact source, and attempts naming that source determine its cap. +- **pending operation input** — absent planned initial-message, queue-target, and deferred-write entries become `missingInitialMessages`, pending steer/follow-up, and pending writes after cancellation and abort rules are applied. +- **structural state** — source kind, complete hook result, generated `branch_summary_prepared`, planned result presence, `step_failed`, and lane-pointer equality determine compaction completion and every pre-move/post-move navigation prefix. For summarized navigation, source leaf means not moved, target leaf means moved but summary not appended, and summary-entry leaf means moved and appended. The reducer rejects a move without a durable payload. No branch content is required and no post-move generation state exists. +- **newest own entry and terminal failure** — the highest-`seq` existing entry in the open operation's plan is its newest own entry. Only a step-produced assistant error or an unmarked `aborted` response at its applicable captured cap becomes terminal-failure provenance; an arbitrary deferred-write message cannot. -### Resume +Section 5 validity is enforced only for the discovered open operation, the independent still-relevant next-run slice, and the planned entries needed to interpret or repeat them. Restore does not re-audit completed records, unrelated tree entries, historical facts, or another lane. Append-time validation and focused conformance tests enforce the full write contract; bounded restore asks only whether its current prefix is safe and unambiguous. -`resume()` continues the open operation from what the reduction says: +Live execution installs the same state transitions in memory after each commit. Fresh reduction of that durable prefix must equal live state, but this fixed-point comparison is a test invariant. Production does not reread storage after settlement, suspension, or finish. -- missing initial messages → append them (accepted input is never lost), even when aborting. -- aborting → reconcile: synthetic tool results, closing assistant message, `operation_finished` aborted. -- unresolved tool batch → per call: skip, re-execute, or synthesize (section 6). -- deferred handle → redeem (section 6). -- terminal failure — the newest own message is a step-produced assistant error (a give-up entry, a non-retryable request error, or a failed redemption; never an arbitrary deferred-write message) → apply accepted writes and consume queued conversational input; if nothing consumed starts new work, append `operation_finished` failed. Recovery never completes such a run. -- unfinished step → resume that exact step before consuming new checkpoint input: next attempt if the cap allows, else fail the operation. A compaction step resumes with its recorded `compactionReason`. -- otherwise → continue at the next checkpoint; pending writes and queue items apply normally there. +### Ordinary procedure re-entry -Recovery appends are ordinary appends with one extra rule: skip any provisioned id that already exists. A crash during recovery therefore leaves less to recover; re-running recovery is always safe. Recovery repeats an unknown effect only when its policy permits it: a retryable step starts a new durable attempt, and a tool replays only when both replay declarations say `safe`. Interrupted hook handlers follow the section 11 replay table. +`resume()` does not dispatch to recovery-only continuations and no program counter is persisted. It invokes the same run, compaction, or navigation procedure as live execution. Each procedure reads the reduced state and reaches the first unfinished ordinary transition: -Old v3 sessions contain no records. Every lane question answers "idle"; section 12 normalization restores `main` at the final retained logical entry after discarded fact-like entries resolve through their nearest retained ancestor. +| reduced prefix | ordinary re-entry | +|---|---| +| missing accepted initial messages | append the missing planned entries before other operation work, including while aborting | +| assistant `step_started`, no attempt | `assistantStep` commits attempt 1, then makes the provider request | +| assistant attempt, response absent | `assistantStep` treats the effect as unknown: start the next numbered attempt below the captured cap, or settle the missing planned response as synthetic interruption at the cap | +| assistant/fetch response present, usage absent | `persistMissingResponseUsage` reconstructs the preplanned usage record before classification or abort completion | +| accounted assistant response, no linked transition | `assistantStep` runs the ordinary pure classifier; retry, linked overflow, suspension, terminal failure, tool planning, or checkpoint follows normally | +| accepted tool-call response, no batch plan | `runToolBatch` commits the complete plan before clearance | +| tool plan with missing results | `reconcileToolBatch` proceeds in source order: rerun clearance for no-start calls, replay only safe started calls, or append the required interrupted, genuine-length, or abort synthetic under the planned id | +| original or pending deferred source, no fetch attempt awaiting settlement | `redeemDeferred` creates the fetch step once if needed, commits one attempt for the exact source, and performs at most one check-once fetch for this `resume()` | +| fetch attempt, response absent | `redeemDeferred` treats the poll as unknown: use a later numbered attempt below the copied per-source cap, or settle the missing id as terminal interruption at the cap | +| accounted fetch response | ordinary deferred classification advances an equal-handle pending source, retains an interrupted source below cap, accepts a ready response, or enters terminal-failure drain | +| compaction operation or auto-compaction before a durable step | the ordinary compaction procedure runs its decision hook and persists either the complete hook result or a generated source before continuing | +| hook structural source, result absent | repair its preplanned hook usage when present, then commit the stored result if abort has not won | +| generated compaction with no attempt, unknown final attempt, result, or `step_failed` | `summaryStep` respectively starts attempt 1, starts a later numbered attempt below cap, commits its typed result directly, or enters the ordinary structural failure path; no prepared-result record exists | +| generated branch summary without prepared result or `step_failed` | `summaryStep` starts or retries attempts; success writes reported usage and `branch_summary_prepared` before navigation can move | +| navigation at source with a durable hook/prepared payload | `navigationProcedure` conditionally commits the move without another hook or provider effect | +| navigation at target with summary absent | append the exact durable payload; never regenerate it | +| navigation at its summary-entry leaf, or unsummarized navigation at target | atomically append the label and finish when labeled; otherwise append the finish | +| pending writes or conversational queues | the ordinary checkpoint applies writes, consumes eligible input, and re-evaluates assistant need | +| terminal assistant/fetch failure | the ordinary failure-drain checkpoint applies writes and consumes eligible input; absent new work it finishes failed | +| no unfinished transition | the ordinary checkpoint or structural finish boundary conditionally appends `operation_finished` | + +An abort marker takes priority after missing initial messages and response accounting are repaired. If a compaction result or navigation move already committed, the ordinary structural procedure completes that committed structure. Otherwise `abortPath` settles a missing active assistant/fetch response under its existing id, completes planned tool results without replay, best-effort cancels a deferred handle, applies pending writes, and finishes aborted. No unrelated assistant entry is appended. + +Recovery appends use the planned-entry presence already returned by the single batched lookup. The in-memory state is updated after each recovery write, so re-entry skips an entry that now exists and verifies that existing planned content is compatible. A crash during recovery therefore leaves a shorter prefix for the same ordinary procedure; a second recovery is safe. No recovery write occurs during restore itself. + +Runtime identities are checked only immediately before the ordinary effect that needs them: the exact captured/source model before a provider request or required fetch, and the relevant tool implementation before an invocation or safe replay. Synthetic settlement, usage repair, persisted hook-result commit, prepared-summary append, queue/write application, finish, and non-replay tool reconciliation do not require those identities. Abort-only reconciliation bypasses model/tool checks entirely; best-effort deferred cancellation runs only when its capability can be resolved. Navigation performs every provider or hook effect before its move, so completing a committed move never needs runtime model identity. `SuspendedOperation.missing` is the forecast for the next required effect, not the union of every name present in lane configuration. + +Interrupted hook handlers follow the section 11 replay table. Old v3 sessions contain no durable operation records, so restore reports normalized `main` idle at its final retained logical entry; legacy configuration entries never initialize the v4 lane configuration. # Part III — API and implementation @@ -712,17 +1085,19 @@ interface AgentLane { compact(options?: { customInstructions?: string }): Promise; navigateTree(targetId: string | null, options?: NavigateOptions): Promise; resume(): Promise; // continue this lane's open operation - abort(): Promise; // durable on resolve; reconciliation runs in background + abort(): Promise; // first call is durable on resolve; reconciliation runs in background + // repeated calls while aborting return the same drained input // Queues. Durable on resolve (queue_enqueued record); the returned // entryId identifies the item until consumption. steer/followUp require - // an active run; nextRun and cancelQueued work anytime. + // an active run. nextRun works while idle or during any operation and + // only queues input; it never starts a run. cancelQueued works anytime. steer(text: string, images?: ImageContent[]): Promise; steer(message: AgentMessage): Promise; followUp(text: string, images?: ImageContent[]): Promise; followUp(message: AgentMessage): Promise; - nextRun(text: string, images?: ImageContent[]): Promise; - nextRun(message: AgentMessage): Promise; + nextRun(text: string, images?: ImageContent[]): Promise; + nextRun(message: AgentMessage): Promise; /** Durably retract a pending queue item (queue_cancelled record). */ cancelQueued(entryId: string): Promise; /** Append an adjustment usage record (section 5): reconciliation, @@ -739,9 +1114,10 @@ interface AgentLane { executeAction(): Promise; runToCompletion(): Promise; - // Persisted configuration — entries on the path behind this lane's leaf, - // resolved by point queries. Setters resolve on durable acceptance; - // while a run is open they become deferred writes on this lane. + // Persisted total configuration. Getters read the newest lane_config; + // getModel resolves its durable reference through Models. Each setter + // commits an immediate total replacement on this lane's mutation line, + // including while an operation is open. getModel(): Promise; setModel(model: Model): Promise; getThinkingLevel(): Promise; setThinkingLevel(level: ThinkingLevel): Promise; getActiveTools(): Promise; setActiveTools(names: string[]): Promise; @@ -762,8 +1138,9 @@ All prompt overloads normalize to `AgentMessage[]`. Text plus images becomes one ```ts class AgentHarness implements AgentLane { - /** Opens the session, restores every lane, starts no effects. - One suspended entry per lane with an open operation. */ + /** Initializes an unconfigured main when needed, then restores every + lane without starting provider, tool, hook, or timer effects. One + suspended entry per lane with an open operation. */ static create(options: AgentHarnessOptions): Promise<{ harness: AgentHarness; suspended: SuspendedOperation[]; @@ -779,9 +1156,10 @@ class AgentHarness implements AgentLane { lanes(): Promise; // Harness-global configuration: registries and runtime capabilities. - // Tool implementations are code and cannot persist; the active set - // (names) persists per lane. - getTools(): Promise; setTools(tools: AgentTool[], activeNames?: string[]): Promise; + // Tool implementations are code and cannot persist; active names live + // only in each lane's total configuration. setTools replaces only the + // global registry; use a lane's setActiveTools to change activation. + getTools(): Promise; setTools(tools: AgentTool[]): Promise; getResources(): Promise; setResources(r: Resources): Promise; getStreamOptions(): Promise; setStreamOptions(o: StreamOptions): Promise; getRetryPolicy(): Promise; setRetryPolicy(p: RetryPolicy): Promise; @@ -793,13 +1171,15 @@ class AgentHarness implements AgentLane { event stream. No transcripts; compose with lane.watch(). */ watchSession(): Promise<{ snapshot: SessionSnapshot; start; unsubscribe }>; - // Harness-global. Every hook and event payload carries `lane`. + // Registries are harness-global. Every hook payload carries `lane`; + // events are either lane-scoped or harness-global as section 10 defines. hooks: Hooks; events: Events; - /** Detach cleanly. Signals in-flight effects, waits for the append in - progress, releases the writer claim. Open operations stay resumable; - no shutdown record is needed. */ + /** Detach cleanly. Stops admission, signals in-flight effects, rejects + parked manual actions, drains appends already accepted by Session, then + closes Session and releases its writer claim. Open operations stay + resumable; no shutdown record is needed. */ close(): Promise; } @@ -820,11 +1200,12 @@ interface AgentHarnessOptions { session: Session; models: Models; // provider collection for all requests - // Initial lane configuration — used when a lane's path has no persisted - // config entries; persisted config wins otherwise. + // Immutable lane seed captured at create(). It initializes main when the + // session is first attached and every lane later created by this harness; + // it is never a fallback for a configured lane. model: Model; - thinkingLevel?: ThinkingLevel; - activeToolNames?: string[]; + thinkingLevel?: ThinkingLevel; // seed default: "off" + activeToolNames?: string[]; // seed default: initial tool names // Runtime capabilities — harness-global, reconstructed at create() tools?: AgentTool[]; @@ -861,6 +1242,10 @@ interface AgentHarnessOptions { } ``` +`AgentHarness.create()` normalizes the three option fields into one immutable `LaneConfiguration`, copying the active-name array and storing the model as `{ provider, modelId }`. A fresh session and a normalized v3 session have an unconfigured `main`; before the restore phase, the harness appends the seed as `main`'s first `lane_config`. Every already-configured format-4 lane is read only from its newest `lane_config`; the options seed never overrides it. Any additional format-4 lane without a configuration record is corruption. + +`createLane(name, at)` uses the same captured seed even if `main` or another lane has since changed. Its one storage commit creates the pointer and first `lane_config` atomically. Later setters replace only the targeted lane's total value and do not mutate the seed. Therefore reopening with different options can affect lanes created by that new harness instance, but cannot change an existing lane without a setter. + ### Results and tagged errors The public API uses a small vendored subset of the `better-result` v3 pattern. `packages/agent` does not take a runtime dependency on `better-result`. @@ -944,6 +1329,7 @@ The remaining classes use the same base: | `NoActiveOperation` | `lane` | | `NothingToResume` | `lane` | | `InvalidMessage` | `lane`, `reason` | +| `InvalidNavigation` | `lane`, `reason` | | `UnknownSkill` | `name` | | `UnknownTemplate` | `name` | | `UnknownTarget` | `targetId` | @@ -963,11 +1349,14 @@ interface OperationError { message: string; } +type OptionalFinalAssistant = + | { finalEntryId: string; finalMessage: AssistantMessage } + | { finalEntryId?: never; finalMessage?: never }; + type RunOutcome = | { kind: "completed"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } - | { kind: "aborted"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } - | { kind: "failed"; leafId: string; error: OperationError; - finalEntryId?: string; finalMessage?: AssistantMessage } + | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) | { kind: "suspended"; leafId: string; finalEntryId: string; deferred: DeferredHandle }; type CompactionOutcome = @@ -984,9 +1373,10 @@ type NavigationOutcome = type RunRejected = LaneBusy | InvalidMessage | UnknownSkill | UnknownTemplate | Closed; type CompactionRejected = LaneBusy | NothingToCompact | Closed; -type NavigationRejected = LaneBusy | UnknownTarget | Closed; +type NavigationRejected = LaneBusy | InvalidNavigation | UnknownTarget | Closed; type ResumeRejected = LaneBusy | NothingToResume | MissingIdentities | Closed; type QueueRejected = NoActiveRun | InvalidMessage | Closed; +type NextRunRejected = InvalidMessage | Closed; type CancelQueuedRejected = UnknownQueueItem | Closed; type AbortRejected = NoActiveOperation | Closed; @@ -994,6 +1384,7 @@ type RunResult = Result<{ runId: string } & RunOutcome, RunRejected>; type CompactionResult = Result<{ runId: string } & CompactionOutcome, CompactionRejected>; type NavigationResult = Result<{ runId: string } & NavigationOutcome, NavigationRejected>; type QueueResult = Result<{ entryId: string }, QueueRejected>; +type NextRunResult = Result<{ entryId: string }, NextRunRejected>; type CancelQueuedResult = Result<{ outcome: "cancelled" | "already_consumed" | "already_cleared"; }, CancelQueuedRejected>; @@ -1014,6 +1405,10 @@ type ResumeResult = Result; type CreateLaneResult = Result; ``` +`QueueResult` is the active-run contract for `steer` and `followUp`; `NoActiveRun` belongs only to that contract. `NextRunResult` has no active-operation rejection: while the harness is open, valid input is accepted while idle or during a run, compaction, or navigation. Acceptance appends only the operation-independent queue record. It does not accept or start a run; the next run acceptance captures the item. + +`navigateTree()` returns `InvalidNavigation` before its decision hook or any durable write when the target is the current leaf, or when a label is supplied for the `null` root target. A non-null unknown entry instead returns `UnknownTarget`. Root-label facts are not added. + `cancelQueued` outcomes mirror the mutation-line histories: `cancelled` means the entry will never be appended; `already_consumed` means the entry exists (the model saw or will see it); `already_cleared` means abort drained the item or an earlier cancel won. A storage write failure is not an `Err`. It faults the harness and rejects the promise with `HarnessFault`: @@ -1039,9 +1434,9 @@ class HarnessClosed extends Error { Calls on a faulted harness reject with the same `HarnessFault` instance until the session is reopened. `close()` rejects process-local promises for accepted operations with `HarnessClosed`; their durable operations remain open and resumable. Result-returning calls made after `close()` return `Err(new Closed(...))`; other calls reject with `HarnessClosed`. An invariant violation also rejects. Promise rejection therefore means a defect or a dead harness, not an expected operation outcome. These errors do not belong to public `Result` error unions. -`finalMessage` is the run's newest entry that projects to an assistant message; `finalEntryId` is that entry's id. `leafId` is the lane's leaf when the operation finished — the race-free anchor for branch queries (`findEntriesOnBranch({ start: leafId })`). The two differ when a deferred write was applied after the final assistant message. Full transcripts are not duplicated into results; they are in the session and were delivered as events. +`finalMessage` is the run's newest durable assistant response and `finalEntryId` is that response entry's id. On an aborted or failed run both fields are absent when no assistant attempt settled; otherwise both refer to the newest existing response, which need not have stop reason `aborted`. `leafId` is the lane's leaf when the operation finished — the race-free anchor for branch queries (`findEntriesOnBranch({ start: leafId })`). It can differ from `finalEntryId` when a deferred write or required tool result was appended later. Full transcripts are not duplicated into results; they are in the session and were delivered as events. -**Type provenance.** Core conversation and tool types (`AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, `ThinkingLevel`) come from `packages/agent/src/types.ts`. Provider types (`Model`, `Models`, `Usage`, `RetryPolicy`, stream options, deferred handles) come from `packages/ai`. The generic telemetry contract and schema machinery come from `packages/telemetry`; the AI-request and harness span schemas come from `packages/agent/src/harness/telemetry.ts`. Session, harness, hook, event, result, snapshot, navigation, and durable-record types are defined under `packages/agent/src/harness/`. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runToolBatchForSingleCall`, request/option bags such as `AssistantRequest` and `FactWrite`) are constructive implementation detail, not contract. +**Type provenance.** Core conversation and tool types (`AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, `ThinkingLevel`) come from `packages/agent/src/types.ts`. Provider types (`Model`, `Models`, `Usage`, `RetryPolicy`, stream options, deferred handles) come from `packages/ai`. The generic telemetry contract and schema machinery come from `packages/telemetry`; the AI-request and harness span schemas come from `packages/agent/src/harness/telemetry.ts`. Session, harness, hook, event, result, snapshot, navigation, and durable-record types are defined under `packages/agent/src/harness/`. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runPlannedToolCall`, and request/option bags such as `AssistantRequest`) are constructive implementation detail, not contract. ### Suspended operations @@ -1053,10 +1448,15 @@ interface SuspendedOperation { startedAt: number; // Unix ms, from the operation_started record reason: "crash" | "deferred"; prompt?: AgentMessage[]; // runs: normalized original prompt - deferred?: DeferredHandle; // reason "deferred" + deferred?: DeferredHandle; // reason "deferred": pending response or + // below-cap unmarked fetch interruption aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; // abort accepted pre-crash; - // cleared payloads, offered for requeue - missing: { tools: string[]; models: string[] }; // non-empty: resume() returns Err + // stable cleared payloads returned by repeated abort, + // offered for requeue + /** Identities required by the next ordinary effect, not every name in the + lane configuration. Recomputed as reduction advances; abort-only + reconciliation never reports or blocks on these. */ + missing: { tools: string[]; models: string[] }; } ``` @@ -1064,10 +1464,17 @@ interface SuspendedOperation { ```ts // Interactive pi. suspended has 0 or 1 entries, always "main". -const { harness, suspended } = await AgentHarness.create({ session, models, model }); +const { harness, suspended } = await AgentHarness.create({ + session, + models, + model, + thinkingLevel: "off", + activeToolNames: tools.map((tool) => tool.name), + tools, +}); for (const s of suspended) await (await harness.lane(s.lane))!.resume(); -await harness.prompt("fix the bug"); -await harness.steer("focus on the tests"); +await harness.nextRun("focus on the tests"); // legal while idle; queues input but starts no run +await harness.prompt("fix the bug"); // captures the queued item, then starts the run await harness.setModel(opus); // Slack bot. Channel = session + main; thread = lane, keyed by thread id. @@ -1078,8 +1485,10 @@ if (!thread) { if (!created.ok) return handleLaneError(created.error); thread = created.value; } +// The new lane has the immutable options seed, not configuration from +// pingedEntryId, main, or another lane. await thread.prompt("summarize this thread"); // parallel to main and other threads -await thread.setModel(haiku); // this thread only +await thread.setModel(haiku); // immediate total replacement; this thread only await thread.session.appendMessage(msg); // this thread's branch // Thread renderer: this lane only. @@ -1112,11 +1521,11 @@ start((event) => send(client, event)); // flush buffer in order, `watch()` captures the snapshot and starts buffering in one step. `start(listener)` flushes the buffer in order and switches to live delivery. Each event arrives exactly once, in order. No sequence numbers, no registration race. `unsubscribe()` drops the subscription and its buffer; a watcher that never calls `start()` buffers without bound. -`watch()` is lane-scoped: this lane's transcript, operation state, queues, pending writes, and only this lane's events. A Slack thread renderer sees its thread and nothing else. `watchSession()` is the session-wide observer: lane inventory, no transcripts, unfiltered event stream. A dashboard composes both: `watchSession()` for the overview, `lane.watch()` per opened thread. +`watch()` is lane-scoped: this lane's transcript, operation state, queues, and pending writes, plus this lane's events and the harness-global events defined below. A Slack thread renderer sees no other lane's transcript or lane-scoped activity. `watchSession()` is the session-wide observer: lane inventory, no transcripts, unfiltered event stream. A dashboard composes both: `watchSession()` for the overview, `lane.watch()` per opened thread. ```ts interface QueuedItem { - entryId: string; // correlates with QueueResult and cancelQueued + entryId: string; // QueueResult/NextRunResult and cancelQueued correlation message: AgentMessage; } @@ -1135,8 +1544,9 @@ interface LaneSnapshot { /** status "suspended": everything a client needs to offer resume/abort. The same data create() returned; a remote UI only sees snapshots. */ suspended?: SuspendedOperation; - /** Live progress, when mid-turn. What the watcher would have - accumulated from streaming events. */ + /** The current assistant/fetch response draft. It remains present from + message_start until the matching response entry commits. After + message_end it is final but still non-durable until entry_added. */ streamingMessage?: AssistantMessage; runningTools: { toolCallId: string; @@ -1148,7 +1558,7 @@ interface LaneSnapshot { }; queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; - pendingWrites: { id: string; entry: ProvisionedEntry }[]; + pendingWrites: { id: string; entry: ProvisionedEntry }[]; faulted: boolean; // harness-wide, mirrored into every snapshot } @@ -1162,22 +1572,24 @@ interface SessionSnapshot { Rules: - Configuration is not in snapshots. Getters return the current value; `config_update` events (section 10) tell a UI when to re-read. One source of truth. -- `streamingMessage` and `runningTools` let a client that attaches mid-turn render immediately, without replaying events. -- Reconnect means a new `watch()`. Against a living harness the new snapshot includes live progress. Only process death loses stream state: a restored harness has no partial streams to report, and the snapshot shows the suspended operation instead. The durable transcript is complete either way. Surviving transport drops is the serving layer's job. -- A lane watcher receives the section 10 event vocabulary filtered to its lane, plus harness-global events such as `fault` and `usage`. `watchSession()` and `events.on(type, listener)` receive everything; `events.on` is live-only — no snapshot, no buffer. +- `streamingMessage` and `runningTools` let a client that attaches mid-turn render immediately, without replaying events. `streamingMessage` is not part of `transcript`: `message_end` replaces it with the final post-hook value but does not clear it; the matching `entry_added` confirms the append, adds the entry to `transcript`, and clears the draft. A fault or process death can remove a draft that never committed. +- Direct messages and finalized tool-result messages use the same immediate `message_start` → `message_end` lifecycle, then enter `transcript` only on `entry_added`. They never populate `streamingMessage`, whose type is assistant-only. `runningTools` follows `tool_start` through `tool_end`; durable tool-result visibility still comes from `entry_added`. +- An `aborting` snapshot reports only durable/live state that actually exists. It does not synthesize a streaming assistant message. After reconciliation, the operation disappears; the transcript may be unchanged by abort except for required planned tool results and accepted deferred writes. +- Reconnect means a new `watch()`. Against a living harness the new snapshot includes live progress. Only process death loses stream state: a restored harness has no partial streams to report, and the snapshot shows the suspended operation instead. Every entry in the durable transcript is complete; a lost draft was never an entry. Surviving transport drops is the serving layer's job. +- A lane watcher receives events whose `lane` matches, plus events with no lane. The harness-global `usage` event is the explicit exception: it carries the originating lane but is delivered to every lane watcher because its totals are session-wide. `watchSession()` receives the unfiltered stream, and `events.on(type, listener)` observes matching events across the whole harness. `events.on` is live-only — no snapshot, no buffer. - Watchers are independent; each has its own buffer and its own `start()` gate. ## 10. Events -One flat stream. `events.on(type, listener)` receives everything; lane watchers receive their lane's events (section 9). +One flat stream. `events.on(type, listener)` receives matching events across the harness; lane watchers apply the lane/global filter in section 9. Guarantees: -- Passive. A throwing listener is caught and reported as a `handler_error` event plus telemetry; it never affects execution. A listener that throws while handling `handler_error` goes to telemetry only. +- Passive. Listeners cannot replace or mutate execution values; event payloads are isolated from the objects retained by the procedure. A throwing listener is caught and reported as a `handler_error` event plus telemetry; it never affects execution. A listener that throws while handling `handler_error` goes to telemetry only. Hooks are the only interception surface. - Ordered. Delivery follows process order, identical for watchers and `events.on`. Concurrent lanes do not promise `seq`-ordered passive delivery; durable consumers use `getLog()`. - Not persisted, not replayed. Reconnect means a new `watch()`. -- Events that report durable facts fire after the fact is committed; what an event announces is already queryable. -- Events report final values, after hook transformation. +- Events whose contracts announce durable facts fire after commit. In particular, `entry_added` means its entry is queryable. For a multi-write append, no commit event fires until the whole append succeeds; events then follow logical mutation order. Labeled navigation therefore emits its `fact_update` before its operation-end event, with both durable before either event. Lifecycle events describe process-local work and need not be durable: `message_end` explicitly precedes the attempted entry append. +- Completion events report final values after the relevant transformation hook. Streaming updates are intermediate; `after_response` produces the final streamed response before `message_end`, and `after_tool` produces the final tool result before `tool_end` and its tool-result message lifecycle. If an abort marker wins after `message_end` but before response append, the later `entry_added` carries the required normalized `aborted` response and is authoritative. - Payloads are JSON-serializable and secret-free; a server can proxy them verbatim. Live objects (models, tools) are referenced by name, never embedded. - Lane-scoped events carry `lane: string` (omitted below); harness-global events omit it — except `usage`, which is delivered harness-globally and carries the record's lane in its payload. Operation-scoped events carry `runId`; turn-scoped events carry `turnId`; recovered work carries `recovery: true`. @@ -1188,9 +1600,12 @@ Guarantees: { type: "run_start"; runId } { type: "run_resume"; runId } // resume() entered (any operation kind) { type: "run_suspend"; runId; deferred: DeferredHandle } // lane parked -{ type: "run_abort"; runId; steer: AgentMessage[]; followUp: AgentMessage[] } // abort accepted; cleared payloads -{ type: "run_end"; runId; outcome: "completed" | "aborted" | "failed"; - leafId; finalEntryId?; finalMessage?; error? } +{ type: "run_abort"; runId; steer: AgentMessage[]; followUp: AgentMessage[] } // first abort accepted; emitted once +({ type: "run_end"; runId; leafId } & ( + | { outcome: "completed"; finalEntryId: string; finalMessage: AssistantMessage; error?: never } + | ({ outcome: "aborted"; error?: never } & OptionalFinalAssistant) + | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant) +)) { type: "fault"; code; message } // harness-wide { type: "handler_error"; error; stack? } & ({ kind: "hook"; hook } | { kind: "event"; event }) @@ -1201,11 +1616,16 @@ Guarantees: { type: "retry_start"; runId; step; attempt } { type: "retry_end"; runId; step; attempt; success: boolean; finalError? } -// Messages. Every message entering the tree fires these, regardless of -// source. message_end means committed; entryId is the tree entry. +// Messages. Every produced user, assistant, and tool-result message keeps +// the existing lifecycle. Direct and tool-result messages emit start/end +// back-to-back; only assistant/fetch streams emit updates. after_response +// has already produced the final response seen by message_end. message_end +// means the message stream ended, not that an entry committed; a direct +// message has a zero-update stream. entryId, when present, is the provisioned +// intended id and is not proof of append. { type: "message_start"; runId?; message: AgentMessage } { type: "message_update"; runId; message: AgentMessage; event: AssistantMessageEvent } // streaming only -{ type: "message_end"; runId?; message: AgentMessage; entryId: string } +{ type: "message_end"; runId?; message: AgentMessage; entryId?: string } // Tools { type: "tool_start"; runId; turnId; toolCallId; toolName; args } // effective args @@ -1213,15 +1633,17 @@ Guarantees: { type: "tool_end"; runId; turnId; toolCallId; toolName; result; isError; terminate } // Tree, queues, facts -{ type: "entry_added"; entry: Entry } // non-message entries -{ type: "write_pending"; runId; entryId; entry } // deferred write accepted; entry_added - // or message_end follows with the same id +{ type: "entry_added"; entry: Entry } // every committed entry, including messages +{ type: "write_pending"; runId; entryId; entry } // deferred write accepted; message lifecycle may + // occur later, but entry_added confirms commit { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] } { type: "fact_update" } & ( - | { fact: "name"; name: string | undefined } - | { fact: "label"; targetId: string; label: string | undefined }) + | { fact: "name"; name: string | undefined } + | { fact: "label"; targetId: string; label: string | undefined } + | { fact: "custom"; key: string; value: JsonValue | undefined }) -// Configuration. Compact payloads; clients re-read via getters. +// Configuration. A lane setter has committed one total lane_config before +// this compact property event fires; clients re-read via getters. { type: "config_update" } & ( | { property: "model"; value: { provider; modelId }; previous } | { property: "thinkingLevel"; value; previous } @@ -1238,7 +1660,7 @@ Guarantees: oldLeafId; newLeafId; summaryEntry?; error? } // Lanes -{ type: "lane_created"; at: string | null } +{ type: "lane_created"; at: string | null } // pointer and seed lane_config committed // Cost. Harness-global delivery — every watcher receives it — with the // record's lane in the payload. totals is the session-wide ledger sum as @@ -1253,19 +1675,26 @@ Guarantees: ```text run_start + message_start / message_end / entry_added consumed prompt/queue messages turn_start - message_start / message_update* / message_end assistant committed - tool_start / tool_update* / tool_end per call - message_end tool results, source order + message_start / message_update* / message_end assistant/fetch stream finished + entry_added response committed + tool_start / tool_update* / tool_end per real call + message_start / message_end tool results, source order + entry_added each tool-result entry committed turn_end - compaction_start ... compaction_end auto, at a checkpoint, when needed + compaction_start ... entry_added ... compaction_end auto, at a checkpoint, when needed turn_start ... turn_end until nothing is pending run_end ``` -A UI's busy indicator spans `run_start`..`run_end`, and the `compaction_start`/`navigation_start` brackets for standalone operations. Resumed structural operations re-emit their start event (`recovery: true`) so brackets always balance. +A UI's busy indicator spans `run_start`..`run_end`, and the `compaction_start`/`navigation_start` brackets for standalone operations. Resumed structural operations re-emit their start event (`recovery: true`) so brackets always balance. Compaction and branch-summary provider streams are internal and emit no public `message_start`, `message_update`, or `message_end`; their committed typed result emits `entry_added`, and structural start/end plus retry events describe their orchestration. -Failed attempts emit `retry_scheduled`, then `retry_start`, then `retry_end` when retrying resolves either way. `run_suspend` ends event flow for the parked lane; the next `run_resume` continues it. +For a streamed assistant-generation or deferred-fetch response, the exact order is: `message_start`, zero or more `message_update`, `after_response`, `message_end` with the final transformed stream value and optional intended id, response-entry append, `entry_added`, preplanned usage commit, then classification. Thus `message_end` can be the last response event even when the append later faults; only `entry_added` proves durability. If `abort_requested` wins between end and append, settlement normalizes the committed response as section 6 requires, and `entry_added` reports that durable value. A direct message, synthetic response, or finalized tool result has no updates but keeps the same immediate `message_start` → `message_end` before its append, followed by `entry_added` only if that append commits. Existing entries skipped during recovery emit nothing because events are not replayed. + +A durable retryable assistant response below the captured cap — including an unmarked `aborted` provider interruption — emits `retry_scheduled`, then `retry_start`, then `retry_end` when the later numbered attempt resolves either way. An abort during the delay starts no later attempt and therefore emits no lifecycle events for one. Deferred-fetch polls never emit those retry lifecycle events: a pending poll emits `run_suspend` with its new equal-handle source, and an interrupted poll emits `run_suspend` with its retained source; a later `run_resume` may make the next poll. + +Abort emits `run_abort` once and eventually `run_end`. Assistant message events occur only when an already-intended assistant/fetch attempt settles or is synthetically settled under its provisioned id. An abort between steps, during tool work, or while suspended can therefore have no abort-specific assistant event; `run_end.finalMessage`/`finalEntryId` then refer to the newest response that already exists or are both absent. Planned tool-result message lifecycles, their `entry_added` confirmations, and accepted deferred-write events still precede `run_end` when reconciliation appends those entries. Structural operations emit no assistant-message lifecycle for abort: `compaction_end` / `navigation_end` reports `aborted` when the marker won before the commit point and `completed` when the structural commit won first. ## 11. Hooks @@ -1289,7 +1718,7 @@ Semantics, uniform across all hooks: - Handlers run sequentially in registration order. Each transformation handler sees the output of the previous one; returned `messages` append and a returned `systemPrompt` replaces the current value. - A throwing handler does not fail the run: it is skipped, reported via `handler_error`, and the remaining handlers run. One exception: `before_tool` fails closed — a throwing handler blocks the tool. A skipped policy handler must not allow a tool it might have blocked. - Hook results that feed durable state are persisted before execution proceeds: `before_run` output lands in the `operation_started` record, `before_tool` effective arguments in the `tool_started` record, and the finalized `after_tool` result plus `terminate` decision in the tool-result entry. The hook's return alone is not durable; a crash before that commit can run it again. -- Events report post-hook values; observers never see pre-hook state. +- Events report post-hook values; observers never see pre-hook state. Event listeners are passive and cannot transform those values. An extension that needs to replace a response uses `after_response`, not `message_end`. ### Catalog @@ -1351,8 +1780,11 @@ before_payload: { result: { payload: unknown } | undefined; } -// Per response, after the stream finishes, before the assistant message -// is committed. The committed message is what events and the session see. +// Per response, after provider streaming settles but before message_end and +// before the assistant entry append. Its returned message is the final stream +// value seen by message_end and is the input to settlement. If abort wins +// before append, settlement may still normalize the durable copy to aborted; +// entry_added and the session expose that authoritative committed value. after_response: { event: { status: number; headers: Record; message: AssistantMessage }; result: { message?: AssistantMessage } | undefined; // must keep role @@ -1376,19 +1808,24 @@ after_tool: { // Structural operations ------------------------------------------------ -// Decline, adjust, or supply the summary. Runs after operation_started, -// live and on resume alike. Not re-run when the result entry exists or -// any step_attempt for this work already exists (hook-written or generated -// — records cannot distinguish them, and neither needs the hook again). +// Decline, adjust, or supply the summary. Runs after operation_started. +// If it supplies output, the harness first constructs the complete typed +// provisioned entry and persists it on step_started; if it selects provider +// generation, step_started persists that choice and policy. Either source +// record prevents this decision hook from running again. before_compaction: { + /** For reason "overflow", preparation already omits the exact response + named by the pending or durable overflow link. */ event: { reason: "manual" | "threshold" | "overflow"; preparation: CompactionPreparation; customInstructions? }; - /** A supplied compaction persists as a CompactionEntry with fromHook: true. */ + /** A supplied compaction is materialized completely on step_started, then + appended exactly as a CompactionEntry with fromHook: true. */ result: { decline?: boolean; compaction?: CompactResult } | undefined; } before_navigation: { - event: { targetId; preparation: NavigationPreparation }; - /** A supplied summary persists as a BranchSummaryEntry with fromHook: true. */ + event: { targetId; preparation: NavigationPreparation; customInstructions? }; + /** A supplied summary persists completely on step_started, then as the + exact BranchSummaryEntry with fromHook: true after the move. */ result: { decline?: boolean; summary?: { summary: string; details?; usage? } } | undefined; } ``` @@ -1405,7 +1842,7 @@ Hooks re-run only where the work itself re-runs. Persisted outputs are never rec | `after_response` | per response | per response | per response | | `before_tool` | per call | — | not when `tool_started` exists | | `after_tool` | per executed result | — | on safe replay only | -| `before_compaction`, `before_navigation` | per operation | no | not when a result entry or any `step_attempt` for this work exists | +| `before_compaction`, `before_navigation` | once until a structural source commits | no | not when any structural `step_started` for this work exists; hook output is persisted there | | `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | ## 12. Session and SessionTree @@ -1425,9 +1862,6 @@ interface EntryBase { interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; terminate?: true } -interface ModelChangeEntry extends EntryBase { type: "model_change"; provider: string; modelId: string } -interface ThinkingLevelEntry extends EntryBase { type: "thinking_level_change"; thinkingLevel: string } -interface ActiveToolsEntry extends EntryBase { type: "active_tools_change"; activeToolNames: string[] } interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; retainedTail: AgentMessage[]; tokensBefore: number; details?; usage?; fromHook: boolean } @@ -1435,21 +1869,21 @@ interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fro details?; usage?; fromHook: boolean } interface CustomEntry extends EntryBase { type: "custom"; customType: string; data? } -type Entry = MessageEntry | ModelChangeEntry | ThinkingLevelEntry | ActiveToolsEntry - | CompactionEntry | BranchSummaryEntry | CustomEntry; +type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; ``` A harness-written assistant `MessageEntry` always contains a `SettledAssistantMessage`; `pending` is rejected before any durable write. A v4 tool-result `MessageEntry` additionally persists the finalized batch-control decision as `terminate?: true` beside `message`. It is orchestration state for the reduction (section 7), never model context; the projection to provider messages ignores it. `AgentToolResult.terminate` exists at the tool API level but `ToolResultMessage` does not carry it, so the entry field is the durable form. For compaction and branch-summary entries, `fromHook: true` means the summary was supplied by `before_compaction` or `before_navigation`; `false` means the harness generated it. The field is required on every v4 entry. This durable provenance is also an ownership boundary for `details`: harness-generated summaries may use a harness-owned shape that later summary preparation can interpret (for example, cumulative file tracking), while hook-supplied details are opaque and must never be interpreted by the harness. -Every v4 compaction — generated or hook-supplied — stores the complete `retainedTail`; an empty tail is `[]`, never omission. The compaction entry is a self-contained checkpoint: context builds never read past it. Entry `usage` fields — on assistant messages, tool results, compactions, and branch summaries — are immutable display snapshots of the response(s) that produced that entry: a message entry matches its one producing record; a compaction or branch-summary entry carries its successful attempt's request(s), never failed attempts. The durable ledger is the `usage` records; effective cost including later adjustments is a read-time ledger query by `entryId` (sections 5, 13). +Every v4 compaction — generated or hook-supplied — stores the complete `retainedTail`; an empty tail is `[]`, never omission. The compaction entry is a self-contained checkpoint: context builds never read past it. For an overflow compaction, both summary preparation and this stored tail omit the exact `supersededResponseEntryId` named by its `step_started`; the response remains a separate tree entry. Entry `usage` fields — on assistant messages, tool results, compactions, and branch summaries — are immutable display snapshots of the response that produced that entry: an assistant/fetch message entry supplies the payload for its attempt's preplanned usage record; a real planned tool result keeps only that finalized execution's own `AgentToolResult.usage`, while a synthetic result has none; a compaction or branch-summary entry carries its successful attempt's request(s), never failed attempts. The durable ledger separately retains every execution and replay `usage` record; effective cost including later adjustments is a read-time ledger query by `entryId` (sections 5, 13). -v3 files additionally contain `custom_message`, `label`, and `session_info` entries, plus old compaction entries that use `firstKeptEntryId`. Load normalizes them before exposing the v4 tree: +v3 files additionally contain `custom_message`, `label`, `session_info`, `model_change`, `thinking_level_change`, and `active_tools_change` entries, plus old compaction entries that use `firstKeptEntryId`. These names are decoder vocabulary only; format 4 exposes no configuration entry type. Load normalizes them before exposing the v4 tree: - `custom_message` becomes a custom agent message. - `label` and `session_info` become global facts (latest by file position wins) and disappear from the logical tree. A label targets its nearest retained parent. -- Each retained child of a discarded entry is reparented to the discarded entry's nearest retained ancestor. +- Legacy model, thinking-level, and active-tool entries disappear. They do not initialize or alter `LaneConfiguration`; harness attachment uses the immutable options seed for an unconfigured normalized `main`. +- Each retained child of a discarded fact-like or legacy-configuration entry is reparented to that entry's nearest retained ancestor. - `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. - An old compaction resolves `firstKeptEntryId` against its own branch and materializes that range as `retainedTail`. V4 never exposes or persists `firstKeptEntryId`. - Existing `details` and `usage` on compaction and branch-summary entries are preserved unchanged. Existing `fromHook` provenance is preserved; an absent v3 value normalizes to `false`. @@ -1459,7 +1893,7 @@ Read-only opens keep the physical v3 file unchanged; the first v4 write persists ### SessionTree -The tree-facing contract. Each lane exposes one view (`lane.session`); `Session` itself implements it for `main`. Reads pass through always. A write through a lane view enters that lane's mutation line: while a run is open — including suspension and cancellation — it becomes a durable deferred write; during compaction or navigation it waits for the operation to end; on an idle lane it appends directly. Writes on a standalone `Session` (no harness attached) apply immediately. +The tree-facing contract. Each lane exposes one view (`lane.session`); `Session` itself implements it for `main`. Reads pass through always. A tree-entry write through a lane view enters that lane's mutation line: while a run is open — including suspension and cancellation — it becomes a durable deferred write; during compaction or navigation it waits for the operation to end; on an idle lane it appends directly. Global-fact setters instead commit immediately as one semantic fact mutation through `Session.append()`; they neither enter a lane queue nor move a leaf. Writes on a standalone `Session` (no harness attached) apply immediately. ```ts interface EntryQuery { @@ -1482,12 +1916,17 @@ interface SessionTree { getEntry(id: string): Promise; getStats(): Promise; - // Global facts. Latest wins; not branch-scoped. "set", not "append": - // append vocabulary is reserved for tree writes. + // Global facts. Latest wins; not branch-scoped. The application surface + // says "set"; tree methods say "append". Low-level Session.append() is the + // storage-mutation primitive, not application fact vocabulary. Keys live + // only in the custom namespace and cannot collide with name or labels. + // undefined deletes a label or custom fact; JSON null is a custom value. getName(): Promise; setName(name: string | undefined): Promise; getLabel(targetId: string): Promise; setLabel(targetId: string, label: string | undefined): Promise; + getCustomFact(key: string): Promise; + setCustomFact(key: string, value: JsonValue | undefined): Promise; /** Session-wide, all branches, sequence order. */ findEntries(query?: EntryQuery): Promise; @@ -1509,40 +1948,82 @@ Query semantics: a branch scan takes the path from `start` to root, walks it in - `newestFirst` with `stopAtType: "compaction"` ends at the newest compaction: the context window. - `type` and `customType` filter results; a `stopAt` entry is returned only if it passes the filter. - Extension patterns: effective state = `findEntryOnBranch({ type: "custom", customType })`; collections = `findEntriesOnBranch(...)`; global inventory = `findEntries(...)`. -- Context build is a branch scan with `stopAtType: "compaction"`, projected through `entryProjectors` and `toProviderMessages`. Its projection is the compaction summary, the materialized `retainedTail`, then the entries after the compaction; nothing before the compaction is read. +- Context build is a branch scan with `stopAtType: "compaction"`. Its base sequence is the compaction summary, the materialized `retainedTail`, then entries after the compaction; nothing before the compaction is read. Before `transform_context` and `toProviderMessages`, harness projection omits assistant messages whose stop reason is `error`, `aborted`, or `deferred`. A genuine output-limit `length` message projects normally. A response classified as overflow is not generically marked on its entry: the linked overflow compaction omits that exact entry from its preparation and materialized `retainedTail`. Custom entries then project through `entryProjectors`, and the resulting `AgentMessage[]` passes through `toProviderMessages`. - `SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. -Read consistency: finders and `getEntry` return committed entries only. A deferred write is not in the tree until applied; a handler that appends and immediately queries does not see its own write. Pending writes are visible in the snapshot, correlated by provisioned id. +Read consistency: finders and `getEntry` return committed entries only. A deferred write is not in the tree until applied; a handler that appends and immediately queries does not see its own write. Pending writes are visible in the snapshot, correlated by provisioned id. When a `SessionTree` is attached to a harness, a direct or later-applied message write emits its immediate message lifecycle before the append and `entry_added` after commit; a standalone `Session` has no harness event stream. ### Session `Session` adds the lane surface and the record log. It is usable standalone — no harness required. In production the harness writes records; recovery fixtures and Tier A tests prefill them through the same API. Lanes, entries, and facts are Session-level. ```ts +type FactWrite = + | { fact: "name"; name: string | undefined } + | { fact: "label"; targetId: string; label: string | undefined } + | { fact: "custom"; key: string; value: JsonValue | undefined }; + +type SessionMutation = + | { kind: "entry"; lane: string; entry: ProvisionedEntry } + | { kind: "record"; record: NewRecord } + | { kind: "fact"; fact: FactWrite } + | { kind: "lane"; action: "create" | "move"; lane: string; leafId: string | null }; + +type NonEmptySessionMutations = + readonly [SessionMutation, ...SessionMutation[]]; + +/** One committed logical mutation. Entry LogItems are lane-free: routing + belongs only to the corresponding input SessionMutation. Facts and lane + changes have no durable id. */ +type LogItem = + | { kind: "entry"; entry: Entry } + | { kind: "record"; record: LaneRecord } + | { kind: "fact"; seq: number; fact: FactWrite } + | { kind: "lane"; seq: number; action: "create" | "move"; + lane: string; leafId: string | null }; + class Session implements SessionTree { // bound to "main" constructor(storage: SessionStorage, options?: { idGenerator?: IdGenerator }); /** Process-local id provisioning used by Session and harness. Default UUIDv7; tests inject a deterministic generator. Sync by design. */ readonly idGenerator: IdGenerator; + /** Stops new calls, lets appends already admitted by Session settle, drains + the storage queue, then releases backend resources. Reopen through the + repository to use the durable session again. */ + close(): Promise; + /** SessionTree bound to a lane: reads default to its leaf, appends chain to it and advance it. The only write-binding mechanism; no SessionTree method takes a lane parameter. view("main") behaves like the Session. */ view(lane: string): SessionTree; + /** Batched exact-id lookup used by bounded reduction. The input ids must + be unique. The immutable result contains only existing requested ids, + keyed by id; missing ids are omitted and no unrequested entry appears. */ + getEntries(ids: readonly string[]): Promise>; + // Lanes — permanent named pointers. Durable via storage (section 13). getLanes(): Promise<{ lane: string; leafId: string | null }[]>; - createLane(lane: string, at: string | null): Promise; // rejects existing names + /** Latest total replacement; undefined only for a fresh or normalized-v3 + main before its first harness attachment. */ + getLaneConfig(lane: string): Promise; + /** Atomically creates the pointer and first total configuration with + [lane create, lane_config]. Session provisions the record id and returns + the committed LaneConfigRecord. */ + createLane(lane: string, at: string | null, + configuration: LaneConfiguration): Promise; // rejects existing names moveLane(lane: string, to: string | null): Promise; - /** Low-level provisioned append for the harness, recovery, and test - fixtures. Bypasses the SessionTree deferral policy; a harness caller - already holds the lane mutation line. */ - appendEntry(entry: ProvisionedEntry, lane: string): Promise; + /** Low-level atomic append for the harness, recovery, and test fixtures. + Bypasses SessionTree deferral policy. Session validates the whole input + before dispatch; a harness caller already holds the lane mutation line. + Arrays are non-empty, apply in order, and return expanded logical items. */ + append(mutation: SessionMutation): Promise; + append(mutations: NonEmptySessionMutations): Promise; - // Records — harness and recovery write these; applications may append - // usage adjustment records (section 5) and nothing else. - appendRecord(record: NewRecord): Promise; + // Harness and recovery append orchestration records through append(). + // Applications use recordUsage() rather than constructing records. findRecords( query: RecordQuery & { type: K }, ): Promise[]>; @@ -1579,34 +2060,38 @@ interface RecordQuery { } ``` +Semantic Session and SessionTree methods construct mutations and inspect the returned `LogItem` discriminants. Array results correspond positionally to input mutations, so processing can retain an entry mutation's routing lane without copying it into the returned item. `createLane()` extracts the second array item as its `LaneConfigRecord`; entry/record Effects similarly recover their committed typed payloads while keeping their semantic return types. Storage itself exposes no parallel typed write methods. + `Session` exposes no `getStorage()` escape hatch: all writes flow through `Session`, which is the single writer the storage contract assumes. -**Ownership rule:** after an application passes a `Session` to `AgentHarness.create()`, it mutates that session only through the harness and its lane views until `close()` resolves. Concurrent writes through the original standalone reference are unsupported caller misuse; the harness adds no machinery for it. +**Ownership rule:** after an application passes a `Session` to `AgentHarness.create()`, it mutates that session only through the harness and its lane views until `close()` resolves. Concurrent writes through the original standalone reference are unsupported caller misuse; the harness adds no machinery for it. Harness close calls `Session.close()`, so the closed object is not reused; a later process or harness reopens the same durable session through its repository. ## 13. Storage ### Contract -One session per storage instance. Storage persists and answers queries; `Session` owns validation and view binding. Storage never executes operations, queues, or recovery. Record payloads are opaque except for indexed columns and the required open-operation recovery projection. +One session per storage instance. Storage persists and answers queries; `Session` owns validation and view binding. Storage never executes operations, queues, or recovery. Record payloads are opaque except for indexed query columns, the latest lane-configuration projection, and the required open-operation recovery projection. Exact entry-plan lookup is by the entry id index and does not interpret entry payloads. ```ts interface SessionStorage { getMetadata(): Promise; + /** Stops new calls, drains already-accepted writes, stops background + renewal, and releases only this instance's writer claim. */ + close(): Promise; - // Lanes - getLanes(): Promise<{ lane: string; leafId: string | null }[]>; - createLane(lane: string, at: string | null): Promise; - moveLane(lane: string, to: string | null): Promise; - - /** Durable on resolve. Input carries no parentId, seq, or timestamp; - storage assigns all three. parentId is the lane's current leaf; the - entry becomes the lane's new leaf, in the same transaction. Callers - cannot pass a stale parent because they never pass one. */ - appendEntry(entry: ProvisionedEntry, lane: string): Promise; - appendRecord(record: NewRecord): Promise; + /** The only storage write primitive. One call is one queued atomic storage + mutation. A non-empty array applies in order with consecutive seq values. + The result expands into one LogItem per logical mutation. */ + append(mutation: SessionMutation): Promise; + append(mutations: NonEmptySessionMutations): Promise; // Reads + getLanes(): Promise<{ lane: string; leafId: string | null }[]>; + getLaneConfig(lane: string): Promise; getEntry(id: string): Promise; + /** One exact batched lookup. Input ids are unique; missing ids are omitted. + Returned map and entries are immutable and contain no unrequested id. */ + getEntries(ids: readonly string[]): Promise>; findEntries(query?: EntryQuery): Promise; /** start is mandatory here; defaulting to a lane's leaf is view sugar. */ findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise; @@ -1617,31 +2102,40 @@ interface SessionStorage { findOpenOperations(lane: string, options?: { limit?: number }): Promise; getLog(options?): Promise; - // Global facts - getName(): Promise; setName(name: string | undefined): Promise; - getLabel(id: string): Promise; setLabel(id, label): Promise; + // Global-fact reads. Writes use append({ kind: "fact", ... }). + getName(): Promise; + getLabel(id: string): Promise; + getCustomFact(key: string): Promise; getStats(): Promise; } ``` Contract rules, all backends: -- One monotonic `seq` across entries, records, facts, and lane moves. -- Storage linearizes concurrent writes from all lanes of the session and allocates `seq` inside each write's atomic commit; callers never read, reserve, or increment the sequence. Write promises resolve in commit order. The lane mutation line (section 15) serializes decisions; this rule serializes the writes underneath them — both are needed, neither replaces the other. -- A write is durable when its promise resolves; events fire after. -- `Session` and the harness provision ids with `session.idGenerator`; storage enforces per-session uniqueness at append. -- Every durable payload must be JSON-serializable. `Session` validates before dispatch so Memory, JSONL, and SQLite accept the same values; Memory does not retain values JSONL would reject. -- Reads return immutable data. +- One monotonic `seq` across entries, records, facts, and lane changes. +- `append()` accepts one mutation or a non-empty array. Session validates and JSON-checks the complete input before dispatch. The backend then validates against transactional intermediate state, applies every logical mutation in array order, assigns consecutive `seq` values with no interleaving, and commits all or none. A later mutation in the same call observes earlier lane, entry, record, fact, open-operation, configuration, and statistics changes. +- An entry mutation's `lane` is routing envelope data, not part of `Entry` or its committed `LogItem`. Storage assigns `parentId`, `seq`, and `timestamp`; its parent is that lane's leaf after preceding mutations in the same append, and the entry becomes the new leaf. The returned item occupies the same position as its input mutation, which preserves routing correlation during append processing without persisting or returning lane ownership. +- A record mutation receives its assigned `seq` and `timestamp`. Fact and lane mutations receive sequence positions represented by their returned `LogItem`. `getLog()` expands every append into its logical items and applies ordering, cursor, and limit to those expanded items. +- A configured lane has at least one `lane_config`; the newest is its whole current value. A setter is one record mutation. A lane-create mutation is valid only when the next mutation in the same append is that lane's first total `lane_config`. `Session.createLane()` emits exactly `[lane create, initial lane_config record]`, with consecutive positions in that order, and returns the second item's committed record. Neither half can be observed alone. +- A completed navigation that accepted a label is valid only when its exact label fact immediately precedes `operation_finished` in the same append. `finishOperation()` emits `[label fact, operation_finished]`, with consecutive positions in that order. No other write can interleave, so the accepted label wins over every fact write ordered before completion. Unlabeled completion appends only the terminal record. +- A hook-sourced structural `step_started` stores its complete provisioned typed result and optional preplanned hook-usage id. A generated `branch_summary_prepared` stores its complete result payload before navigation moves; generated compaction has no prepared record. Both are ordinary record payloads and need no backend-specific transaction or index. +- A `tool_batch_started` is one ordinary atomic record whose payload contains the complete source-index/result-id plan. Tool starts and tool usage are later ordinary records; planned results are ordinary entry appends. Backends need no batch transaction or tool-specific backend index. +- Storage linearizes append calls from all lanes of the session; callers never read, reserve, or increment `seq`. Append promises resolve in commit order. The lane mutation line (section 15) serializes decisions; this rule serializes storage underneath them — both are needed, neither replaces the other. +- An append is durable when its promise resolves. Returned `LogItem`s and nested payloads are immutable. Session installs all returned items into live projections only after success, then emits commit events in logical mutation order. No observer sees part of a multi-write append. Process-local message/tool lifecycle events may still precede the entry mutation they announce. +- `Session` and the harness provision ids with `session.idGenerator`; storage enforces per-session uniqueness across the existing state and the full append input. +- Every durable payload must be JSON-serializable. `Session` validates the whole append before dispatch so Memory, JSONL, and SQLite accept the same values; Memory does not retain values JSONL would reject. +- Reads return immutable data. `getEntries(ids)` is one backend call for an exact unique-id set; a backend may chunk internally for parameter limits, but it returns one immutable map containing only existing requested entries. - `findOpenOperations` is a required recovery projection: Memory maintains it with its record state, JSONL derives it while replaying the file, and SQLite answers it from the lane's current open-operation projection. It returns unfinished starts newest first and must expose a second result when a replayed/imported backend observes multiple open operations so recovery can reject corruption. Backends with conditional current-state projections may reject a second `operation_started` append instead of creating that corruption through their normal write API. - No general conditional writes exist. Single-writer plus the lane mutation line make compare-and-set unnecessary for normal appends and pointer/fact updates. The lane open-operation projection is the narrow exception: starting an operation conditionally sets the lane's open operation from `null` to the run id, and a failed update means the lane is already busy. - One writer per session, enforced by the serving layer; SQLite additionally rejects a second writer itself. Per session, not per backend: one SQLite database hosts many sessions, each with its own single writer. -- Any write failure faults the harness (section 4). The store is left a valid prefix. -- Global-fact and lane-move history is kept, never rewritten: latest by `seq` wins. History is the cheaper implementation (insert, never update), and lane-move history is a reflog if anyone ever wants one. +- Any append failure faults the harness (section 4). Live state and events publish nothing from the failed call. Memory and SQLite roll it back; after a JSONL I/O failure or process death, reopen may observe either the preceding prefix or the whole append, but never only some logical mutations. Recovery treats that all-or-none storage outcome normally. +- Global-fact and lane-move history is kept, never rewritten: latest by `seq` wins. Built-in name and label facts use distinct kinds from application facts; an application key is interpreted only within the `custom` kind. A missing name/label/custom value is a deletion tombstone, while a present JSON `null` is a value. Facts have no ids and share no identity namespace with entries or records. History is the cheaper implementation (insert, never update), and lane-move history is a reflog if anyone ever wants one. +- `close()` first rejects new calls, then waits for every append already admitted to the backend's session-wide queue. Only after that queue drains does it stop renewal and release resources or a writer claim. Release is fenced: an instance may release only the owner/fence pair it acquired, so a stale close cannot release a newer writer. `Session.close()` first closes its own admission and lets already-admitted Session appends settle, then delegates to storage close; `AgentHarness.close()` first stops lane admission and signals process-local effects. No close method writes a session record, finishes an operation, or makes a durable open operation non-resumable. - For format-4 sessions, the token and cost fields returned by `getStats()` are the sum of `usage` records across all lanes — one rule, no entry-derived billing, and no double counting by construction. `messageCount` counts all message entries in the session tree, including entries copied into a fork. A fork initializes the count from its copied entries, then increments it for newly appended message entries. Backends maintain both as running projections, so reads and the `usage` event's totals are O(1). Format-3 sessions have no records; their usage stats stay entry-derived. The one-time v4 conversion writes one aggregate `adjustment` record (`details: { source: "v3-import" }`) summing the v3 entries' usage, so totals survive conversion. Outside the ledger's claim: the settle-to-write crash window, unreported mid-stream billing, tools that die without reporting, and extension-private LLM calls (section 1 non-goal) — though `adjustment` records let an application close even those after the fact. ### Memory -Plain structures: entry map, record list, lane map, fact lists, one seq counter, one session-wide write queue. Append validates, clones, allocates `seq` at the head of that queue, commits; reads clone out. The reference implementation: the parity test suite runs against it first. +Plain structures: entry map, record list, lane map, latest-config map, separate built-in/custom fact lists, running statistics, one seq counter, one session-wide mutation queue. Each `append()` is one queue job: clone transactional state, validate and apply its logical mutations in order with consecutive `seq`, then publish all changes together or none. The job resolves with its `LogItem`s only after success; Session then publishes live state and commit events. `getEntries` performs exact lookups in the entry map and returns one immutable map. Close rejects new calls and drains the queue. The reference implementation: the parity test suite runs against it first. ### JSONL @@ -1667,47 +2161,60 @@ A v3 `parentSession` path resolves to the parent header's id when that file is a The repository layout matches coding-agent v3. Under `sessionsRoot`, each resolved cwd uses a directory named `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`. New files are named `${createdAtIso.replace(/[:.]/g, "-")}_${sessionId}.jsonl`. `list({ cwd })` scans that cwd's directory; `list()` scans every direct child directory. Listing reads only each file's header and filesystem metadata; it does not open or replay the session. A file with a missing or malformed header is omitted from the result. First-write v3 conversion replaces the original file in place and never changes its directory or filename. -One file per session: a header line, then one JSON object per line, in `seq` order. Every logical mutation is exactly one line; a line is the atomic unit. +One file per session: a header line, then one physical JSON line per `SessionStorage.append()` call. One logical mutation, including a length-one array input, is written as its ordinary object. Two or more mutations are one non-empty JSON array line containing those same ordinary objects in order; there is no batch id, checksum, length, or other metadata. Physical lines are ordered by their first logical `seq`, and array elements occupy consecutive positions. ```text {"kind":"header", "version":4, id, createdAt, cwd, parentSessionId?, legacyParentSessionPath?, metadata?} -{"kind":"entry", "lane":"main", id, parentId, type, timestamp, ...} // append; advances main -{"kind":"entry", id, parentId, type, timestamp, ...} // fork import; advances no lane -{"kind":"record", "lane":"main", id, runId?, type, timestamp, ...} -{"kind":"lane", "lane":"slack:t1", "leafId":"e42"} // create or move -{"kind":"fact", "fact":"name", "name":"Refactor auth"} -{"kind":"fact", "fact":"label", "targetId":"e17", "label":"checkpoint"} -``` - -- Open reads the whole file into memory; all queries run against that state. One session-wide append queue serializes writes from every lane, one line each; the queue allocates `seq`, and its order is the line order. Every storage mutation in this section is exactly one line — nothing in the design needs a multi-line atomic write. -- The repository does not retain created or opened storage instances. It knows how to locate and load sessions, then transfers each storage and its write queue to the returned `Session`. Reopening loads a fresh storage instance; the serving layer's single-writer ownership rule prevents concurrent opens for writing. Repository operations are not serialized, so callers await operations with ordering dependencies. -- The optional `lane` on an entry line is envelope metadata and dies at decode. When present, the line atomically appends the entry and advances that lane; replay requires `parentId` to equal its current leaf. When absent, the line imports a fork entry without moving a lane. Entries expose `seq` but no lane. -- Torn tail: a malformed final line is the append that died mid-write. Open truncates it; the write was never acknowledged, nothing is lost. A malformed line anywhere else is corruption; open rejects. +{"kind":"entry", "lane":"main", "entry":{id,parentId,type,timestamp,...}} // append; advances main +{"kind":"entry", "entry":{id,parentId,type,timestamp,...}} // repository-private fork import +{"kind":"record", "record":{lane,id,runId?,type,timestamp,...}} // config, steps, plans, usage +{"kind":"lane", "action":"move", "lane":"slack:t1", "leafId":"e57"} +{"kind":"fact", "fact":{"fact":"name", "name":"Refactor auth"}} +{"kind":"fact", "fact":{"fact":"label", "targetId":"e17", "label":"checkpoint"}} +{"kind":"fact", "fact":{"fact":"custom", "key":"extension.example/state", "value":null}} +[{"kind":"lane","action":"create","lane":"slack:t1","leafId":"e42"}, + {"kind":"record","record":{lane:"slack:t1",id,type:"lane_config",timestamp,configuration:{...}}}] +``` + +The displayed configured-lane array is one physical line; it is wrapped above only for readability. + +- Open reads the whole file into memory; all queries, including one exact-map `getEntries` lookup, run against that state. One session-wide queue serializes append calls from every lane. Each call allocates its consecutive logical positions, serializes one object or one array plus the trailing newline into one buffer, and issues one `appendFile` call. The array overload still returns `LogItem[]` for a length-one input even though its physical encoding is the ordinary object. Replay expands arrays in element order for projections and `getLog()`. +- A complete array line is one transaction. Replay validates every element and all cross-element relationships against temporary state before publishing any; an invalid element, empty array, or complete transaction that violates ordering or references is corruption. A custom fact with `value` omitted is a deletion; `"value": null` is retained as JSON null. +- The repository does not retain created or opened storage instances. It knows how to locate and load sessions, then transfers each storage and its append queue to the returned `Session`. `close()` prevents another enqueue, waits until every previously enqueued append has settled, then releases the storage; no acknowledged or already-accepted append is skipped. Reopening loads a fresh storage instance; the serving layer's single-writer ownership rule prevents concurrent opens for writing. Repository operations are not serialized, so callers await operations with ordering dependencies. +- The optional `lane` on an encoded entry object is routing envelope metadata and dies at decode. `SessionStorage.append()` always supplies it: replay requires the committed `parentId` to equal that lane's current leaf, then advances the lane. A lane-less entry object is reserved for repository-private fork construction and advances no lane; it is not a `SessionMutation` and cannot appear in an append array. Both forms produce the same lane-free entry `LogItem`; entries expose `seq` but no lane. +- Torn tail: a malformed final object or array line is the append that died mid-write. Open discards that whole physical line, so no element of a torn array survives. A malformed interior line, or a complete but invalid object/array transaction, is corruption and open rejects. - Durability is process-crash level: a resolved append call. No fsync promise; if power-loss durability is ever needed, it becomes an explicit capability. -- v3 files: entries only, no `kind` tags. Open builds the normalized logical tree from section 12; every entry belongs to `main`, and `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. Before the first v4 append, the file is rewritten once with a v4 header (write temp, rename). This is the single conversion the compatibility policy allows. Read-only opens never rewrite. +- v3 files: entries only, no `kind` tags. Open builds the normalized logical tree from section 12; every entry belongs to `main`, and `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. Before the first format-4 mutation, the file is rewritten once with a v4 header (write temp, rename). This is the single conversion the compatibility policy allows. Read-only opens and close without mutation never rewrite. ### SQLite SQLite uses a greenfield schema with one persisted leaf per lane. ```sql -session_sequences (session_id, next_seq) -- atomic seq allocator +sessions (session_id, created_at, parent_session_id, metadata) -- repository catalog +session_stats (session_id, message_count, usage_payload) -- O(1) running projections +session_sequences (session_id, next_seq) -- atomic seq allocator entries (session_id, seq, id, parent_id, type, timestamp, payload) records (session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload) lanes (session_id, lane, leaf_id, open_operation_id) -- current pointer + open op projection lane_moves (session_id, seq, lane, leaf_id) -- history; getLog parity -facts (session_id, seq, kind, key, value) -- name, labels; latest by seq +facts (session_id, seq, kind, key, is_deleted, value) -- name, labels, custom; latest by seq branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) branch_tips (session_id, branch_id, tip_id) -- PRIMARY KEY (session_id, tip_id) writer_leases (session_id, owner_id, fence, expires_at_ms) -- writer claim -- indexes +entries: UNIQUE (session_id, id) -- exact and batched entry lookup records: (session_id, lane, type, seq), (session_id, lane, type, op_kind, seq) + (session_id, lane, run_id, seq) -- bounded open-operation slice +facts: (session_id, kind, key, seq) -- latest built-in/custom value or tombstone branch_entries: (session_id, branch_id, entry_type, entry_seq) (session_id, entry_id) -- reverse lookup: entry → branches ``` -`writer_leases` enforces one writer per session with expiring, fenced claims. Storage renews the claim inside every write transaction and while idle. Repository-owned cleanup releases only its matching owner and fence. +`records.run_id` stores the effective operation identity used by `RecordQuery.runId`: an `operation_started` row stores its own `id`, an operation-owned row stores its payload `runId`, and an operation-independent row stores null. The `(session_id, lane, run_id, seq)` index therefore returns the whole open slice, including its start, without an `OR` scan. + +`writer_leases` enforces one writer per session with expiring, fenced claims. Storage renews the claim inside every append transaction and while idle. After its admitted transaction queue drains, `close()` stops renewal and deletes the claim only with a matching `(session_id, owner_id, fence)` predicate. A stale owner therefore cannot release a replacement writer's claim. Each `append()` is one SQLite transaction: allocate one consecutive sequence range, validate and apply its logical mutations in order, update every projection, and commit all or none. Configured lane creation is therefore the ordinary `[lane create, lane_config record]` append, and the existing record index answers `getLaneConfig`. The facts index returns each latest built-in value, custom JSON value, or deletion tombstone without conflating SQL null with JSON null. `open()` acquires that writer claim. `list()` never acquires or renews writer leases: it reads every matching session directly from the session catalog and projects the latest name fact into the top-level `SqliteSessionMetadata.name` field for server-side inventory. Application-owned `SqliteSessionMetadata.metadata` remains unchanged. @@ -1723,13 +2230,13 @@ Two invariants carry the whole design: 1. Reverse index: look up `start` → any containing branch. 2. Range scan that branch, `entry_seq <= start.seq` (parent-before-child makes path order equal seq order), join entries, apply filters and stops. -**Append plan** — `appendEntry(entry, lane)`, one transaction. The storage instance queues writes before opening the transaction; the transaction increments the session's sequence row and uses the returned value, so concurrent lane calls cannot receive the same `seq` and their promises resolve in that order. +**Entry-mutation plan** — applied whenever an `append()` transaction reaches `{ kind: "entry", lane, entry }`. The storage instance queues complete append calls before opening the transaction and reserves enough consecutive sequence values for the whole call, so concurrent lanes cannot interleave their logical mutations and promises resolve in commit order. -1. `leaf = lanes[lane].leaf_id`; allocate `seq` from `session_sequences`; insert the entry with `parent_id = leaf`. +1. `leaf = lanes[lane].leaf_id`; use this mutation's assigned `seq`; insert the entry with `parent_id = leaf`. 2. `branch_tips` lookup: does a branch end at `leaf`? - Yes → insert one `branch_entries` row there; update that tip to the new entry. - No → new branch: copy rows `entry_seq <= leaf.seq` from any branch containing `leaf`, insert the new entry's row, insert its tip. (Empty lane: no copy, just the new branch.) -3. `lanes[lane].leaf_id = entry.id`. Update fact/stats projections. Commit, then events. +3. `lanes[lane].leaf_id = entry.id`. Update statistics and continue with the next logical mutation against this transactional state. After all mutations validate and apply, commit once; Session then installs the returned `LogItem`s and emits commit events in logical order. The four cases, `Bn: [...]` are one branch's rows in seq order: @@ -1749,7 +2256,7 @@ Case 2 — two lanes, one leaf. First extends, second copies. tree: a─b─c─u lanes: main→d, t1→u └─d -Case 3 — lane parked mid-history. createLane("t2", at=b), then append. +Case 3 — lane parked mid-history. createLane("t2", at=b, configuration=Cseed), then append. lanes: main→d, t2→b cache: B1:[a b c u], B2:[a b c d] t2 reads: b found in B1 (or B2), scan seq ≤ 2 — nothing built @@ -1767,7 +2274,7 @@ Case 4 — a branch still ends at an entry that has children. Stale branches (no lane resolves through them) are kept. -Every restore query is an index seek plus a bounded scan: a lane's open operation via `(lane, type, seq)`, its last run-kind start via `(lane, type, op_kind, seq)`, its records above the operation via the same index, its own entries via the read plan from its leaf. No query touches another lane's traffic. +Every restore query is an index seek plus a bounded scan or exact lookup: `lanes.open_operation_id` discovers the open operation, `(lane, type, op_kind, seq)` finds the newest run boundary, `(lane, type, seq)` reads still-relevant next-run queue records, `(lane, run_id, seq)` reads the open operation, and `(session_id, id)` serves its one batched entry-plan lookup. Restore does not use `branch_entries`; branch indexes remain for ordinary context and structural preparation. No query touches another lane's traffic. SQLite implementation follow-ups: @@ -1805,10 +2312,13 @@ export interface StreamAssistantConfig { signal?: AbortSignal; } -/** One provider request. Emits message_start / message_update / message_end - to the sink; returns the final assistant message. Provider errors are - in-band: stopReason "error" | "aborted" | "deferred". Does not mutate - its inputs — persistence is the caller's job. */ +/** One provider request. Emits message_start / message_update, runs the + after_response transform, then emits message_end with that final value; + returns the same final assistant message. A harness sink may attach the + attempt's provisioned entry id to message_end; compatibility callers may + omit it. Provider errors are in-band: stopReason "error" | "aborted" | + "deferred". Does not mutate its inputs — persistence happens later in + the caller. */ export function streamAssistant( messages: AgentMessage[], config: StreamAssistantConfig, @@ -1827,7 +2337,7 @@ interface AgentTool { } ``` -Three phases per call, exposed separately because the harness needs to write between them and recovery needs phase 2 and 3 without phase 1: +Three phases per call are exposed separately because the harness writes `tool_started` between phases 1 and 2 and recovery needs phases 2 and 3 without phase 1. The batch driver owns no durable ids: before calling it, the harness commits one `tool_batch_started` for every source call. The driver keeps passing the original `AgentToolCall` object to its callbacks, letting the harness map it to the source index without adding an index to hooks, events, or tool context: ```ts type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; tool: AgentTool; args: unknown }; @@ -1868,27 +2378,50 @@ export interface ToolCallbacks { block?: { reason: string }; } | undefined>; afterToolCall?(call, args, result, isError, signal): Promise; - /** Between phases 1 and 2: the durability point. The harness writes its - tool_started record here. Called in source order in both modes — - preparation is always sequential. */ + /** Phase-two dispatch. Omission calls executeToolCall() directly. The + harness supplies a function that delegates each invocation to + fx.executeTool; this callback is internal orchestration, not tool context. */ + executeTool?(prepared: PreparedToolCall): Promise<{ + result: AgentToolResult; + isError: boolean; + }>; + /** Between phases 1 and 2: the durability point. The batch plan already + exists; the harness writes tool_started without another result id. + Called in source order in both modes. */ onToolStart?(call: AgentToolCall, effectiveArgs: Record): Promise; - /** After phase 3, before the result message is emitted; source order. - The harness appends the result entry here, persisting the finalized - terminate decision on it (section 12). */ - onToolResult?(message: ToolResultMessage, terminate: boolean): Promise; + /** After phase 3 and the result message's start/end lifecycle; source + order. The original call identifies its existing planned id. The + harness writes usage first when present, then appends the result with + terminate; entry_added follows that commit. */ + onToolResult?(call: AgentToolCall, message: ToolResultMessage, + terminate: boolean): Promise; } /** Batch-driver rules: - - stopReason "length" fails every call without executing: streamed - arguments are salvage-parsed and can validate while silently - truncated; none are safe. + - Provider toolCallId values are unique within the assistant response by + contract. The driver assumes that invariant and adds no duplicate handling. + - A stopReason "length" passed to this driver produces one explanatory + immediate error per source call without lookup, hooks, onToolStart, or + execution. The harness calls this only after classifying a genuine + output-limit stop; an overflow-classified response gets no plan or batch. - Mode: sequential when options.toolExecution === "sequential" or when any called tool declares executionMode "sequential"; else parallel. - - Parallel mode: phase 1 and onToolStart run sequentially in source - order; phase 2 runs concurrently; phases 3, onToolResult, and message - emission happen in source order after all executions settle. - - Abort: no further calls are prepared; already-executing calls settle. - - terminate: true when every finalized result sets terminate. */ + - Preparation is source-ordered and sequential in both modes. Sequential + mode completes phases 1–3, emits the result message lifecycle, and calls + onToolResult for one call before the next. + - Parallel mode invokes onToolStart and dispatches callbacks.executeTool + in source order without awaiting earlier effects. Effects settle + concurrently. Phase 3, result-message lifecycle emission, and + onToolResult then await and finalize those outcomes in source order. + - Blocked and invalid calls skip onToolStart and phase 2, but still call + onToolResult at their source position with an immediate error. + - Abort stops further preparation and lets already-dispatched effects + settle. An internal durability callback may propagate abort before + phase 2; the batch driver starts no effect + for that call, awaits/finalizes earlier dispatched effects in source + order, then propagates control. The harness's reconciliation fills every + still-missing planned result. + - terminate is true only when every finalized result sets terminate. */ export function executeToolBatch( assistant: AssistantMessage, tools: AgentTool[], callbacks: ToolCallbacks, options: { toolExecution?: "sequential" | "parallel" }, emit: AgentEventSink, @@ -1898,7 +2431,7 @@ export function executeToolBatch( ### Compatibility wrapper -The existing public interface of `agent-loop.ts` does not break. Every export keeps its signature and behavior: `agentLoop`, `agentLoopContinue`, `runAgentLoop`, `runAgentLoopContinue`, `AgentEventSink`, and the config surface they consume (`getSteeringMessages`, `getFollowUpMessages`, `prepareNextTurn`, `shouldStopAfterTurn`, `beforeToolCall`, `afterToolCall`, event order included). They compose `streamAssistant` and `executeToolBatch` with the no-op `TelemetryContext` — no durability, no new semantics. The existing `agent-loop` and `agent` test suites pass unchanged. +The existing public interface of `agent-loop.ts` does not break. Every export keeps its signature and behavior: `agentLoop`, `agentLoopContinue`, `runAgentLoop`, `runAgentLoopContinue`, `AgentEventSink`, and the config surface they consume (`getSteeringMessages`, `getFollowUpMessages`, `prepareNextTurn`, `shouldStopAfterTurn`, `beforeToolCall`, `afterToolCall`, event order included). They compose `streamAssistant` and `executeToolBatch` with the no-op `TelemetryContext` and omit `ToolCallbacks.executeTool`, so phase 2 uses `executeToolCall()` directly. They add no durability and preserve existing event order and results. The existing `agent-loop` and `agent` test suites pass unchanged. ## 15. Harness internals @@ -1908,14 +2441,78 @@ Part III adds no new durability semantics over Part II. It adds two mechanisms: ### The effects boundary -Every effect a procedure performs goes through one injected `Effects` handle, `fx`. In `drive: "automatic"` the handle passes straight through to the session, the models, the tools, and the hook runner. In `drive: "manual"` the same handle is wrapped in a gate (below). The method list is the complete crash-site catalog: stopping before or after one of these calls is exactly a section 6 X state. +Every effect an operation procedure performs goes through one injected `Effects` handle, `fx`: every durable write, provider request or fetch, individual tool invocation, hook invocation, and timer. In `drive: "automatic"` the handle delegates to the session, provider/tool adapters, and hook runner. In `drive: "manual"` the same handle is wrapped in a gate (below). The effect methods are the complete procedure crash-site catalog: stopping before or after one of these calls is exactly a section 6 X state. + +Explicit lane-surface mutations are the deliberate exception to gating. Acceptance, queue/configuration calls, lane-view writes, abort, and configured lane creation enqueue directly on the same lane FIFO used by `Effects`; otherwise a parked operation could not be steered or aborted. Pre-acceptance `before_run` still invokes `fx.runHook`, but the eventual acceptance mutation is ungated. In manual mode that hook can therefore be the lane's next action before an operation record exists. + +Procedures also receive a read-only `ProcedureRuntime`: reduced `LaneState` and planned entries, branch/context readers, an injected id generator, environmental model/tool identity resolution, and the passive event sink. These operations do not write, wait on an external effect, invoke a provider/tool/hook, or enter the manual action queue. In particular, a procedure never receives `Session`, `Models`, a tool registry, or a hook runner and never calls one directly. Identity resolution happens immediately before the hook, provider, or tool path that needs it; abort-only and synthetic paths do not resolve identities. ```ts +type StructuralStepStartIntent = + | { source: "generated" } + | { source: "hook"; hookResult: ProvisionedEntry; hookUsageRecordId?: string }; + +type StepStartIntent = { id: string; runId: string } & ( + | { step: "assistant"; triggerMessageId: string } + | { step: "deferred_fetch"; configuration: LaneConfiguration; retryPolicy: RetryPolicy } + | ({ step: "compaction"; resultEntryId: string } & + StructuralStepStartIntent & ( + | { compactionReason: "manual" | "threshold" } + | { compactionReason: "overflow"; + supersededResponseEntryId: string; triggerMessageId: string } + )) + | ({ step: "branch_summary"; resultEntryId: string } & + StructuralStepStartIntent) +); + interface Effects { - // Durable writes. Each validates and commits at the head of the lane's - // mutation line (below), then updates LaneState. + // Semantic durable writes. Implementations delegate to Session.append() + // but retain typed validation, live-state, event, telemetry, and manual-gate + // behavior. Each commits at the head of the lane mutation line, then updates + // LaneState. Every successful entry commit emits entry_added after the + // complete storage append and state update agree. appendEntry(entry: ProvisionedEntry, telemetryContext: TelemetryContext): Promise; appendRecord(record: NewRecord, telemetryContext: TelemetryContext): Promise; + /** After the response's message_end, appends it under its attempt's + provisioned id and emits entry_added. In the same mutation-line job, an + earlier abort marker normalizes the settled message to stopReason + "aborted" and clears deferred-only fields; an already-committed response + is never changed. */ + settleAttemptResponse( + attempt: Extract, + message: SettledAssistantMessage, + telemetryContext: TelemetryContext, + ): Promise; + /** Effect-intent races. These append only when abort has not already won. */ + startAttempt(attempt: NewRecord, + telemetryContext: TelemetryContext): Promise; + startToolBatch(plan: NewRecord, + telemetryContext: TelemetryContext): Promise; + startTool(start: NewRecord, + telemetryContext: TelemetryContext): Promise; + /** Structural commit races. These write only when abort has not already + won; an existing result/move remains committed when abort arrives later. */ + commitStructuralEntry(entry: ProvisionedEntry, + telemetryContext: TelemetryContext): Promise<"committed" | "aborted">; + /** A generated structural step can fail only while abort has not won. */ + failStructuralStep(record: NewRecord, + telemetryContext: TelemetryContext): Promise; + /** Persists one complete generated branch-summary payload before the move. + Returns aborted instead of writing when the marker won. */ + prepareBranchSummary(record: NewRecord, + telemetryContext: TelemetryContext): + Promise; + /** A summarized move additionally verifies that a complete hook or + generated payload is already durable. */ + commitNavigationMove(to: string | null, + telemetryContext: TelemetryContext): Promise<"committed" | "aborted">; + /** Commits step_started while atomically capturing the lane's current + total configuration and normalized retry policy for generated sources. + Hook sources instead persist the supplied complete result and usage id. + Deferred-fetch configuration and policy are copied from its original + generation step and supplied by its owning procedure. */ + startStep(start: StepStartIntent, + telemetryContext: TelemetryContext): Promise; moveLane(to: string | null, telemetryContext: TelemetryContext): Promise; setFact(fact: FactWrite, telemetryContext: TelemetryContext): Promise; @@ -1923,10 +2520,12 @@ interface Effects { tryFinishRun(runId: string, outcome: "completed" | "failed", telemetryContext: TelemetryContext, error?: OperationError): Promise<"finished" | "continue">; + /** For completed navigation with an accepted label, the conditional + commit uses one Session.append([label fact, operation_finished]). */ finishOperation(runId: string, outcome: "completed" | "declined" | "failed" | "aborted", telemetryContext: TelemetryContext, error?: OperationError): Promise<"finished" | "continue">; - commitRunEndFollowUp(runId: string, item: ProvisionedEntry, + commitRunEndFollowUp(runId: string, item: ProvisionedEntry, telemetryContext: TelemetryContext): Promise<"committed" | "dropped">; consumeQueueItem(runId: string, queue: "steer" | "followUp", entryId: string, telemetryContext: TelemetryContext): Promise<"consumed" | "skipped">; @@ -1934,13 +2533,19 @@ interface Effects { telemetryContext: TelemetryContext): Promise<"applied" | "skipped">; // External effects. + /** Exactly one provider generation request. Assistant/fetch public message + lifecycle is emitted by the owning request adapter; structural callers + supply a private sink. Nested request hooks call this same `fx` facade. */ streamAssistant(request: AssistantRequest, telemetryContext: TelemetryContext): Promise; executeTool(prepared: PreparedToolCall, telemetryContext: TelemetryContext): Promise<{ result: AgentToolResult; isError: boolean }>; - fetchDeferred(model: Model, handle: DeferredHandle, + /** The source entry supplies the exact provider/model and complete handle. + Returned and rejection-converted responses complete their public + message lifecycle before this method resolves. */ + fetchDeferred(source: MessageEntry, options: DeferredFetchOptions, telemetryContext: TelemetryContext): Promise; - cancelDeferred(model: Model, handle: DeferredHandle, + cancelDeferred(source: MessageEntry, telemetryContext: TelemetryContext): Promise; // Interception and time. @@ -1953,9 +2558,9 @@ interface Effects { Rules: - Reads (`getEntry`, `findEntriesOnBranch`, context building, id allocation) are not effects and never gate. -- **Construction rule:** procedures receive only `fx` plus their current `TelemetryContext` — never the session, models, tools, or hook runner directly. Every `Effects` call receives that context as its final non-payload parameter; section 15 procedure snippets omit repetitive context threading where it would obscure control flow and show it where parentage matters. Tool objects handed to `executeToolBatch` are wrapped so each `execute` routes through `fx.executeTool`; the section 14 callbacks route through `fx.runHook`, `fx.appendRecord`, and `fx.appendEntry`, always with the current scope context. The rule is enforced by construction and by a test: any operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked. -- `fx.streamAssistant` wraps section 14 `streamAssistant` with authenticated dispatch through `Models`; `transform_context`, `before_payload`, and `after_response` run inside it via `fx.runHook`. Summary steps force `deferred: false`; a deferred structural result is a defect. -- The `fx` implementation converts a rejected `fetchDeferred` into a `stopReason: "error"` assistant message, so expected provider failures stay in-band. Unexpected rejections from durable writes fault the harness (section 4). +- **Construction rule:** procedures receive `fx`, their current `TelemetryContext`, and the read-only `ProcedureRuntime` described above — never the session, models, tools, or hook runner directly. Every effect call receives that context as its final non-payload parameter; section 15 snippets omit repetitive context threading where it would obscure control flow. The harness supplies `ToolCallbacks.executeTool = prepared => fx.executeTool(prepared, currentContext)` to `executeToolBatch`, so every phase-two call crosses its own boundary; the other section 14 callbacks route each hook and durable write through `fx`. The rule is enforced by construction and by a test: any operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked. +- `fx.streamAssistant` wraps exactly one section 14 `streamAssistant` request with authenticated dispatch. Its request-pipeline callbacks invoke `transform_context`, `before_payload`, and `after_response` through the same outer `fx.runHook`, so manual drive exposes them as nested hook actions. Assistant generation uses the lane event sink. Each request of structural generation calls `fx.streamAssistant` separately with a private sink, emits no public assistant-message lifecycle, and forces `deferred: false`; a deferred structural result is a defect. +- The `fx` implementation delegates deferred work through `Models`, resolves the provider/model from the exact source entry, and converts a rejected fetch into a `stopReason: "error"` assistant message, so expected provider failures stay in-band. Fetch settlement runs `after_response` through the same `fx` facade and completes `message_end` before returning to the redemption procedure. That procedure always supplies `{ wait: 0 }`; poll cadence stays with the caller. Unexpected rejections from durable appends fault the harness (section 4). ### The lane mutation line @@ -1971,24 +2576,32 @@ function mutateLane(job: () => Promise): Promise { } ``` -A job is: validate against live `LaneState` → at most one durable write → update `LaneState`. Nothing else. Provider requests, tool executions, hooks, and backoff never run inside a job; they run between jobs, which is exactly why every commit revalidates inside its own job. Because jobs run one at a time, two concurrent operations on a lane have exactly two possible histories — `[A, B]` or `[B, A]` — and both are defined outcomes. No third, interleaved history exists. +A job is: validate against live `LaneState` → at most one `Session.append()` call → install every returned logical mutation in order → publish commit events in that order. The append normally contains one logical mutation; configured lane creation and labeled navigation completion contain two. Provider requests, tool executions, hooks, and backoff never run inside a job; they run between jobs, which is exactly why every commit revalidates inside its own job. Because jobs run one at a time, two concurrent operations on a lane have exactly two possible histories — `[A, B]` or `[B, A]` — and both are defined outcomes. No third, interleaved history exists. The jobs, by caller: - **Lane surface** (ungated, enqueue directly): - - *Operation acceptance* — validate idle, capture the pending `nextRun` items into `initialMessages`, write `operation_started`, set `state.operation`. The second of two concurrent acceptances sees the first and rejects `busy` with no write. `before_run` ran before this job, outside the line, on the prompt only. - - *Queue acceptance* (`steer`, `followUp`) — validate an active, non-aborting run; write `queue_enqueued`. `nextRun` validates nothing and always accepts. + - *Operation acceptance* — after pre-acceptance `fx.runHook("before_run", ...)` returns, validate idle, capture the pending `nextRun` items into `initialMessages`, write `operation_started`, set `state.operation`. The second of two concurrent acceptances sees the first and rejects `busy` with no write; its already-completed hook output is discarded. The hook ran outside the line on the prompt only, but still crossed `Effects` and is a manual action. + - *Configured lane creation* — validate the name and anchor, then call `Session.createLane()`, which appends `[lane create, seed lane_config]`; publish the lane and `lane_created` only after both logical mutations commit. + - *Queue acceptance* (`steer`, `followUp`) — validate an active, non-aborting run; write `queue_enqueued`. While the harness is open, `nextRun` validates its message but performs no active-operation check, writes its operation-independent enqueue, and starts no run. - *Queue cancellation* (`cancelQueued`) — no `queue_enqueued` for the id: `Err(UnknownQueueItem)`; target entry exists: `already_consumed`; not pending (abort-drained or already cancelled): `already_cleared`; else write `queue_cancelled` and remove the item from its pending set. - - *Deferred-write acceptance* (lane-view writes, config setters) — run open: write `write_deferred`; structural operation open: wait for it to end, then re-enter; idle: append the entry directly. - - *Abort* — write `abort_requested`, set `aborting`, drain `pendingSteer`/`pendingFollowUp` (payloads return to the abort caller and in the `run_abort` event), signal the active effect's `AbortController`. + - *Deferred-write acceptance* (lane-view tree writes) — run open: write `write_deferred`; structural operation open: wait for it to end, then re-enter; idle: append the entry directly. + - *Configuration setter* — derive one total replacement from the lane's current value, append `lane_config`, then update the current value. This runs immediately during any operation and survives abort. + - *Abort* — on the first call, write `abort_requested` and store the exact drained `pendingSteer`/`pendingFollowUp` payloads. After that job commits and leaves the line, emit `run_abort`, signal the active effect's `AbortController`, and cancel this lane's unreleased manual provider/tool/fetch/sleep actions without executing them. A call that finds the marker returns copies of those stored/derived payloads with the same run id and performs no write, event, signal, or gate cancellation. A call that finds the terminal record returns `NoActiveOperation`. - *Resume admission* — reserve the lane's single execution slot; no write. - **Procedure via `fx`** (gated in manual mode): - `tryFinishRun` — if aborting or anything pending, write nothing and return `"continue"`; else write `operation_finished` and idle the lane. - - `consumeQueueItem` — if the item is still pending and the run is not aborting, append its entry and remove it; else `"skipped"`. - - `applyPendingWrite` — same shape for deferred writes; they apply even while aborting. + - `consumeQueueItem` — if the item is still pending and the run is not aborting, emit its immediate message start/end, append its entry, and remove it; else `"skipped"` with no events. + - `applyPendingWrite` — same shape for deferred writes, including immediate message lifecycle when the target is a message; they apply even while aborting. - `commitRunEndFollowUp` — write `queue_enqueued` only while the run is active and non-aborting; else `"dropped"`. - - `finishOperation` — terminal record unless preempted: a non-abort outcome returns `"continue"` when an abort marker exists; an `"aborted"` outcome returns `"continue"` while deferred writes are still pending, so reconciliation applies them first. - - Plain `appendEntry`/`appendRecord`/`moveLane`/`setFact` — unconditional single writes, still serialized by the line. + - `finishOperation` — terminal record unless preempted: a non-abort outcome returns `"continue"` when an abort marker exists before an uncommitted structural effect; a committed compaction result or navigation move instead completes. Completed navigation with an accepted label appends `[label fact, operation_finished]` atomically and publishes `fact_update` before the operation-end event after success. An `"aborted"` run outcome returns `"continue"` while deferred writes are still pending, so reconciliation applies them first. + - `settleAttemptResponse` — append the attempt's response exactly once; if the abort marker is already present, normalize the message to `stopReason: "aborted"` in this job. This is the response-append side of race 10. + - `startAttempt` / `startToolBatch` / `startTool` — append an external-effect intent only if abort has not already won; otherwise return `"aborted"`, and the procedure starts no provider/tool effect. Each still surfaces as an `append_record` action in manual drive. + - `prepareBranchSummary` — append the complete generated payload only if abort has not won and the lane is still at the navigation source; otherwise return `"aborted"`. It is an `append_record` action. + - `commitStructuralEntry` / `commitNavigationMove` — if abort already won, write nothing and return `"aborted"`; otherwise perform the compaction-entry append or navigation move that makes the structural operation irrevocably committed. A summarized move additionally requires its hook result or generated prepared record. + - `failStructuralStep` — append `step_failed` only if the generated step is still open and abort has not won; otherwise return `"aborted"` without a write. + - `startStep` — validate that no conflicting step transition or uncommitted-operation abort won. If abort won, return `"aborted"` without a write. Otherwise, for a generated source snapshot the required total configuration and normalized retry policy; for a hook source validate and persist the supplied complete typed result and optional usage id. Append one `step_started`, then install that exact record in state. This job is the generation-step side of race 9 only for generated sources. + - Plain `appendEntry`/`appendRecord`/`moveLane`/`setFact` — unconditional semantic effects, each delegated to one-mutation `Session.append()` and still serialized by the line; operation procedures use the conditional methods above at abort races. Two examples, both orders legal, nothing else possible: @@ -2016,9 +2629,9 @@ The complete list. Each row names the two legal histories and the jobs that forc | 5 | abort vs queue consumption | entry appended, not in abort payload · returned by abort, skipped | `consumeQueueItem` + abort drain | | 6 | abort vs `before_run_end` follow-up | committed then drained by abort · dropped, nothing behind the marker | `commitRunEndFollowUp` | | 7 | `nextRun` vs acceptance | captured by this run · belongs to the next | capture inside acceptance | -| 8 | deferred write vs abort close | applied during reconciliation · applied before it | `finishOperation("aborted")` loops | -| 9 | config/tree write vs acceptance snapshot | committed before the run's first request · deferred write | both are line jobs; snapshots read after acceptance | -| 10 | abort vs in-flight provider/tool effect | effect settles · effect interrupted | irreducible: signal cancellation; only the procedure commits results (abort path owns synthetics) | +| 8 | deferred write vs abort finish | applied during reconciliation · applied before it | `finishOperation("aborted")` loops | +| 9 | config setter vs generation-step start | replacement captured by the new step · already-started step keeps its prior snapshot | config-setter job + generation-step start commit | +| 10 | abort vs in-flight provider/tool effect | response/result commits first and is preserved · marker commits first and settlement is normalized or synthetic | irreducible external race; marker precedes signal, response append revalidates, tool reconciliation owns missing planned results | | 11 | cross-lane writes | any interleaving | storage `seq` linearization (section 13); lanes share no state | | 12 | `cancelQueued` vs consumption | consumed first: `already_consumed` · cancelled first: consumption skips, the model never sees it | cancel job + `consumeQueueItem` | @@ -2033,7 +2646,7 @@ type ActionInfo = | { kind: "append_entry"; entryType: Entry["type"]; entryId: string } | { kind: "append_record"; recordType: LaneRecord["type"] } | { kind: "move_lane"; to: string | null } - | { kind: "set_fact"; fact: "name" | "label" } + | { kind: "set_fact"; fact: "name" | "label" | "custom" } | { kind: "try_finish_run"; outcome: "completed" | "failed" } | { kind: "finish_operation"; outcome: "completed" | "declined" | "failed" | "aborted" } | { kind: "commit_follow_up" } @@ -2047,16 +2660,45 @@ type ActionInfo = ``` ```ts -class GatedEffects implements Effects { - private readonly queue: { info: ActionInfo; release: () => Promise }[] = []; +interface ParkedAction { + info: ActionInfo; + /** Starts the effect without awaiting it. A released parent can therefore + park a nested action that the same driver must release. */ + start(): void; + /** Present for an unreleased provider/tool/fetch/sleep action. Abort removes + it without running the external effect; sleep resolves `"aborted"`, and + the others reject to the procedure's internal Aborted path. */ + abortBeforeStart?(): void; + settled: Promise; +} - private gate(info: ActionInfo, run: () => Promise): Promise { - return new Promise((resolve, reject) => { +class GatedEffects implements Effects { + private readonly queue: ParkedAction[] = []; + + private gate( + info: ActionInfo, + run: () => Promise, + abortBeforeStart?: (resolve: (value: T) => void, reject: (error: unknown) => void) => void, + ): Promise { + return new Promise((resolve, reject) => { + let started = false; + let settle!: () => void; + const settled = new Promise((done) => { settle = done; }); this.queue.push({ info, - release: async () => { await run().then(resolve, reject); }, + start: () => { + if (started) throw new Error("action released twice"); + started = true; + void Promise.resolve().then(run).then(resolve, reject).finally(settle); + }, + abortBeforeStart: abortBeforeStart ? () => { + if (started) return; + started = true; + try { abortBeforeStart(resolve, reject); } finally { settle(); } + } : undefined, + settled, }); - this.arrived(); // wakes a pending driver + this.arrived(); // wakes a pending driver, including a released parent }); } @@ -2064,15 +2706,21 @@ class GatedEffects implements Effects { return this.gate({ kind: "append_record", recordType: record.type }, () => this.inner.appendRecord(record, telemetryContext)); } - // ... one wrapper per method + startStep(start: StepStartIntent, telemetryContext: TelemetryContext) { + return this.gate({ kind: "append_record", recordType: "step_started" }, + () => this.inner.startStep(start, telemetryContext)); + } + // ... one wrapper per effect method. Conditional writes retain their + // specific ActionInfo kind; start/settle/prepare/fail record methods are + // individual append_record or append_entry actions, never one compound gate. } ``` The public controls, on the lane (section 8): -- `peekAction()` resolves with the description of the next parked call, or `undefined` when no operation exists or the operation has settled. No side effect; calling it twice returns the same action. -- `executeAction()` releases exactly the parked call `peekAction()` describes. It then waits until that call settles, the operation settles, or the released call parks a nested action; it returns the next parked action or `undefined`. It never releases two actions. -- `runToCompletion()` releases until the operation settles. +- `peekAction()` resolves with the description of the next parked call. It also sees a pre-acceptance `before_run` hook, when no operation record exists yet. It returns `undefined` only when no call is parked and no admitted operation or pre-acceptance call can produce another action. No side effect; calling it twice returns the same action. +- `executeAction()` removes and starts exactly the parked call `peekAction()` describes; it does not await that call while hiding descendants. It waits until the released call settles, the local operation settles, or a nested action arrives, then returns the next parked action or `undefined`. It never releases two actions. +- `runToCompletion()` repeats that algorithm, releasing nested actions before waiting for their parents, until the operation or pre-acceptance call settles. - Two concurrent drivers are a programmer defect, as is calling the controls in automatic mode. Semantics that make tests deterministic: @@ -2080,104 +2728,181 @@ Semantics that make tests deterministic: - The gate is reentrant. A released action may call another `fx` method — notably `transform_context`, `before_payload`, and `after_response` hooks reached inside `stream_assistant`. The nested call parks as its own action. The driver observes and releases it before the outer action can continue; it never waits for the outer action while hiding the nested park. Every hook therefore remains an independent crash boundary without deadlocking manual drive. - The gate serializes. Parallel tool batches issue phase-2 calls in source order (phase 1 is sequential, section 14); the gate parks them as separate `execute_tool` actions and manual mode runs them one at a time. Parallelism is a production optimization; source-ordered finalization already fixes the semantics, so automatic and manual modes produce the same durable log. - The lane surface stays ungated. While the procedure is parked, a test calls `steer()`, `abort()`, `session.appendMessage()` — their jobs run on the mutation line immediately. Both orders of every race-catalog row are constructed by choosing whether to call the surface method before or after `executeAction()`. -- `close()` while parked: every parked call rejects with `HarnessClosed`, the local operation promise rejects, nothing else commits. The durable state is exactly the prefix of released effects — the definition of a crash site. Reopen the backend and `resume()` runs ordinary section 7 recovery. In automatic mode `close()` signals the in-flight effect, waits for the append in progress, and releases the writer claim; open operations stay resumable either way. +- The first abort job also calls `abortBeforeStart()` on this lane's unreleased provider, fetch, tool, and sleep actions. None executes. Their existing attempt/tool intent remains for `abortPath` to settle; a parked sleep returns `"aborted"`. An external-effect wrapper enqueued after the marker observes the already-aborted signal and rejects/resolves the same way without parking, closing the small interval after a conditional intent commit. An already-released effect is not removed: it receives the ordinary abort signal and its real settlement races the marker. Hooks and durable writes are not discarded; their conditional follow-up commits revalidate the marker. Repeated abort performs no second gate cancellation. +- `close()` while parked: every parked call rejects with `HarnessClosed`, the local operation promise rejects, nothing else commits. The durable state is exactly the prefix of released effects — the definition of a crash site. Reopen the backend and `resume()` runs ordinary section 7 recovery. In automatic mode `close()` stops admission, signals the in-flight effect, lets the active Session write settle, drains the storage queue, and releases only its matching writer claim; open operations stay resumable either way. ### Live lane state ```ts -interface EffectiveLaneConfiguration { - model: { provider: string; modelId: string }; - thinkingLevel: ThinkingLevel; - activeToolNames: string[]; -} - interface TerminalFailureState { entryId: string; - source: "step" | "deferred_fetch"; + source: "assistant" | "deferred_fetch"; + /** stopReason error, or unmarked aborted at the applicable captured cap. */ message: AssistantMessage; } -/** In-memory orchestration state per lane. Always equal to the laneState - produced by reducing the lane's records and own entries (section 7): live - commits update it; restore recomputes it. */ +interface StepState { + started: StepStartedRecord; + attempts: StepAttemptRecord[]; // this stepId, source order + newestAttempt?: { + record: StepAttemptRecord; + response?: MessageEntry; // assistant/fetch only + usage?: UsageRecord; // exact preplanned id + }; + /** Generated branch summary only; complete payload before navigation move. */ + preparedBranchSummary?: BranchSummaryPreparedRecord; + /** Hook payload comes from started; this bit covers its preplanned usage. */ + hookUsageExists: boolean; + resultExists: boolean; // structural typed result + failure?: StepFailedRecord; // generated structural terminal failure +} + +/** In-memory orchestration state per lane. Restore obtains it from the pure + bounded reduction in section 7; live commits apply the same transitions. + Tests, not production settlement, compare live state with fresh reduction. */ interface LaneState { lane: string; leafId: string | null; + /** The newest total lane_config replacement. */ + configuration: LaneConfiguration; operation: null | { id: string; kind: "run" | "compaction" | "navigation"; + sourceLeafId: string | null; intent: OperationStartedRecord["intent"]; - aborting: boolean; - step: null | { // unfinished step: newest attempt's result entry missing - kind: "assistant" | "compaction" | "branch_summary"; - attempts: number; - resultEntryId: string; // the newest attempt's provisioned result - compactionReason?: "manual" | "threshold" | "overflow"; + /** Sole durable cancellation request and the queue payloads it killed. + Null means ordinary execution may continue. */ + abort: null | { + record: AbortRequestedRecord; + steer: ProvisionedEntry[]; + followUp: ProvisionedEntry[]; }; + /** Current durable step. Assistant/fetch responses remain here until + their post-persistence transition is represented by later state. */ + step: StepState | null; toolBatch: null | ToolBatchState; - missingInitialMessages: ProvisionedEntry[]; - pendingSteer: ProvisionedEntry[]; - pendingFollowUp: ProvisionedEntry[]; - pendingWrites: ProvisionedEntry[]; + missingInitialMessages: ProvisionedEntry[]; + pendingSteer: ProvisionedEntry[]; + pendingFollowUp: ProvisionedEntry[]; + pendingWrites: ProvisionedEntry[]; deferred: DeferredHandle | null; // unredeemed handle - overflowRecoveryUsed: boolean; // section 6 overflow guard, from the reduction + overflowRecoveryUsed: boolean; // overflow step exists for current trigger /** Newest entry this operation appended; pure predicates read it. */ newestOwn: null | { entryId: string; type: Entry["type"]; role?: AgentMessage["role"]; stopReason?: TerminalStopReason }; targets: { result?: boolean; summary?: boolean }; // structural ops }; - pendingNextRun: ProvisionedEntry[]; + pendingNextRun: ProvisionedEntry[]; } interface ToolBatchState { + plan: ToolBatchStartedRecord; assistantEntryId: string; - calls: { // original source order and ordinals - toolIndex: number; + calls: { // original source order + toolIndex: number; // source ordering and planned-result lookup only toolCall: AgentToolCall; + resultEntryId: string; // from plan, never tool_started started?: ToolStartedRecord; - resultExists: boolean; + result?: MessageEntry; terminate?: boolean; // persisted on the result entry }[]; - truncated: boolean; // assistant stopReason was "length" - unresolved: boolean; + genuineLength: boolean; // accepted output-limit length; no tool executes + unresolved: boolean; // at least one planned result is absent } -interface LaneReductionInput extends RecordLogSlice { +type NextRunRecord = + | (QueueEnqueuedRecord & { queue: "nextRun"; runId?: never }) + | (QueueCancelledRecord & { runId?: never }); + +interface LaneRecordSlices { + /** Empty when idle. When present, starts with this exact open operation and + contains only records matching its runId, in chronological order. */ + openOperation: OperationStartedRecord | null; + operationRecords: readonly LaneRecord[]; + /** Exclusive boundary used for the independent nextRun query. */ + newestRunStartSeq: number | null; + nextRunRecords: readonly NextRunRecord[]; +} + +interface LaneEntryPlan { + /** Unique exact ids for the one Session.getEntries() call. */ + ids: readonly string[]; + /** Subset provisioned by the open operation; chronology comes from entry seq. */ + operationEntryIds: ReadonlySet; + /** Targets from the independent nextRun slice. */ + nextRunEntryIds: ReadonlySet; +} + +interface LaneReductionInput { + lane: string; leafId: string | null; - /** Entries appended by the open operation, oldest first. Empty when idle. */ - ownEntries: readonly Entry[]; - /** Bounded effective-state lookups at the operation anchor or idle leaf, - oldest first. */ - configurationEntries: readonly Entry[]; - /** Harness option fallbacks used when no persisted value exists. */ - defaults: EffectiveLaneConfiguration; + /** The indexed newest total replacement. Required after main's one-time + initialization and for every created format-4 lane. */ + laneConfig: LaneConfigRecord; + records: LaneRecordSlices; + plan: LaneEntryPlan; + /** Immutable exact lookup result. Missing planned ids are omitted. */ + entries: ReadonlyMap; } interface LaneReductionResult { laneState: LaneState; - effectiveConfiguration: EffectiveLaneConfiguration; - /** Non-null only when newestOwn is an error produced by a step or deferred fetch, - never for an arbitrary error-shaped deferred write. */ + /** Existing planned entries retained beside LaneState so ordinary re-entry + can skip already-completed appends without another storage read. Live + entry commits update this map together with LaneState. */ + plannedEntries: ReadonlyMap; + /** Non-null only when newestOwn is an error response or an unmarked + aborted response at its applicable captured cap, produced by an + assistant or deferred-fetch attempt; never for an arbitrary deferred + write or a structural step_failed record. */ terminalFailure: TerminalFailureState | null; } +function buildLaneEntryPlan(records: LaneRecordSlices): LaneEntryPlan; function reduceLaneState(input: LaneReductionInput): LaneReductionResult; ``` -Four control-flow signals travel by exception inside a procedure; none escapes to a caller. `RunFailed` carries a terminal failure into the drain-and-finish path. `Park` unwinds when a deferred handle was persisted; the lane suspends. `Aborted` unwinds to the abort path. `Overflow` routes a discarded recoverable response (section 6) into the compact-and-retry path. Any other rejection faults the harness. +Four control-flow signals travel by exception inside a procedure; none escapes to a caller. `RunFailed` carries a terminal failure into the drain-and-finish path. `Park` unwinds when a deferred handle remains unredeemed after a pending or interrupted poll; the lane suspends. `Aborted` unwinds only after the durable abort marker wins. `Overflow` routes a durable, fully accounted recoverable response (section 6) into the compact-and-retry path. Any other rejection faults the harness. ```ts class RunFailed { constructor(readonly error: OperationError) {} } class Park { constructor(readonly handle: DeferredHandle) {} } class Aborted {} -class Overflow {} // recoverable response discarded; its cost is already in the ledger +class Overflow { + constructor(readonly responseEntryId: string, readonly triggerMessageId: string) {} +} -const newId = (): string => session.idGenerator.next(); +const newId = (): string => runtime.idGenerator.next(); -/** Recovery-safe re-entry everywhere: skip a provisioned id that already - exists (verify equal content; different content is corruption). */ +/** Re-entry-safe everywhere: use the reducer's planned-entry presence, never + a new storage lookup. fx.appendEntry installs the committed entry in + plannedEntries and LaneState before it resolves. Existing entries emit no + replayed events. Callers emit any process-local message lifecycle before + invoking this helper for a missing message. */ async function appendIfMissing(target: ProvisionedEntry): Promise { - if (!(await session.getEntry(target.id))) await fx.appendEntry(target); + const existing = plannedEntries.get(target.id); + if (existing) return verifyProvisionedContent(existing, target); + await fx.appendEntry(target); +} + +async function appendMessageIfMissing(target: ProvisionedEntry): Promise { + if (plannedEntries.has(target.id)) return appendIfMissing(target); + emitImmediateMessageLifecycle(target.message, target.id); + await appendIfMissing(target); +} + +/** Synthetic assistant settlement follows the same public lifecycle and + entry-before-usage order as a provider result, but performs no provider + effect. settleAttemptResponse rechecks the abort race and emits entry_added + after commit. */ +async function settleSyntheticResponse( + attempt: Extract, + message: SettledAssistantMessage, +): Promise { + emitImmediateMessageLifecycle(message, attempt.responseEntryId); + const response = await fx.settleAttemptResponse(attempt, message); + await fx.appendRecord(preplannedUsageRecord(attempt, response.message)); + return response; } ``` @@ -2185,44 +2910,58 @@ async function appendIfMissing(target: ProvisionedEntry): Promise { ```ts async function resume(): Promise { - if (missing.tools.length || missing.models.length) { - return Result.err(new MissingIdentities({ lane: state.lane, ...missing, - message: "Missing tools or models" })); - } await fx.runHook("before_resume", beforeResumeEvent(state)); // per registration id (section 11) emit({ type: "run_resume", runId: op.id, recovery: true }); - // tagResume re-tags an operation Result as a ResumeResult: Ok gains - // { operation }, Err passes through unchanged. - switch (op.kind) { - case "run": return tagResume("run", await runProcedure()); - case "compaction": return tagResume("compaction", await compactionProcedure()); - case "navigation": return tagResume("navigation", await navigationProcedure()); + try { + // tagResume re-tags an operation Result as a ResumeResult: Ok gains + // { operation }, Err passes through unchanged. Provider/fetch/tool helpers + // call requireIdentity immediately before only the effect they will run. + switch (op.kind) { + case "run": return tagResume("run", await runProcedure()); + case "compaction": return tagResume("compaction", await compactionProcedure()); + case "navigation": return tagResume("navigation", await navigationProcedure()); + } + } catch (error) { + if (MissingIdentities.is(error)) return Result.err(error); + throw error; } } async function runProcedure(): Promise { try { - for (const m of [...op.missingInitialMessages]) await appendIfMissing(m); // never dropped - if (op.aborting) return await abortPath(); - - if (op.deferred) { - const redeemed = await redeemDeferred(); // may throw Park, RunFailed, Aborted + for (const m of [...op.missingInitialMessages]) await appendMessageIfMissing(m); // never dropped + await persistMissingResponseUsage(op.step); // entry-before-usage crash prefix + if (op.abort) return await abortPath(); + + if (op.step?.started.step === "deferred_fetch" && + stepNeedsClassification(op.step, state)) { + const source = sourceNamedByNewestFetchAttempt(op.step, state); + const redeemed = routeDeferredClassification( + classifyDurableDeferredResponse(op.step, state), source); + // No fetch occurs before this already-persisted response is accounted + // and classified. Response-step lookup below uses this deferred step's + // copied active tool names. if (hasToolCalls(redeemed)) await runToolBatch(redeemed); + } else if (op.deferred) { + const redeemed = await redeemDeferred(); // may throw Park, RunFailed, Aborted + if (hasToolCalls(redeemed)) await runToolBatch(redeemed); // copied fetch-step active names } if (op.toolBatch?.unresolved) await reconcileToolBatch(op.toolBatch); // A crash mid-step resumes that exact step before new checkpoint input // is consumed (section 7). Live retry and recovery consume identically. - if (op.step?.kind === "assistant") { + if (op.step?.failure) throw new RunFailed(op.step.failure.error); + if (op.step?.started.step === "assistant" && stepNeedsClassification(op.step, state)) { const outcome = await runTurn(); if (outcome) return outcome; - } else if (op.step?.kind === "compaction") { - await autoCompact(requireAutoReason(op.step)); // recorded reason - } else if (op.step) { + } else if (op.step?.started.step === "compaction" && !op.step.resultExists) { + await autoCompact(op.step.started.compactionReason, + overflowLinkFrom(op.step.started)); // exact link is recorded at step scope + } else if (op.step?.started.step === "branch_summary" && !op.step.resultExists) { throw new Error("Run has a branch-summary step"); // corruption } - if (newestOwnMessageIsStepError(state)) { // terminal-failure marker (section 7) + if (newestOwnMessageIsTerminalFailure(state)) { // error or capped interruption (section 7) return await handleRunFailed(existingFailure(state)); } return await driverLoop(); @@ -2232,7 +2971,10 @@ async function runProcedure(): Promise { } async function handleRunSignal(e: unknown): Promise { - if (e instanceof Park) return suspended(e.handle); // discard procedure; lane parked + if (e instanceof Park) { + emit({ type: "run_suspend", runId: op.id, deferred: e.handle }); // exactly once per park + return suspended(e.handle); // unwind invocation; lane parked + } if (e instanceof Aborted) return await abortPath(); if (e instanceof RunFailed) return await handleRunFailed(e.error); throw e; // storage/defect → faulted harness @@ -2240,7 +2982,7 @@ async function handleRunSignal(e: unknown): Promise { ``` -**Fixed-point self-check.** When `resume()` completes, parks, or closes its operation, the harness recomputes the section 7 reduction from storage and compares its `laneState` to the live `LaneState`. A mismatch is corruption and faults the harness — writer/reducer drift is caught the moment it happens instead of one crash later. The check is cheap (the same two bounded reads restore performs) and runs in production, not only under test. +**Fixed-point test invariant.** Focused and manual-drive tests freshly execute the section 7 bounded reads and pure reduction after each durable boundary, suspension, and finish, then compare the result with live `LaneState`. Production does not perform this reread; live commits update state directly. ### The loop @@ -2250,10 +2992,12 @@ async function driverLoop(): Promise { // checkpoint — each consumption is a conditional mutation-line job for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); for (const m of steeringForThisCheckpoint(op)) await fx.consumeQueueItem(op.id, "steer", m.id); - if (op.aborting) return await abortPath(); + if (op.abort) return await abortPath(); if (await contextOverLimit()) { - await autoCompact(pressureReason()); // may throw RunFailed - continue; // fresh checkpoint: input may have arrived during compaction + const compacted = await autoCompact("threshold"); // may throw RunFailed + if (compacted) continue; // fresh checkpoint after a committed compaction + // A threshold hook declined, or there was nothing useful to compact. + // Threshold is proactive, so continue this checkpoint without looping. } if (needsAssistant()) { @@ -2283,21 +3027,24 @@ async function runTurn(): Promise { try { assistant = await assistantStep(); // may throw Park, RunFailed, Aborted, Overflow } catch (e) { - if (e instanceof Overflow) return await recoverOverflow(); + if (e instanceof Overflow) return await recoverOverflow(e); throw e; } - if (assistant.stopReason === "aborted" || op.aborting) return await abortPath(); - if (hasToolCalls(assistant)) await runToolBatch(assistant); + if (op.abort) return await abortPath(); + if (hasToolCalls(assistant)) await runToolBatch(assistant); // uses this generation step's captured active names return undefined; } -async function recoverOverflow(): Promise { - if (op.aborting) return await abortPath(); - if (op.overflowRecoveryUsed) { // once per conversational input (section 6) - await fx.appendEntry(giveUpAssistantEntry(lastAttemptResultId(op), state, truncationError())); +async function recoverOverflow(overflow: Overflow): Promise { + if (op.abort) return await abortPath(); + if (op.overflowRecoveryUsed) { // a linked compaction already used this trigger + // The second recoverable response is already durable and accounted. return await handleRunFailed(truncationError()); } - await autoCompact("overflow"); // declined or nothing to compact → RunFailed + await autoCompact("overflow", { + supersededResponseEntryId: overflow.responseEntryId, + triggerMessageId: overflow.triggerMessageId, + }); // decline or empty preparation → RunFailed return undefined; // driverLoop loops; needsAssistant is still true } @@ -2316,7 +3063,7 @@ async function handleRunFailed(error: OperationError): Promise { if (await fx.consumeQueueItem(op.id, "followUp", m.id) === "consumed") consumed++; } } - if (op.aborting) return await abortPath(); + if (op.abort) return await abortPath(); if (consumed > 0) return await driverLoop(); // input clears the failure const done = await fx.tryFinishRun(op.id, "failed", error); if (done === "finished") return finished("failed", error); @@ -2331,148 +3078,394 @@ async function handleRunFailed(error: OperationError): Promise { ### Steps -A failed attempt appends nothing. Besides the successful response, only a deferred handle, a terminal message, or the final give-up error enters the tree (section 6, retry trace). +Every settled assistant attempt appends exactly one complete response, including retryable errors, overflow responses, deferred handles, terminal errors, and aborted responses. The attempt's second provisioned object is its usage record. No general attempt-outcome or classification record exists. ```ts +async function persistMissingResponseUsage(step: StepState | null): Promise { + const a = step?.newestAttempt; + if (a?.response && !a.usage) { + await fx.appendRecord(preplannedUsageRecord(a.record, a.response.message)); + } +} + async function assistantStep(): Promise { + let step = continuableAssistantStep(op.step, state); + if (!step) { + const started = await fx.startStep({ + id: newId(), runId: op.id, step: "assistant", + triggerMessageId: newestConsumedUserContextId(state), + }); + if (started === "aborted") throw new Aborted(); + step = installedStep(started); + } + + let retryStartingHere: number | undefined; while (true) { - if (op.aborting) throw new Aborted(); - const attempt = (op.step?.kind === "assistant" ? op.step.attempts : 0) + 1; - if (attempt > retry.maxAttempts) { - const error = retriesExhausted(); - // The give-up entry fulfills the last attempt's provisioned id. - await fx.appendEntry(giveUpAssistantEntry(lastAttemptResultId(op), state, error)); - throw new RunFailed(error); + if (op.abort) throw new Aborted(); + await persistMissingResponseUsage(step); + let current = step.newestAttempt; + + if (current?.response) { + const final = current.response.message; + const classification = classifyDurableAssistantResponse({ + response: final, + step: step.started, + attempt: current.record, + abortRequested: op.abort !== null, + laterTransitions: transitionsLinkedTo(current.record.responseEntryId, state), + }); + if (retryStartingHere === current.record.attempt) { + emitRetryEnd(current.record, classification); // existing success/finalError contract + retryStartingHere = undefined; + } + if (classification.kind === "advanced") { + throw new Error("assistantStep entered after its response transition"); // dispatch defect + } + if (classification.kind === "abort") { + if (!op.abort) throw new Error("abort classification without abort_requested"); + throw new Aborted(); + } + if (classification.kind === "overflow") { + throw new Overflow(current.record.responseEntryId, step.started.triggerMessageId); + } + if (classification.kind === "suspend") { + throw new Park(final.deferred); + } + if (classification.kind === "failure") { + throw new RunFailed(final.stopReason === "aborted" + ? providerInterruptionError(final) + : messageError(final)); + } + if (classification.kind !== "retry") { + return final; // accepted stop, toolUse, or genuine length + } + + const nextAttempt = current.record.attempt + 1; + emitRetryScheduled(step.started, nextAttempt, retryErrorMessage(final)); + const slept = await fx.sleep(retryDelay(step.started.retryPolicy, current.record.attempt)); + if (slept === "aborted" || op.abort) throw new Aborted(); + retryStartingHere = nextAttempt; + current = undefined; + } else if (current) { + // The recorded provider effect may have happened. Never reuse its id + // or repeat it under the same attempt number. + if (current.record.attempt >= step.started.retryPolicy.maxAttempts) { + const response = await settleSyntheticResponse( + current.record, interruptedAssistantMessage()); + if (op.abort) throw new Aborted(); + throw new RunFailed(messageError(response.message)); + } + const nextAttempt = current.record.attempt + 1; + emitRetryScheduled(step.started, nextAttempt, "provider outcome unknown after interruption"); + const slept = await fx.sleep(retryDelay(step.started.retryPolicy, current.record.attempt)); + if (slept === "aborted" || op.abort) throw new Aborted(); + retryStartingHere = nextAttempt; + current = undefined; } - const options = await fx.runHook("before_request", - { model: laneModel(state), step: "assistant", attempt, streamOptions }); - const resultEntryId = newId(); - await fx.appendRecord(stepAttempt(op.id, "assistant", attempt, resultEntryId)); + if (!current) { + const attempt = step.attempts.length + 1; + const model = runtime.identities.requireModel(step.started.configuration.model); + const options = await fx.runHook("before_request", + { model, step: "assistant", attempt, streamOptions }); + const limits = assistantRequestLimits(step.started, model, options); + const record = stepAttempt({ + id: newId(), runId: op.id, stepId: step.started.id, step: "assistant", attempt, + responseEntryId: newId(), usageRecordId: newId(), + intendedOutputLimit: limits.intendedOutputLimit, + contextWindow: limits.contextWindow, + }); + const startedAttempt = await fx.startAttempt(record); + if (startedAttempt === "aborted" || op.abort) throw new Aborted(); + if (retryStartingHere === attempt) emitRetryStart(step.started, attempt); + + // fx.streamAssistant emits message_start/update*, runs after_response, + // and emits message_end. Only then may the response entry commit. + const streamed = await fx.streamAssistant( + assistantRequest(step.started, startedAttempt, model, options)); + const response = await fx.settleAttemptResponse(startedAttempt, streamed); // entry_added after commit + await fx.appendRecord(preplannedUsageRecord(startedAttempt, response.message)); + installedAttempt(startedAttempt, response.message); + // The next iteration performs the pure durable classifier. No hook, + // tool plan, retry, overflow step, suspension, or finish can precede it. + } + } +} +``` + +`continuableAssistantStep` returns the existing assistant step only when its newest attempt still needs settlement, usage repair, classification, or a numbered retry. Once that response was accepted and a newer user/tool-result message now requires generation, it returns `undefined`, so `startStep` snapshots a new trigger/configuration instead of reclassifying the old response. This distinction is derived from reduced records and planned entries, not a persisted continuation flag. - const final = await fx.streamAssistant(assistantRequest(state, options)); - await fx.appendRecord(usageRecord("assistant", op.id, resultEntryId, attempt, final)); // ledger, before any branch +The retry helpers preserve the public lifecycle exactly. A durable retryable response or unknown provider effect below the captured cap emits `retry_scheduled` for the next number, then the captured delay crosses `fx.sleep`. If abort cancels that delay, no event for an unstarted attempt follows. After `startAttempt` commits, the later attempt emits `retry_start` and stores only a process-local open-bracket bit; after its response entry and usage are durable and classified, it emits `retry_end` and clears that bit. If marker-backed abort instead settles or closes that started attempt, `emitRetryEndIfActive` closes the same bracket. A retryable later response ends its bracket unsuccessfully before scheduling the following number. First-attempt success emits none of these events. Resume may re-emit a schedule whose earlier process-local event was lost, but it never fabricates `retry_end` without a `retry_start` from that process. + +`classifyDurableAssistantResponse` is the pure section 6 classifier. Its overflow predicate reads only the durable response plus `intendedOutputLimit` and `contextWindow` on the attempt: explicit context-limit error patterns, a `stop` response whose reported input plus cache-read tokens exceeds the captured window, the existing Xiaomi-compatible zero-output/full-window signal, and recoverable `length`. The classifier recognizes an abort marker and any already-linked transition before returning a new ordinary one. With no marker it evaluates overflow first, then treats `aborted` as a retryable provider interruption under the captured policy, then evaluates retryable errors. At the cap an unmarked `aborted` response returns failure, never abort. It creates no durable metadata. Provider-context projection independently omits every `error`, `aborted`, and `deferred` assistant response; genuine `length` remains, while exact overflow omission is materialized by the linked compaction. + +Generated request construction reads the `LaneConfiguration` and normalized `RetryPolicy` on `step_started`, never newer harness or lane values. `summaryStep(kind, reason, resultEntryId, overflowLink?)` accepts only a generated structural source whose start has the typed result id, captured configuration and policy, and compaction reason when applicable. An overflow compaction requires the link, persists both fields on that start, and builds summary preparation and retained-tail data with `overflowLink.supersededResponseEntryId` omitted; other structural steps reject a link. + +```ts +async function summaryStep( + kind: "compaction" | "branch_summary", + reason: "manual" | "threshold" | "overflow" | undefined, + resultEntryId: string, + overflowLink?: OverflowCompactionLink, +): Promise { + const step = requireGeneratedStructuralStep(op.step, kind, reason, + resultEntryId, overflowLink); + let retryPrepared = false; - if (isRecoverableOverflow(final, state)) { - throw new Overflow(); // discarded; resultEntryId stays unfulfilled + while (true) { + if (op.abort) throw new Aborted(); + const previous = step.newestAttempt; + if (previous && !retryPrepared) { + // The caller invokes summaryStep only while its result/prepared payload + // is absent. Re-entry with an attempt therefore means unknown work. + if (previous.record.attempt >= step.started.retryPolicy.maxAttempts) { + const failed = await fx.failStructuralStep(stepFailedFromUnknown(previous.record)); + if (failed === "aborted") throw new Aborted(); + throw new RunFailed(failed.error); + } + const next = previous.record.attempt + 1; + emitRetryScheduled(step.started, next, "structural provider outcome unknown after interruption"); + const slept = await fx.sleep(retryDelay(step.started.retryPolicy, previous.record.attempt)); + if (slept === "aborted" || op.abort) throw new Aborted(); } - if (final.stopReason === "deferred") { - await fx.appendEntry(assistantEntry(resultEntryId, final)); - emit({ type: "run_suspend", runId: op.id, deferred: final.deferred }); - throw new Park(final.deferred); + retryPrepared = false; + + const attempt = step.attempts.length + 1; + const record = stepAttempt({ + id: newId(), type: "step_attempt", runId: op.id, + stepId: step.started.id, step: kind, attempt, + }); + const started = await fx.startAttempt(record); + if (started === "aborted" || op.abort) throw new Aborted(); + if (attempt > 1) emitRetryStart(step.started, attempt); + + // Split-turn compaction may invoke request() twice. Each invocation runs + // before_request through fx, crosses one fx.streamAssistant action with a + // private sink, and writes reported usage before request() resolves. + const outcome = await runGeneratedSummaryAttempt(kind, preparationFor(step), { + request: async (messages) => { + const model = runtime.identities.requireModel(step.started.configuration.model); + const options = await fx.runHook("before_request", + { model, step: kind, attempt, streamOptions: structuralStreamOptions() }); + const response = await fx.streamAssistant(structuralRequest( + messages, step.started, started, model, options, privateEventSink)); + if (hasReportedUsage(response)) { + await fx.appendRecord(structuralUsageRecord( + op.id, step.started.id, resultEntryId, attempt, kind, response)); + } + return response; + }, + }); + if (attempt > 1) emitRetryEnd(started, + op.abort ? { kind: "abort" } : outcome); + if (op.abort) throw new Aborted(); // usage above remains billed + + if (outcome.kind === "success") { + return outcome.result; // durable commit is the caller's next action } - if (final.stopReason === "error" && isRetryable(final)) { - await fx.sleep(retryDelay(attempt)); // retry events around this - continue; // durable count already advanced + if (outcome.kind === "retry" && attempt < step.started.retryPolicy.maxAttempts) { + emitRetryScheduled(step.started, attempt + 1, outcome.error.message); + const slept = await fx.sleep(retryDelay(step.started.retryPolicy, attempt)); + if (slept === "aborted" || op.abort) throw new Aborted(); + retryPrepared = true; + continue; } - await fx.appendEntry(assistantEntry(resultEntryId, final)); - if (final.stopReason === "error") throw new RunFailed(messageError(final)); - return final; // stop, toolUse, genuine length, aborted + const failed = await fx.failStructuralStep(stepFailedFromOutcome(started, outcome)); + if (failed === "aborted") throw new Aborted(); + throw new RunFailed(failed.error); } } ``` -`isRecoverableOverflow(final, state)` is `isContextOverflow(final)` — overflow-pattern errors and silent overflow — or `isRecoverableLength(final, desiredMaxOutput(state))` from section 6, where `desiredMaxOutput(state)` is the caller-supplied `maxTokens` when set, else the lane model's `maxTokens`. The check runs before the retryable-error branch: an overflow-form error compacts instead of retrying the same oversized request. +The `step_attempt` is durable before the first provider request of an attempt. One attempt may make one or two non-deferred requests, and every individual request crosses `fx.streamAssistant`; each reported usage write immediately follows that request. Structural streams use a private event sink, so none emits public assistant-message lifecycle. A crash anywhere before the structural result boundary makes the whole attempt unknown and starts a later number only under the captured policy. + +Generated compaction returns the complete result in memory and the caller immediately attempts its result-entry commit under `step_started.resultEntryId`; there is deliberately no prepared-result record. Generated branch summary instead calls `fx.prepareBranchSummary` with the complete provisioned `BranchSummaryEntry`, `fromHook: false`, and the successful attempt number before navigation may move. A crash after `branch_summary_prepared` never starts another provider request. -`summaryStep(step, reason, resultEntryId)` has the same shape: `step_attempt` before each attempt (`compactionReason` for compaction steps) carrying the step's single result id, `before_request`, one or two non-deferred requests — each followed by its `usage` record bound to that id — durable cap. It returns the summary value; the caller appends the result entry under that id. A hook-supplied summary makes no request and no request record; its entry persists `fromHook: true`, and if it carries usage the hook measured itself, the appending procedure writes a `hook` usage record beside the entry. For reason `overflow` the appending procedure also writes the compaction `step_attempt`, so the once-per-input guard counts the recovery (section 6). +At the cap, or after a terminal provider failure, `failStructuralStep` conditionally appends `step_failed` and throws `RunFailed`. An abort marker that predates the compaction-entry/navigation-move commit makes that conditional return `"aborted"`, so no `step_failed` is written. The procedure never writes an assistant error into a compaction or branch-summary id. A hook-supplied result takes a separate source path: after the decision hook returns, the harness constructs the complete provisioned typed entry, including `fromHook: true`, preparation fields, details, and immutable usage snapshot, and conditionally commits it on `step_started` with a usage-record id exactly when usage exists. Re-entry repairs that exact `cause: "hook"` record before entry commit and never reruns the hook. For overflow, this hook-sourced compaction start also carries the exact response/trigger link, so it consumes the same-trigger allowance. ### Deferred redemption ```ts +function routeDeferredClassification( + classification: DeferredResponseClassification, + source: MessageEntry, +): SettledAssistantMessage { + if (classification.kind === "abort") throw new Aborted(); + if (classification.kind === "pending") throw new Park(classification.handle); + if (classification.kind === "interrupted") { + throw new Park(source.message.deferred!); // exact source remains unredeemed + } + if (classification.kind === "failure") throw new RunFailed(classification.error); + if (classification.kind === "advanced") { + throw new Error("deferred response already has a later transition"); + } + return classification.message; // ready +} + async function redeemDeferred(): Promise { - const final = await fx.fetchDeferred(deferredModel(state), op.deferred!); - const resultEntryId = newId(); - if (final.stopReason !== "deferred" || hasReportedUsage(final)) { - await fx.appendRecord(usageRecord("deferred_fetch", op.id, resultEntryId, 1, final)); + let step = op.step?.started.step === "deferred_fetch" ? op.step : undefined; + if (!step) { + // This bounded reduced-state lookup happens only while creating F. Once + // step_started commits, every later process reads F's copies. + const original = generationStepForInitialDeferredResponse(state); + const started = await fx.startStep({ + id: newId(), runId: op.id, step: "deferred_fetch", + configuration: copyLaneConfiguration(original.configuration), + retryPolicy: copyRetryPolicy(original.retryPolicy), + }); + if (started === "aborted") throw new Aborted(); + step = installedStep(started); + } + + await persistMissingResponseUsage(step); + const source = newestDeferredSourceEntry(state); + const sourceAttempts = attemptsForDeferredSource(step, source.id); + const prior = sourceAttempts.at(-1); + if (prior && !prior.response && sourceAttempts.length >= step.started.retryPolicy.maxAttempts) { + const response = await settleSyntheticResponse( + prior.record, interruptedDeferredMessage(source)); + if (op.abort) throw new Aborted(); + throw new RunFailed(providerInterruptionError(response.message)); } - if (op.aborting) throw new Aborted(); - if (final.stopReason === "deferred") { - requireSameHandle(final.deferred, op.deferred!); // mismatch is a defect (section 16) - throw new Park(op.deferred!); // pending; no other write + if (prior && (!prior.response || + (prior.response.message.stopReason === "aborted" && !op.abort))) { + const slept = await fx.sleep(retryDelay(step.started.retryPolicy, sourceAttempts.length)); + if (slept === "aborted" || op.abort) throw new Aborted(); } - if (final.stopReason === "aborted") throw new Aborted(); - await fx.appendEntry(assistantEntry(resultEntryId, final)); // ready or terminal - if (final.stopReason === "error") throw new RunFailed(messageError(final)); - return final; + const attempt = step.attempts.length + 1; // never reuse an unknown poll + const record = stepAttempt({ + id: newId(), runId: op.id, stepId: step.started.id, + step: "deferred_fetch", attempt, sourceEntryId: source.id, + responseEntryId: newId(), usageRecordId: newId(), + }); + const startedAttempt = await fx.startAttempt(record); + if (startedAttempt === "aborted" || op.abort) throw new Aborted(); + + // The exact source supplies identity. fx.fetchDeferred performs one wait:0 + // check, runs response hooks, and emits message_end before returning. + const fetched = await fx.fetchDeferred(source, { wait: 0 }); + const response = await fx.settleAttemptResponse(startedAttempt, fetched); + await fx.appendRecord(preplannedUsageRecord(startedAttempt, response.message)); + const settledStep = installedAttempt(startedAttempt, response.message); + return routeDeferredClassification( + classifyDurableDeferredResponse(settledStep, state), source); } ``` -One fetch per `resume()`. Pending re-parks without a write. A terminal answer — returned or converted from a rejected fetch — lands as the error entry and fails the run through the normal drain path, which still honors input accepted before the failure (section 6). +`classifyDurableDeferredResponse` uses the configuration and policy copied onto F and the exact source lineage. It performs the same complete-handle check for a durable pending response before making that response the new source. A below-cap unmarked `aborted` response is itself the durable suspension transition but retains its source; a capped one is terminal failure. Neither becomes operation abort without the marker. + +At most one fetch runs per `resume()`, and it is a check-once request with `wait: 0`. A pending poll appends its distinct response and usage before re-parking on that response, including when its handle equals the source handle. An unmarked interrupted poll does the same but retains its exact source; the next `resume()` applies captured backoff and may make one new attempt below the per-source attempt cap. Neither path emits retry lifecycle events. A ready response uses F's copied active tool names for clearance and execution. A terminal answer — returned or converted from a rejected fetch — lands as the error entry and fails the run through the normal drain path, which still honors input accepted before the failure (section 6). If a crash leaves a poll attempt without its response and no abort marker exists, a later resume provisions the next numbered attempt rather than reusing either preplanned id when below that cap; at the cap it settles the missing id with a synthetic interruption error and fails. With abort, recovery writes synthetic `aborted` under the missing poll's existing response id and does not fetch. ### Tools -The live path is section 14 `executeToolBatch`; the durability callbacks route through `fx`, so the gate and the traces see every write in order: +The live path commits the complete batch plan before calling section 14 `executeToolBatch`; the batch itself is never one gated action. Provider `toolCallId` values are unique within the assistant response by contract. The helper still passes the original source call object to callbacks so `plannedCallFor` can locate its source-ordered planned result without adding an index to hooks, events, or tool context. For an ordinary batch, `runtime.identities.requireToolsForResponseStep` reads the durable step whose attempt provisioned the response and resolves only its captured active names: an assistant response uses its generation snapshot, while a fetched response uses the deferred step's copied active names. A genuine output-limit `length` batch resolves no tool implementations because it performs no clearance or invocation. Every durability callback, hook, and individual phase-two effect then routes through `fx`: ```ts async function runToolBatch(assistant: AssistantMessage, telemetryContext: TelemetryContext): Promise { - const resultIds = new Map(); // toolCallId → provisioned id + const assistantEntryId = newestAssistantEntryId(state); + let batch = op.toolBatch?.assistantEntryId === assistantEntryId ? op.toolBatch : undefined; + if (!batch) { + const sourceCalls = toolCallsWithSourceIndexes(assistant); + const plan = await fx.startToolBatch({ + id: newId(), type: "tool_batch_started", runId: op.id, assistantEntryId, + calls: sourceCalls.map(({ toolIndex }) => ({ toolIndex, resultEntryId: newId() })), + }, telemetryContext); + if (plan === "aborted") throw new Aborted(); + batch = installedToolBatch(plan, assistant); // exact source-index mapping + } + + const plannedCallFor = (call: AgentToolCall) => + batch!.calls.find((item) => item.toolCall === call)!; - await executeToolBatch(assistant, gatedActiveTools(), { + const tools = assistant.stopReason === "length" + ? [] + : runtime.identities.requireToolsForResponseStep(assistant, state); + await executeToolBatch(assistant, tools, { beforeToolCall: async (call, args) => { return await fx.runHook("before_tool", - { toolCallId: call.id, toolName: call.name, args }); // may patch args or block + { toolCallId: call.id, toolName: call.name, args }, telemetryContext); }, onToolStart: async (call, effectiveArgs) => { - const resultEntryId = newId(); - resultIds.set(call.id, resultEntryId); - await fx.appendRecord(toolStarted(op.id, { - assistantEntryId: newestAssistantEntryId(state), - toolIndex: indexOf(assistant, call), + const planned = plannedCallFor(call); // id stays on the plan + const started = await fx.startTool(toolStarted(op.id, { + assistantEntryId, toolIndex: planned.toolIndex, toolCallId: call.id, toolName: call.name, - effectiveArgs, resultEntryId, - replay: declaredReplay(call), - })); + effectiveArgs, replay: declaredReplay(call), + }), telemetryContext); + if (started === "aborted") throw new Aborted(); + }, + executeTool: (prepared) => { + if (op.abort) throw new Aborted(); + return fx.executeTool(prepared, telemetryContext); }, afterToolCall: (call, args, result, isError) => - fx.runHook("after_tool", { toolCallId: call.id, toolName: call.name, args, ...result, isError }), - onToolResult: async (message, terminate) => { - // Blocked/invalid calls have no tool_started and no provisioned id; - // their error result entry gets a fresh id (section 5). - const entryId = resultIds.get(message.toolCallId) ?? newId(); + fx.runHook("after_tool", + { toolCallId: call.id, toolName: call.name, args, ...result, isError }, + telemetryContext), + onToolResult: async (call, message, terminate) => { + const planned = plannedCallFor(call); // blocked/invalid use this too if (message.usage) { - await fx.appendRecord(toolUsageRecord(op.id, entryId, message.toolCallId, message.usage)); + await fx.appendRecord(toolUsageRecord(op.id, planned.resultEntryId, + call.id, message.usage), telemetryContext); } - await appendIfMissing(resultEntry(entryId, message, terminate)); + await appendIfMissing(resultEntry(planned.resultEntryId, message, terminate)); }, }, { toolExecution: config.toolExecution }, emitLaneEvents, telemetryContext, abortSignal); } ``` -The recovery path handles each call at its crash site, in source order, keeping original ordinals: +Ordinary `runProcedure` re-entry calls `reconcileToolBatch` for any reduced plan with missing results; there is no separate recovery dispatcher. It handles each planned call at its section 6 site in source order. `runPlannedToolCall` composes `prepareToolCall`, the `tool_started` append, `fx.executeTool`, `finalizeToolCall`, optional usage, immediate tool-result message lifecycle, and the planned result append for one call; it allocates no id. A real result keeps that finalized execution's own usage, while a synthetic has none; earlier usage records remain ledger-only. Thus a call with no start reruns clearance, while a started call never reruns clearance or derives arguments again: ```ts -async function reconcileToolBatch(batch: ToolBatchState, telemetryContext: TelemetryContext): Promise { - if (batch.truncated) { // stopReason "length": never execute +async function appendReconciledToolResult(target: ProvisionedEntry): Promise { + if (plannedEntries.has(target.id)) return appendIfMissing(target); + emitImmediateMessageLifecycle(target.message, target.id); + await appendIfMissing(target); +} + +async function reconcileToolBatch(batch: ToolBatchState, + telemetryContext: TelemetryContext): Promise { + if (op.abort) throw new Aborted(); // abortPath owns this state + if (batch.genuineLength) { // accepted length: never execute for (const call of batch.calls) { - if (!call.resultExists) await appendIfMissing(truncatedToolResult(newId(), call.toolCall)); + if (!call.result) { + await appendReconciledToolResult(incompleteArgumentsToolResult( + call.resultEntryId, call.toolCall)); + } } - return; + return; // planned errors force another turn } for (const call of batch.calls) { - if (call.resultExists) continue; - - if (call.started) { // X3: effect outcome unknown - if (call.started.replay === "safe" && currentDeclaration(call) === "safe") { - const prepared = { kind: "prepared", toolCall: call.toolCall, - tool: toolByName(call.started.toolName), - args: call.started.effectiveArgs }; // persisted, not re-derived - const executed = await fx.executeTool(prepared); - const finalized = await finalizeToolCall(prepared, executed, - { afterToolCall }, telemetryContext, abortSignal); // fx-wired hook callback - if (finalized.result.usage) { - await fx.appendRecord(toolUsageRecord(op.id, call.started.resultEntryId, - call.toolCall.id, finalized.result.usage)); // the replay's own record - } - await appendIfMissing(resultEntry(call.started.resultEntryId, - createToolResultMessage(finalized), finalized.result.terminate === true)); - } else { - await appendIfMissing(syntheticResult(call.started.resultEntryId, "interrupted")); + if (call.result) continue; + + if (!call.started) { // X2/X3: full clearance path + await runPlannedToolCall(call, telemetryContext); // all hooks/effects/writes use fx + continue; // same plan; no fresh result id + } + + // Missing current implementation means no safe replay and needs no + // identity error because the synthetic path performs no tool effect. + const currentReplay = runtime.identities.replayDeclaration(call.started.toolName); + if (call.started.replay === "safe" && currentReplay === "safe") { + const prepared = { kind: "prepared", toolCall: call.toolCall, + tool: runtime.identities.requireTool(call.started.toolName), + args: call.started.effectiveArgs }; // persisted, not re-derived + const executed = await fx.executeTool(prepared, telemetryContext); + const finalized = await finalizeToolCall(prepared, executed, + { afterToolCall: fxWiredAfterTool(telemetryContext) }, + telemetryContext, abortSignal); + if (finalized.result.usage) { + await fx.appendRecord(toolUsageRecord(op.id, call.resultEntryId, + call.toolCall.id, finalized.result.usage), telemetryContext); // replay's own cost } - } else { // X1/X2: full path, original ordinal - await runToolBatchForSingleCall(call); + await appendReconciledToolResult(resultEntry(call.resultEntryId, + createToolResultMessage(finalized), finalized.result.terminate === true)); + } else { + await appendReconciledToolResult( + syntheticResult(call.resultEntryId, "interrupted")); } } } @@ -2480,139 +3473,257 @@ async function reconcileToolBatch(batch: ToolBatchState, telemetryContext: Telem ### Abort -`abort()` itself is a lane-surface job (mutation line, above): marker, queue drain, signal, resolve. Reconciliation is procedure work. If the operation was suspended with no procedure running, `abort()` starts one at the abort path; manual mode leaves it parked at its first action. +`abort()` itself is the idempotent lane-surface job described above: first marker, stable queue drain, one signal, resolve. Reconciliation is procedure work. A running procedure reaches it after any already-started in-process provider/tool settlement has passed through its conditional append/finalization. If the operation was suspended with no procedure running, the first or repeated `abort()` starts or joins one abort path; manual mode leaves it parked at its first action. ```ts +async function settleAbortedProviderAttempt(): Promise { + const step = op.step; + const current = step?.newestAttempt; + if (!current || (step.started.step !== "assistant" && + step.started.step !== "deferred_fetch")) return; + + if (current.response) { + if (!current.usage) { + await fx.appendRecord(preplannedUsageRecord(current.record, current.response.message)); + } + emitRetryEndIfActive(current.record, { kind: "abort" }); + return; // preserve its committed stop reason + } + + // The prior provider effect is unknown. Abort forbids a later attempt. + const synthetic = syntheticAbortedMessage(step.started, current.record); // zero usage; + // identity comes from durable data + await settleSyntheticResponse(current.record, synthetic); + emitRetryEndIfActive(current.record, { kind: "abort" }); +} + async function abortPath(): Promise { - if (op.deferred) await fx.cancelDeferred(deferredModel(state), op.deferred); // best effort: - // rejection → telemetry, then proceed + await settleAbortedProviderAttempt(); + if (op.deferred) { + const source = newestDeferredSourceEntry(state); + await bestEffortCancelDeferred(source); // internally calls fx.cancelDeferred when resolvable; + // failures are telemetry only + } + while (true) { + // Live started effects have already finalized. A missing started result is + // therefore an unknown crashed effect and is never replayed after abort. for (const call of op.toolBatch?.calls ?? []) { - if (call.resultExists) continue; - await appendIfMissing(syntheticResult(idFor(call), call.started ? "interrupted" : "aborted")); + if (call.result) continue; + await appendReconciledToolResult(syntheticResult( + call.resultEntryId, call.started ? "interrupted" : "aborted")); } - for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); // facts survive abort - if (!newestOwnMessageIsAborted(state)) await appendIfMissing(abortClosureEntry(newId(), state)); + for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); const done = await fx.finishOperation(op.id, "aborted"); - if (done === "finished") return finished("aborted"); - // "continue": a deferred write arrived meanwhile — apply it before closing + if (done === "finished") return finished("aborted"); // optional final assistant fields + // "continue": a deferred write arrived meanwhile — apply it before the terminal record } } ``` +Neither helper resolves runtime model/tool implementations. The synthetic assistant uses the captured model reference and the planned tool results use stored calls and ids. `bestEffortCancelDeferred` calls `fx.cancelDeferred(source)` only when the environmental capability can be resolved and suppresses expected provider cancellation failure after telemetry; `HarnessClosed` and a harness fault still unwind. No abort path calls `newId()` for an assistant response. + +### Close + +Close is process lifecycle, not operation abort. It writes no `abort_requested` or `operation_finished` and does not run `abortPath()`: + +```ts +async function closeHarness(): Promise { + closeAdmission(); // all new public calls now observe Closed + signalRunningProviderAndToolEffects(); // process-local signal only; no durable marker + gatedEffects.rejectAllParked(HarnessClosed); // includes nested and pre-acceptance actions + rejectLocalOperationPromises(HarnessClosed); + + await laneMutationLines.settleAdmittedJobs(); // an append already entered may finish; + // no procedure may enqueue its next effect + await ownedSession.close(); // drains Session/storage queues, stops renewal, + // releases only this owner/fence claim +} +``` + +A provider or tool effect that returns because of the close signal cannot append its response/result after admission closes; the corresponding prior attempt or tool-start intent remains the durable crash prefix. An already-entered Session append is allowed to settle and all of its live-state updates complete before `Session.close()`. Manual close rejects every unreleased action without running it. Reopen reduces the same prefix and ordinary `resume()` continues it. No shutdown-only record or recovery procedure exists. + ### Structural operations ```ts +async function persistHookStructuralUsage(step: StepState | null): Promise { + if (!step || step.started.step === "assistant" || + step.started.step === "deferred_fetch" || + step.started.source !== "hook" || step.hookUsageExists) return; + const { hookResult, hookUsageRecordId } = step.started; + if (hookResult.usage) { + await fx.appendRecord(hookUsageRecord( + hookUsageRecordId!, op.id, hookResult.id, hookResult.usage)); + } +} + async function compactionProcedure(): Promise { try { - if (op.aborting) return await abortStructural(); + await persistHookStructuralUsage(op.step); + if (op.abort && !op.targets.result) return await abortStructural(); + if (op.step?.failure) throw new RunFailed(op.step.failure.error); + + let step = op.step; + if (!step && !op.targets.result) { + const prep = preparation(state); + const hook = await fx.runHook("before_compaction", { + reason: "manual", preparation: prep, + customInstructions: op.intent.customInstructions, + }); + if (hook?.decline) return await finishStructural("declined"); + const start = hook?.compaction + ? hookStructuralStart("compaction", op.intent.resultEntryId, + compactionEntry(op.intent.resultEntryId, hook.compaction, true, + { preparation: prep })) + : generatedStructuralStart("compaction", op.intent.resultEntryId); + const started = await fx.startStep({ + id: newId(), runId: op.id, compactionReason: "manual", ...start, + }); + if (started === "aborted") return await abortStructural(); + step = installedStep(started); + await persistHookStructuralUsage(step); + } + if (!op.targets.result) { - let result: CompactResult | undefined; - let fromHook = false; - if (!op.step) { // no attempt yet: the decision hook may still run - const hook = await fx.runHook("before_compaction", - { reason: "manual", preparation: preparation(state), - customInstructions: op.intent.customInstructions }); - if (hook?.decline) return await finishStructural("declined"); - result = hook?.compaction; - fromHook = result !== undefined; - if (result?.usage) { - await fx.appendRecord(hookUsageRecord(op.id, op.intent.resultEntryId, result.usage)); - } - } - result ??= await summaryStep("compaction", "manual", op.intent.resultEntryId); - await appendIfMissing(compactionEntry(op.intent.resultEntryId, result, fromHook)); + const entry = step!.started.source === "hook" + ? step!.started.hookResult + : compactionEntry(op.intent.resultEntryId, + await summaryStep("compaction", "manual", op.intent.resultEntryId), false); + const commit = await fx.commitStructuralEntry(entry); + if (commit === "aborted") return await abortStructural(); } return await finishStructural("completed"); } catch (e) { return await handleStructuralSignal(e); } } +interface OverflowCompactionLink { + supersededResponseEntryId: string; + triggerMessageId: string; +} + /** Inside a run, at a checkpoint or after an overflow response. Same hook, - same durable attempts and cap as manual compaction; no nested operation - records. Exhausted retries throw RunFailed — the enclosing run drains - and finishes failed, without before_run_end (section 11). For reason - "overflow", a hook decline or an empty preparation also throws - RunFailed: without compaction the request cannot fit (section 6). */ -async function autoCompact(reason: "threshold" | "overflow"): Promise { - const resultEntryId = op.step?.kind === "compaction" ? op.step.resultEntryId : newId(); - if (op.step?.kind !== "compaction") { // no durable compaction decision yet; on the overflow - // path op.step is the abandoned assistant step - const prep = preparation(state); + source records, durable attempts, and cap as manual compaction; no nested + operation records. Overflow always carries the exact response and trigger. + A decline or empty preparation throws RunFailed because the request cannot + fit without compaction. */ +async function autoCompact( + reason: "threshold" | "overflow", + requestedLink?: OverflowCompactionLink, +): Promise { + let step = op.step?.started.step === "compaction" ? op.step : undefined; + const resultEntryId = step?.started.resultEntryId ?? newId(); + const link = step ? overflowLinkFrom(step.started) : requestedLink; + requireLinkExactlyForOverflow(reason, link); + + if (!step) { + const prep = preparation(state, + link ? { omitEntryId: link.supersededResponseEntryId } : undefined); if (nothingToCompact(prep)) { if (reason === "overflow") throw new RunFailed(truncationError()); - return; + return false; } const hook = await fx.runHook("before_compaction", { reason, preparation: prep }); if (hook?.decline) { if (reason === "overflow") throw new RunFailed(truncationError()); - return; - } - if (hook?.compaction) { - if (reason === "overflow") { // the once-per-input guard counts this attempt - await fx.appendRecord(stepAttempt(op.id, "compaction", 1, resultEntryId, reason)); - } - if (hook.compaction.usage) { - await fx.appendRecord(hookUsageRecord(op.id, resultEntryId, hook.compaction.usage)); - } - await appendIfMissing(compactionEntry(resultEntryId, hook.compaction, true)); - return; + return false; } + const start = hook?.compaction + ? hookStructuralStart("compaction", resultEntryId, + compactionEntry(resultEntryId, hook.compaction, true, { preparation: prep })) + : generatedStructuralStart("compaction", resultEntryId); + const started = await fx.startStep({ + id: newId(), runId: op.id, compactionReason: reason, ...link, ...start, + }); + if (started === "aborted") throw new Aborted(); + step = installedStep(started); } - const result = await summaryStep("compaction", reason, resultEntryId); - await appendIfMissing(compactionEntry(resultEntryId, result, false)); + + await persistHookStructuralUsage(step); + if (op.abort) throw new Aborted(); + const entry = step.started.source === "hook" + ? step.started.hookResult + : compactionEntry(resultEntryId, + await summaryStep("compaction", reason, resultEntryId, link), false); + const commit = await fx.commitStructuralEntry(entry); + if (commit === "aborted") throw new Aborted(); + return true; } async function navigationProcedure(): Promise { try { - if (op.aborting) return await abortStructural(); - const moved = state.leafId === op.intent.targetId; // acceptance rejected target == source - let summary: SummaryValue | undefined; - let fromHook = false; + let moved = navigationMoveCommitted(state); // target or summary-entry leaf + await persistHookStructuralUsage(op.step); + if (op.abort && !moved) return await abortStructural(); + if (op.step?.failure) throw new RunFailed(op.step.failure.error); + + let step = op.step; + if (op.intent.summarize && !step) { + if (moved) throw new Error("Navigation moved without a durable summary payload"); + const prep = navigationPreparation(op.sourceLeafId, op.intent.targetId); + const hook = await fx.runHook("before_navigation", { + targetId: op.intent.targetId, + preparation: prep, + customInstructions: op.intent.customInstructions, + }); + if (hook?.decline) return await finishStructural("declined"); + const start = hook?.summary + ? hookStructuralStart("branch_summary", op.intent.summaryEntryId!, + branchSummaryEntry(op.intent.summaryEntryId!, hook.summary, true, prep)) + : generatedStructuralStart("branch_summary", op.intent.summaryEntryId!); + const started = await fx.startStep({ + id: newId(), runId: op.id, ...start, + }); + if (started === "aborted") return await abortStructural(); + step = installedStep(started); + await persistHookStructuralUsage(step); + } - if (op.intent.summarize && !op.targets.summary) { - if (!moved && !op.step) { // decision hook: once, pre-move - const hook = await fx.runHook("before_navigation", - { targetId: op.intent.targetId, - preparation: preparation(state) }); // preparation derives from - // intent.sourceLeafId — valid pre- and post-move - if (hook?.decline) return await finishStructural("declined"); - summary = hook?.summary; - fromHook = summary !== undefined; - if (summary?.usage) { - await fx.appendRecord(hookUsageRecord(op.id, op.intent.summaryEntryId!, summary.usage)); - } - } - summary ??= await summaryStep("branch_summary", undefined, - op.intent.summaryEntryId!); // regenerates after a post-move crash + if (op.intent.summarize && !structuralPayload(step!)) { + const generated = await summaryStep( + "branch_summary", undefined, op.intent.summaryEntryId!); + const prepared = await fx.prepareBranchSummary(branchSummaryPrepared({ + id: newId(), runId: op.id, stepId: step!.started.id, + attempt: step!.attempts.at(-1)!.attempt, + result: branchSummaryEntry( + op.intent.summaryEntryId!, generated, false, + navigationPreparation(op.sourceLeafId, op.intent.targetId)), + })); + if (prepared === "aborted") return await abortStructural(); } - if (!moved) await fx.moveLane(op.intent.targetId); // the commit point (section 6) - if (op.intent.summarize && !op.targets.summary) { - await appendIfMissing(summaryEntry(op.intent.summaryEntryId!, summary!, fromHook)); // chains to the target + if (!moved) { + const commit = await fx.commitNavigationMove(op.intent.targetId); + if (commit === "aborted") return await abortStructural(); + moved = true; } - if (op.intent.label !== undefined) { - await fx.setFact(labelFact(op.intent.targetId, op.intent.label)); // idempotent + if (op.intent.summarize && !op.targets.summary) { + await appendIfMissing(structuralPayload(op.step!)!); // exact hook/prepared payload } + // finishOperation atomically appends the accepted label fact with the + // terminal record when this completed navigation is labeled. return await finishStructural("completed"); } catch (e) { return await handleStructuralSignal(e); } } async function finishStructural(outcome: "completed" | "declined") { const done = await fx.finishOperation(op.id, outcome); - if (done === "continue") return await abortStructural(); // abort won the ordering - return structuralOutcome(outcome); + if (done === "continue") return await abortStructural(); // abort won before commit + return structuralOutcome(outcome); // committed structure wins later abort } async function abortStructural() { - // Nothing to reconcile: structural operations own no tool batch, and - // lane-view writes wait for them (section 12). - await fx.finishOperation(op.id, "aborted"); + // Called only before the compaction-entry/navigation-move commit. Persisted + // hook usage is accounting, but no structural result or assistant entry is added. + await persistHookStructuralUsage(op.step); + emitRetryEndIfActive(op.step?.newestAttempt?.record, { kind: "abort" }); + const done = await fx.finishOperation(op.id, "aborted"); + if (done !== "finished") throw new Error("aborted structural operation did not finish"); return structuralOutcome("aborted"); } async function handleStructuralSignal(e: unknown) { - if (e instanceof Aborted) return await abortStructural(); + if (e instanceof Aborted) return await abortStructural(); if (e instanceof RunFailed) { const done = await fx.finishOperation(op.id, "failed", e.error); return done === "continue" ? await abortStructural() : structuralOutcome("failed", e.error); @@ -2625,26 +3736,32 @@ Hook-to-block wiring, in one table: | harness hook | insertion point | |---|---| -| `transform_context` | inside `fx.streamAssistant` (`StreamAssistantConfig.transformContext`) | -| `before_request` | before `fx.streamAssistant`, patches stream options | -| `before_payload` | inside the stream function, provider level | -| `after_response` | on the stream result, before the entry is appended | +| `before_run` | pre-acceptance lane call; output is consumed by the ungated acceptance job | +| `before_resume` | `resume()` dispatch, before any other procedure effect | +| `before_run_end` | `driverLoop` finish boundary; result committed via `fx.commitRunEndFollowUp` | +| `transform_context` | nested inside `fx.streamAssistant` (`StreamAssistantConfig.transformContext`) | +| `before_request` | before each `fx.streamAssistant`, patches stream options | +| `before_payload` | nested inside the stream function, provider level | +| `after_response` | nested on the stream result, before `message_end` and the later entry append | | `before_tool` | `ToolCallbacks.beforeToolCall` (phase 1) | | `after_tool` | `ToolCallbacks.afterToolCall` (phase 3) | -| `before_run_end` | `driverLoop` finish boundary; result committed via `fx.commitRunEndFollowUp` | -| `before_resume` | `resume()` dispatch, before any effect | +| `before_compaction` | manual/threshold/overflow decision before conditional `step_started` | +| `before_navigation` | summarized-navigation decision before conditional `step_started` | | — (record/entry writes) | `ToolCallbacks.onToolStart` / `onToolResult` via `fx` | Notes: - Auto-compaction inside a run runs under the run's own records; no nested operation. -- There is no "crashed mid-step" case in the code: an interrupted attempt is an attempt without a result entry, and the cap check decides retry versus `RunFailed`. -- Parallel batches and crash sites compose: `tool_started` records are written in source order during the sequential phase-1 pass, so a crash mid-batch leaves a source-order prefix of records — some with results, some without (section 6 table applies per call). -- An aborted assistant message (`stopReason: "aborted"`) skips tool execution; `abortPath()` owns the synthetic results. -- A crash between the navigation move and its summary entry loses the in-memory summary text; recovery regenerates it under the same attempt cap. A hook-supplied summary lost in that window is regenerated rather than re-asked: the hook's decline authority ended at the move. +- There is no persisted program counter for "crashed mid-step." Without abort, an assistant attempt without its response starts a later numbered attempt or receives a synthetic interruption at the cap; with abort it receives synthetic `aborted` under that same attempt id. A generated compaction attempt without its result entry and a generated branch-summary attempt without `branch_summary_prepared` start a later attempt or close with `step_failed`, unless pre-commit abort closes the operation first. Hook-source steps never repeat their decision hook. +- Parallel batches and crash sites compose: the complete `tool_batch_started` precedes phase 1; real calls then get source-ordered `tool_started` records immediately before their individually gated dispatches. A crash may leave several started effects, including starts beyond an earlier unresolved or immediate call, but committed results are always a source-order prefix. Section 6 reduces every planned source index independently. +- Every assistant message with `stopReason: "aborted"` skips tool execution. With an earlier abort marker, `abortPath()` owns any missing planned results and never creates an assistant response id. Without the marker, assistant/fetch classification retries under captured policy or fails at the cap; it never enters `abortPath()`. +- Abort before a compaction entry or navigation move suppresses that structural commit and finishes aborted. Abort after it leaves the committed structure in place and completes its remaining writes with outcome completed. +- A crash between the navigation move and summary entry retains the complete hook result on `step_started` or generated result on `branch_summary_prepared`. Recovery appends that exact payload and never invokes the hook or provider after the move. ## 16. pi-ai: deferred requests +The pi-ai deferred request, fetch, cancellation, and authenticated `Models` dispatch APIs below are already landed. Harness package H8 integrates them; it assigns no new work to `packages/ai`. + Everything is per-request; batch APIs can implement the same shape through a custom provider. ```ts @@ -2748,17 +3865,19 @@ interface Models { } ``` -`Models.fetchDeferred` and `Models.cancelDeferred` delegate to the provider methods with normal model resolution and authentication (credential store, expiring tokens, header merge). Their options carry the normal HTTP request settings, lifecycle callbacks, and model transforms; fetch options additionally carry the provider long-poll duration. A provider that returns `stopReason: "deferred"` must implement fetch; cancellation is best effort. +`Models.fetchDeferred` and `Models.cancelDeferred` delegate to the provider methods with normal model resolution and authentication (credential store, expiring tokens, header merge). Their options carry the normal HTTP request settings, lifecycle callbacks, and model transforms; fetch options additionally carry the provider long-poll duration. A provider that returns `stopReason: "deferred"` must implement fetch; cancellation is best effort. The harness passes `wait: 0` on every redemption, so one `resume()` checks once and re-parks when the result is still pending; the application schedules another resume and may use the persisted `pollAfterMs` hint. -A terminal fetch answer is final for the run: the harness appends the error message and fails the operation, never starts an automatic replacement request, and converts a rejected fetch promise into the same `stopReason: "error"` message form so expected provider and authentication failures stay in-band. On a returned still-deferred message it requires the complete handle to equal the persisted handle: a provider cannot replace durable handle data without a write, so a mismatch is a defect. +A terminal fetch answer is final for the run: the harness appends the error message and fails the operation, never starts an automatic replacement request, and converts a rejected fetch promise into the same `stopReason: "error"` message form so expected provider and authentication failures stay in-band. A returned `aborted` response without `abort_requested` is not terminal until the copied interruption cap: it re-parks on the persisted source handle and a later `resume()` may poll once more. + +On a returned still-deferred message, **complete handle equality** means equal `provider`, `modelId`, `api`, and `id`, equal presence and value for `expiresAt` and `pollAfterMs`, and JSON-deep-equal `data` with equal presence. Object key order is irrelevant; array order is not. The harness persists and accounts the pending response first, then applies this check during ordinary classification. Absent an abort marker, a mismatch is a durable invalid prefix and a harness defect; handle rotation is not supported. Equal handles still produce distinct response entries, and the newest such entry becomes the next attempt's `sourceEntryId`. Deferred assistant messages carry a handle, not content. Session context projection omits them from provider context; durable suspension and redemption use the persisted handle. -Stop-reason normalization is the adapter's job, and the harness branches only on the normalized value. For OpenAI Responses: `incomplete_details.reason === "max_output_tokens"` maps to `stopReason: "length"`; `content_filter` maps to a non-retryable `stopReason: "error"`. Adapters may retain the provider's reason as `rawStopReason` for diagnostics; core logic never reads it. +Stop-reason normalization is the adapter's job, and the harness branches only on the normalized value. Provider adapters also guarantee that `toolCallId` is unique within one settled assistant response; core tool orchestration assumes that invariant and adds no duplicate-id handling. For OpenAI Responses: `incomplete_details.reason === "max_output_tokens"` maps to `stopReason: "length"`; `content_filter` maps to a non-retryable `stopReason: "error"`. Adapters may retain the provider's reason as `rawStopReason` for diagnostics; core logic never reads it. ## 17. Forks and subagents -One copy primitive on the session repository: +One copy primitive on the session repository. A fork first captures one immutable source snapshot containing the selected committed entries, latest visible facts, current lane pointers, and current total lane configurations at the same source prefix. Memory and JSONL capture it as one job on the source mutation queue; SQLite uses one read transaction. Writes ordered after that snapshot are absent from every copied category, so a fork cannot combine an old pointer with a newer configuration or fact. ```ts type ForkOptions = @@ -2769,13 +3888,13 @@ repo.fork(source, options & { id?, parentSessionId? }): Promise; repo.create({ id?, parentSessionId? }): Promise; ``` -- Entries only. JSONL copies them without `lane`, then writes the final lane pointers. No records, no queues: a fork starts idle, every lane question answers "no open operation". No records also means no ledger: a fork's token and cost statistics start at zero — cost belongs to the session that incurred it; entry usage snapshots still display. Its `messageCount` is initialized from all copied message entries. -- Lanes: `scope: "branch"` → the fork has only `main`, at the fork point. `scope: "tree"` → every lane name and leaf pointer is copied. No operation logs or queues are copied either way, so every forked lane is idle. -- Facts: `scope: "tree"` copies all; `scope: "branch"` copies the name always, labels only when their target entry was copied. +- Conversation entries are copied without their source lanes, including durable assistant responses that provider-context projection omits. No operation, queue, step, tool, classification, or usage records are copied. A copied compaction entry is self-contained: its `retainedTail` already excludes any exact overflow response that the source compaction superseded, so that omission survives without copying the link record. A fork point before that compaction has no copied overflow transition and uses the ordinary entry-local projection rules: `error`, `aborted`, and `deferred` assistant messages are omitted, while `stop` and general `length` messages project. The fork starts idle and its token and cost statistics start at zero — cost belongs to the session that incurred it; entry usage snapshots still display. Its `messageCount` is initialized from all copied message entries. +- Lanes: `scope: "branch"` → the fork has only `main`, at the fork point, with a fresh total `lane_config` equal to source `main`'s configuration in the captured snapshot. `scope: "tree"` → every lane name and snapshot leaf pointer is copied, and each receives a fresh total record equal to that source lane's configuration in the same snapshot. Each destination pointer and first config are established through atomic configured-lane creation. These are new records in the fork, not copied record history, and configuration is never derived from the copied anchor entry. No other lane records are copied, so every forked lane is idle. +- Facts: `scope: "tree"` copies the current name, every current label, and every current custom application fact. `scope: "branch"` copies the current name and all current custom facts, but only labels whose target entry was copied. Deleted names, labels, and custom facts are absent; JSON-null custom facts remain values. The destination writes fresh fact history with no source fact ids because facts have no ids. - The fork point may be any message entry. A copy whose tip sits mid-tool-batch is still promptable: pi-ai's transformMessages inserts synthetic empty results for orphaned tool calls at request build time. -- The source is untouched; copying while it runs reads the committed prefix. +- The source is untouched; copying while it runs reads only the committed prefix captured by the coherent snapshot. An open operation's already-committed entries may be copied, but its records and promised future entries are not. - Linkage is `parentSessionId`, set by `fork()` and settable on `create()` — the basis for subagent parent/child tracking and export bundles. -- A subagent tool derives its child session id deterministically from its invocation (`f(parentSessionId, toolCallId)`): a safe replay reattaches to the same child instead of spawning a twin, and the child stays discoverable from the parent even when a crash swallowed the tool result. +- **Non-normative application example.** `AgentHarness` has no built-in subagent tool. An application implementing one may derive a child session id from its parent session id plus the provider `toolCallId`: a safe replay can then reopen the same child instead of spawning a twin. No core record, reducer rule, tool API, or invariant depends on this convention. - Policy, restated from Part I: a platform thread that shares history with its channel is a lane; a fork is for isolation — subagents, exports, clones. A subagent can also run on a lane of its parent's session when isolation is not wanted. ## 18. Telemetry @@ -2993,16 +4112,18 @@ The three operation spans share `pi.session.id` (string, required, high cardinal | `pi.harness.navigation` | root or application span | common operation attributes plus `pi.operation.kind`: `navigation` | `pi.operation.outcome`: `completed`, `declined`, `aborted`, `failed` | outcome `failed` | | `pi.harness.checkpoint` | `pi.harness.run` | `pi.lane.name`!, `pi.operation.id`!, `pi.checkpoint.kind`!: `normal`, `failure_drain`, `abort_reconcile` | none | only throw/reject | | `pi.harness.turn` | `pi.harness.run` | `pi.lane.name`!, `pi.operation.id`!, `pi.turn.id`! string, high cardinality | none | only throw/reject | -| `pi.harness.step` | `pi.harness.turn`, `pi.harness.checkpoint`, `pi.harness.compaction`, or `pi.harness.navigation` | `pi.lane.name`!, `pi.operation.id`!, `pi.step.kind`!: `assistant`, `compaction`, `branch_summary`; `pi.step.attempt`! number; `pi.compaction.reason`?: `manual`, `threshold`, `overflow` | `pi.step.outcome`: `succeeded`, `retry`, `failed`, `aborted`, `deferred`, `overflow` | outcome `retry` or `failed` | +| `pi.harness.step` | `pi.harness.run`, `pi.harness.turn`, `pi.harness.checkpoint`, `pi.harness.compaction`, or `pi.harness.navigation` | `pi.lane.name`!, `pi.operation.id`!, `pi.step.id`! string high-cardinality, `pi.step.kind`!: `assistant`, `deferred_fetch`, `compaction`, `branch_summary`; `pi.step.attempt`! number; `pi.compaction.reason`?: `manual`, `threshold`, `overflow` | `pi.step.outcome`: `succeeded`, `retry`, `failed`, `aborted`, `deferred`, `overflow` | outcome `retry` or `failed` | | `pi.harness.tool` | `pi.harness.turn` for live work or `pi.harness.run` for reconciliation | `pi.lane.name`!, `pi.operation.id`!, `pi.turn.id`? string high-cardinality, `pi.tool.name`! string, `pi.tool.call_id`! string high-cardinality, `pi.tool.replay`!: `never`, `safe`; `pi.tool.recovery`! boolean | `pi.tool.is_error` boolean for the raw phase-2 execution result | `pi.tool.is_error: true` | | `pi.harness.hook` | root or the current harness/AI scope | `pi.lane.name`!, `pi.operation.id`? string high-cardinality, `pi.hook.name`! string with values from `HookName`, `pi.hook.registration_id`? string | `pi.hook.outcome`: `completed`, `skipped`, `blocked`, `failed` | handler throw, including fail-closed `before_tool` | -| `pi.harness.sleep` | `pi.harness.step` or `pi.harness.run` | `pi.operation.id`!, `pi.sleep.delay_ms`! number | `pi.sleep.outcome`: `elapsed`, `aborted` | only throw/reject | +| `pi.harness.sleep` | `pi.harness.run`, `pi.harness.compaction`, or `pi.harness.navigation` | `pi.operation.id`!, `pi.sleep.delay_ms`! number | `pi.sleep.outcome`: `elapsed`, `aborted` | only throw/reject | | `pi.harness.event_handler` | root or the scope emitting the event | `pi.event.type`! low-cardinality string with the section 10 event discriminants, `pi.lane.name`? string high-cardinality | none | listener throw; the event system catches it after the span rejects | -| `pi.session.write` | root or the current harness scope | `pi.lane.name`!, `pi.operation.id`? string high-cardinality, `pi.session.mutation`!: `entry`, `record`, `lane`, `fact`; `pi.session.item_type`? string | `pi.session.seq` number when the committed API exposes it | storage rejection | +| `pi.session.write` | root or the current harness scope | `pi.lane.name`!, `pi.operation.id`? string high-cardinality, `pi.session.mutation`!: `entry`, `record`, `lane`, `fact`, `multi`; `pi.session.item_type`? string | `pi.session.seq` number for a single mutation or the first sequence in a multi-write append | storage rejection | + +The parent column maps directly to `TelemetryParentDefinition`: “root or application span” is `root_or_external`; “root or the current scope” and “root or any caller span” are `any`; every finite pi span list uses `spans` with exactly those names. `pi.harness.tool` wraps one phase-two `fx.executeTool` only and settles before `after_tool` finalization: `pi.tool.is_error` describes the raw execution result and there is no final `terminate` attribute. A batch plan is only a `pi.session.write` for its record. Blocked, invalid, genuine-length, aborted-before-start, and interrupted-without-replay results execute no tool and emit no tool span; every live execution or safe replay emits its own span. Live execution supplies the active turn id and parents the span to `pi.harness.turn`; reconciliation has no durable turn id, omits it, and parents the span directly to the resumed `pi.harness.run` invocation. The `pi.hook.name` values array is exactly `before_run`, `before_resume`, `before_run_end`, `transform_context`, `before_request`, `before_payload`, `after_response`, `before_tool`, `after_tool`, `before_compaction`, and `before_navigation`. The `pi.event.type` values array contains every `type` discriminant in the section 10 catalog and no others. `pi.harness.hook` describes one registered handler invocation, so isolated handler failures have their own status without failing the enclosing run. `pi.harness.event_handler` does the same for passive listener failures. The harness schema declares no span events initially. -The parent column maps directly to `TelemetryParentDefinition`: “root or application span” is `root_or_external`; “root or the current scope” and “root or any caller span” are `any`; every finite pi span list uses `spans` with exactly those names. `pi.harness.tool` wraps phase 2 (`executeTool`) only and settles before `after_tool` finalization: `pi.tool.is_error` describes the raw execution result, there is no final `terminate` attribute, and blocked or invalid calls that never execute emit no tool span. Live execution supplies the active turn id and parents the span to `pi.harness.turn`; reconciliation has no durable turn id, omits it, and parents the span directly to the resumed `pi.harness.run` invocation. The `pi.hook.name` values array is exactly `before_run`, `before_resume`, `before_run_end`, `transform_context`, `before_request`, `before_payload`, `after_response`, `before_tool`, `after_tool`, `before_compaction`, and `before_navigation`. The `pi.event.type` values array contains every `type` discriminant in the section 10 catalog and no others. `pi.harness.hook` describes one registered handler invocation, so isolated handler failures have their own status without failing the enclosing run. `pi.harness.event_handler` does the same for passive listener failures. The harness schema declares no span events initially. +One `pi.session.write` span covers one `Session.append()` call. Single-mutation appends use their logical kind; a non-empty array uses `pi.session.mutation: "multi"` and omits `pi.session.item_type` when its items differ. Dynamic identifiers and names are attributes, never span names. One `pi.harness.step` span covers one in-process provider attempt, while required `pi.step.id` correlates every attempt of the same durable `step_started`; `deferred_fetch` attempts parent directly to the resumed run. A hook-sourced structural `step_started` has no provider attempt and therefore emits no step or AI-request span: its hook invocation and durable writes keep their ordinary spans. Generated structural streams emit step and AI-request spans but no public assistant-message events. Writing `branch_summary_prepared` is a `pi.session.write`; appending it after a committed move requires no provider span. The schema definitions are the exhaustive vocabulary pi instrumentation may emit. -Dynamic identifiers and names are attributes, never span names. The schema definitions are the exhaustive vocabulary pi instrumentation may emit. +When an earlier abort marker interrupts an active assistant/fetch attempt, that attempt span may end with outcome `aborted`; its conditional response append and usage remain ordinary session-write spans. Without the marker, a provider response whose stop reason is `aborted` gives the assistant step span outcome `retry` below the captured cap or `failed` at the cap, and never gives the operation span outcome `aborted`. Deferred-fetch interruption attempts use the same step outcomes but emit no public retry lifecycle events. Recovery that synthesizes a missing aborted response emits write spans but no AI-request span because it performs no provider effect. Abort between steps, during backoff, during tool-only reconciliation, or while suspended creates no assistant step span merely for termination. The operation span ends with outcome `aborted`, and best-effort deferred cancellation emits its ordinary `pi.ai.request` span only when attempted. The agent package exports both schemas, `AGENT_TELEMETRY_SCHEMAS`, each span-name union, per-name start/end/combined attribute types, event types, discriminated span unions, and typed `startAiSpan()` / `startHarnessSpan()` helpers. The telemetry package exports `createTypedSpanStarter()` and `TypedSpanStarter`; callers can bind the agent tuple when one scope needs both AI-request and harness spans. Every typed starter or domain helper accepts only that span's start attributes; its callback receives a schema-scoped view of the live span whose `setAttributes()` accepts only that span's optional end attributes and whose `addEvent()` accepts only declared event names and attributes. Individual calls reject missing required attributes, duplicate composed span names, unknown attributes, type mismatches, and invalid closed-set values at compile time. TypeScript does not try to prove that any end setter ran; `startSpan()` always owns automatic settlement. The scoped view erases to the generic `TelemetrySpan`; production performs no schema validation. @@ -3010,13 +4131,13 @@ The schema objects are also the documentation source. `packages/agent/scripts/ge ### Effects and nesting -Telemetry wrappers follow ownership of ordinary work. The procedure layer wraps orchestration scopes — operation invocation, checkpoint, turn, and retryable step — and passes each callback's `TelemetrySpan` as the parent parameter to work below it. `Effects` wraps the atomic effect it owns. Telemetry is not part of the gated action vocabulary and creates no durable crash boundary. +Telemetry wrappers follow ownership of ordinary work. The procedure layer wraps orchestration scopes — operation invocation, checkpoint, turn, and each in-process attempt of a durable step — and passes each callback's `TelemetrySpan` as the parent parameter to work below it. `Effects` wraps the atomic effect it owns. Telemetry is not part of the gated action vocabulary and creates no durable crash boundary. ```ts async function assistantAttempt( turnContext: TelemetryContext, - attempt: number, - resultEntryId: string, + step: Extract, + record: Extract, ): Promise { return startHarnessSpan( turnContext, @@ -3024,20 +4145,21 @@ async function assistantAttempt( { "pi.lane.name": state.lane, "pi.operation.id": op.id, + "pi.step.id": step.id, "pi.step.kind": "assistant", - "pi.step.attempt": attempt, + "pi.step.attempt": record.attempt, }, async (stepContext) => { + const started = await fx.startAttempt(record, stepContext); + if (started === "aborted") throw new Aborted(); + const final = await fx.streamAssistant( + assistantRequest(step, started), stepContext, + ); // message_end has fired + const response = await fx.settleAttemptResponse(started, final, stepContext); await fx.appendRecord( - stepAttempt(op.id, "assistant", attempt, resultEntryId), - stepContext, + preplannedUsageRecord(started, response.message), stepContext, ); - const final = await fx.streamAssistant(assistantRequest(state), stepContext); - await fx.appendRecord( - usageRecord("assistant", op.id, resultEntryId, attempt, final), - stepContext, - ); - return final; + return response.message; // pure classification follows outside this request span }, ); } @@ -3049,7 +4171,7 @@ Section 14's `streamAssistant()` is the logical model-request wrapper. It starts |---|---| | operation dispatcher | `pi.harness.run`, `pi.harness.compaction`, or `pi.harness.navigation` | | checkpoint / turn / step procedure scopes | corresponding `pi.harness.*` scope span | -| `appendEntry`, `appendRecord`, `moveLane`, `setFact`, and a conditional commit that writes | `pi.session.write`; a conditional no-write result emits no write span | +| `appendEntry`, `appendRecord`, `startStep`, `prepareBranchSummary`, `moveLane`, `setFact`, and a conditional commit that appends | one `pi.session.write` per underlying `Session.append()` call; a conditional no-append result emits no write span | | `streamAssistant`, `fetchDeferred`, `cancelDeferred` | `pi.ai.request` with the matching `pi.ai.operation` | | `executeTool` | `pi.harness.tool` | | `runHook` | one `pi.harness.hook` per registered handler | @@ -3060,7 +4182,7 @@ A context object and adapter-native span are process-local capabilities. Neither ### Span lifetime -One operation span wraps one admitted in-process invocation of operation work. An initial `prompt()` / `compact()` / `navigateTree()` starts its span only after its `operation_started` acceptance commit; an admission `Err` such as `LaneBusy`, `InvalidMessage`, `NothingToCompact`, or `UnknownTarget` emits no operation span. A `resume()` starts its wrapper only after lane reservation, identity checks, and the other expected rejection checks pass. Each successful resume admission gets another span with the same durable operation id and recovery `true`. Repeated deferred polling therefore produces repeated ordinary wrapper spans correlated by operation id — no extra public lifecycle concept or durable telemetry state. +One operation span wraps one admitted in-process invocation of operation work. An initial `prompt()` / `compact()` / `navigateTree()` starts its span only after its `operation_started` acceptance commit; an admission `Err` such as `LaneBusy`, `InvalidMessage`, `InvalidNavigation`, `NothingToCompact`, or `UnknownTarget` emits no operation span. A `resume()` starts its wrapper after lane reservation and expected checks that do not require procedure progress. Missing runtime identities are checked only when the resumed procedure reaches the effect that needs them, after any identity-free durable repairs; a resulting `MissingIdentities` resolves the wrapper without outcome enrichment or error status. Each resume invocation otherwise uses the same durable operation id and recovery `true`. Repeated deferred polling therefore produces repeated ordinary wrapper spans correlated by operation id — no extra public lifecycle concept or durable telemetry state. - a returned `completed`, `declined`, `aborted`, or `suspended` result resolves normally; instrumentation may enrich the span with the matching allowed outcome; - a returned `failed` result explicitly sets error status and still resolves normally as the public API requires; it may also enrich the span with outcome `failed`; @@ -3073,13 +4195,14 @@ The span tree follows execution scopes: ```text pi.harness.run +├─ pi.harness.step deferred_fetch, numbered poll attempt ├─ pi.harness.checkpoint -│ └─ pi.harness.step compaction, attempt +│ └─ pi.harness.step compaction, numbered attempt ├─ pi.harness.turn │ ├─ pi.harness.step assistant, attempt -│ │ ├─ pi.ai.request provider, model, stop reason -│ │ └─ pi.harness.sleep retry delay +│ │ └─ pi.ai.request provider, model, stop reason │ └─ pi.harness.tool tool name, call id, replay +├─ pi.harness.sleep retry delay between attempts ├─ pi.harness.hook ├─ pi.harness.event_handler └─ pi.session.write entry/record/lane/fact @@ -3088,7 +4211,7 @@ pi.harness.compaction manual operation pi.harness.navigation ``` -The procedure layer owns operation, checkpoint, turn, and step scopes. `Effects` owns session writes, phase-2 tool execution, hooks, and sleep. The request-dispatch wrapper around `Models` owns `pi.ai.request`; passive event delivery owns handler spans. Each owner receives its parent context explicitly. +The procedure layer owns operation, checkpoint, turn, and durable-step attempt scopes. `Effects` owns session writes, phase-2 tool execution, hooks, and sleep. The request-dispatch wrapper around `Models` owns `pi.ai.request`; passive event delivery owns handler spans. Each owner receives its parent context explicitly. ### Safety and testing @@ -3106,30 +4229,52 @@ Three tiers. Each tests a different claim; none replaces another. ### Tier A — reduction and resume -Prefill a session with the records and entries of one section 6 crash state through the public `Session` API (`appendRecord`, low-level `appendEntry`), open the harness, call `resume()`, assert the durable result. +Prefill a session with the records and entries of one section 6 crash state through low-level `Session.append()` calls, open the harness, call `resume()`, and assert the durable result. Keep separate procedure boundaries as separate calls; arrays are used only for contractually atomic states. ```ts -await session.appendRecord(opStarted("run", { originalPrompt, initialMessages: [userEntry] })); -await session.appendEntry(userEntry, "main"); -await session.appendRecord(stepAttempt("assistant", 1)); -await session.appendEntry(assistantWithToolCall, "main"); -await session.appendRecord(toolStarted({ replay: "safe", resultEntryId: "result-1" })); -// This durable prefix is X3. +await session.append({ kind: "record", record: laneConfig("main", Cseed) }); +await session.append({ + kind: "record", + record: opStarted("run", { originalPrompt, initialMessages: [userEntry] }), +}); +await session.append({ kind: "entry", lane: "main", entry: userEntry }); +await session.append({ kind: "record", record: stepStarted("assistant", { + id: "step-1", configuration: Cseed, retryPolicy, triggerMessageId: userEntry.id, +}) }); +await session.append({ kind: "record", record: stepAttempt("assistant", { + stepId: "step-1", attempt: 1, responseEntryId: "response-1", usageRecordId: "usage-1", + intendedOutputLimit: 4096, contextWindow: 128000, +}) }); +await session.append({ + kind: "entry", lane: "main", entry: { ...assistantWithToolCall, id: "response-1" }, +}); +await session.append({ kind: "record", record: assistantUsage({ + id: "usage-1", stepId: "step-1", attempt: 1, entryId: "response-1", +}) }); +await session.append({ kind: "record", record: toolBatchStarted({ + assistantEntryId: "response-1", calls: [{ toolIndex: 0, resultEntryId: "result-1" }], +}) }); +await session.append({ kind: "record", record: toolStarted({ + assistantEntryId: "response-1", toolIndex: 0, replay: "safe", +}) }); +// This durable prefix is X4: the planned, started call has no result. const { harness, suspended } = await AgentHarness.create(options); expect(suspended).toHaveLength(1); expect((await harness.resume()).ok).toBe(true); ``` -Coverage: every X1–X5 tool state, replay safe/never/changed declarations, every source-order position in a batch, truncated (`length`) batches proving no execution, abort before and after each durable point, the terminal-failure marker with and without later consumed input, missing initial messages, pending, cancelled, and abort-killed queue items, deferred writes, deferred handles (pending, ready, terminal, rejected fetch, mismatched handle, abort), unfinished steps resuming before new checkpoint input is consumed — including steering accepted during an interrupted retry — attempt caps across restart including auto-compaction exhaustion, every overflow crash site from the section 6 table, post-move navigation states from the section 6 table, section 5 validity rejections, and half-completed recovery (run the same prefix through recovery twice). +Coverage: bounded restore with one exact batched entry-plan lookup and no branch/configuration walk; independent next-run reduction for idle, run-open, compaction-open, and navigation-open lanes; invalid navigation intents that target their source or attach a label to the root; `step_started` with no attempt; assistant attempts with no response below and at the captured cap; response without usage; response and usage before classification; response plus each linked transition; distinct response ids across retries; every settled stop reason remaining durable; unmarked `aborted` assistant responses retrying below and failing at the captured cap without operation abort; one self-contained deferred-fetch step with exact copied configuration/policy, consecutive attempts, repeated equal-handle pending entries that advance exact source lineage, ready/terminal responses, unknown poll effects, complete-handle mismatch rejection, and unmarked interruptions re-parking on the unchanged source below and failing at the copied per-source cap; structural source discriminants; complete hook results and preplanned hook-usage repair without hook replay; generated stable result ids, unknown attempts, and `step_failed`; generated compaction with no prepared record; generated branch-summary usage then `branch_summary_prepared`; every valid and invalid navigation source/target/summary leaf-result state with no post-move generation; every X1–X7 tool state, including no plan, plan-only, started, usage-without-result, and result; replay safe/never/changed declarations; every source-order position in a batch; genuine output-limit `length` batches proving no execution and one planned explanatory result per call; abort before assistant-step start, after attempt intent, on both sides of response append and usage, during retry delay, before/after tool planning and every tool start/result position, while deferred, around each pending write, and before/after each structural commit; repeated abort; abort-only recovery with missing model/tool implementations; effect-specific missing identities that do not block durable repairs or synthetic settlements; the terminal-failure marker with and without later consumed input; missing initial messages; pending, cancelled, and abort-killed queue items; deferred writes; attempt caps across restart including auto-compaction exhaustion; every overflow crash site from the section 6 table, including exact response/trigger link validation and omission from preparation and retained tail; all navigation states from the section 6 table, custom-instruction delivery, exact prepared-payload append, and completion-winning label rewrites; bounded section 5 validity rejections; and every half-completed recovery prefix created after an individual repair write. Each such prefix is closed, reopened, resumed, and compared with uninterrupted recovery; merely invoking recovery twice from its initial prefix is insufficient. -The in-memory backend is the reference. The parity suite runs the same setups against memory, JSONL, and SQLite; one case runs concurrent writes on two lanes and asserts unique increasing `seq` and identical `getLog()` order; another asserts every backend rejects the same non-JSON payloads. +The in-memory backend is the reference. The parity suite runs the same setups against memory, JSONL, and SQLite. Query instrumentation proves each restored lane uses indexed open/latest-run/config reads, one run-id-bounded operation slice, and one `getEntries` call for its exact plan, with no branch scan or other-lane record read. Separate cases keep next-run input visible under an open structural operation, run concurrent writes on two lanes and assert unique increasing `seq` plus identical `getLog()` order, and assert every backend rejects the same non-JSON payloads. ### Tier B — writer conformance -Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), lane move (`L`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, queue cancellation, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, context overflow (discard, guard, hook-supplied), manual compaction, navigation (move-first), deferred suspension and every fetch outcome. This tier catches the critical regression class: an effect starting before its intent record. +Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), lane move (`L`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, queue cancellation, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, durable overflow response and guard, hook-supplied compaction, manual compaction, navigation (move-first), deferred suspension, repeated equal-handle pending polls, and every fetch outcome. Navigation admission cases assert `InvalidNavigation` with no hook or durable write for the current leaf and for a labeled root target. Every provider-settled assistant/fetch case asserts `step_started → step_attempt → provider effect with message_start/message_update* → after_response → message_end → response entry/entry_added → preplanned usage → classification`; `message_end` carries only the provisioned intended id and never proves the later append. A synthetic settlement performs no provider effect, emits no update, and runs no response hook; its order is `message_start → message_end → response entry/entry_added → preplanned usage → classification`. Deferred cases additionally assert one fetch with `wait: 0` per resume, exact source advancement or retention, no original-step lookup after F starts, ready tool selection from F's copied active names, and no retry lifecycle events. Every structural case asserts one stable typed result id, no public assistant-message lifecycle, and `step_failed` only for terminal generated failure. Hook cases assert complete output on `step_started`, exact preplanned usage before entry, `fromHook: true`, and no hook replay. Generated navigation asserts usage and `branch_summary_prepared` before move, exact post-move append with `fromHook: false`, and no provider request after move; generated compaction asserts no prepared record. Every tool case asserts response usage → complete batch plan → source-ordered clearance → `tool_started` immediately before each real `fx.executeTool` → source-ordered finalization → result `message_start/message_end` → tool usage when present → planned result/`entry_added`. Parallel cases prove that effects overlap while starts and dispatches retain source order, result commits form a source-order prefix, and blocked/invalid calls have planned results but no start or tool effect. This tier catches the critical regression classes: an effect starting before its intent record, a response omitted for one stop reason, classification starting before usage is durable, or a result id allocated after clearance began. + +Abort writer-conformance traces additionally assert that marker-before-response normalizes the existing attempt response, response-before-marker preserves its stop reason, a missing response is synthesized under that same id only on recovery, repeated abort emits/writes once, planned unstarted tools get results while started real/error results survive, pending writes precede `operation_finished`, and between-turn, backoff, deferred, and structural abort append no assistant closure. Separate no-marker traces prove that `aborted` assistant/fetch responses are accounted interruptions, emit no `run_abort`, retry under captured policy when allowed, and finish failed rather than aborted at the cap. `operation_finished` is the only universal terminal write. -Tier B also asserts the append-only-context invariant (section 4) executably: within a run, every faux-provider request's message list extends the previous request's as an exact prefix — except across a compaction entry, the one sanctioned invalidation. This turns the KV-cache discipline from prose into a failing test whenever a write path inserts before the tail. +Tier B also asserts provider-context projection and the append-only invariant (section 4) executably. Durable `error`, `aborted`, and `deferred` assistant responses never reach the provider; genuine output-limit `length` does, followed by its explanatory tool errors; and the exact response linked by overflow is absent from compaction preparation and retained tail. Within a run, every faux-provider request's message list otherwise extends the previous request's as an exact prefix, except across a compaction entry, the one sanctioned invalidation. This turns projection and KV-cache discipline into failing tests whenever a path includes a non-projecting response or inserts before the tail. ### Tier C — deterministic interleavings @@ -3141,23 +4286,27 @@ const promptResult = harness.prompt("calculate"); while ((await harness.peekAction())?.kind !== "execute_tool") await harness.executeAction(); -// X3: intent durable, effect not started +// X4: the batch plan and call intent are durable; the effect is still parked. +const plans = await session.findRecords({ lane: "main", type: "tool_batch_started" }); const started = await session.findRecords({ lane: "main", type: "tool_started" }); -expect(await session.getEntry(started[0]!.resultEntryId)).toBeUndefined(); +expect(started).toHaveLength(1); +expect(await session.getEntry(plans[0]!.calls[0]!.resultEntryId)).toBeUndefined(); expect((await harness.steer("focus on tests")).ok).toBe(true); // surface is ungated await harness.runToCompletion(); expect((await promptResult).ok).toBe(true); ``` -Crash simulation is `close()` at a chosen boundary, then reopening the same backend and resuming. Crash sites are derived mechanically, not hand-picked: drive each section 6 trace in manual mode, snapshot the backend after **every** `executeAction()`, then reopen every snapshot and `resume()` — and run recovery twice per snapshot, proving half-completed recovery is safe. New effects added to a trace get crash coverage automatically. Coverage: **both orders of every race-catalog row (section 15)**, input injected between arbitrary actions, abort while a cancellable effect is parked and while it runs, and automatic versus manual drive producing identical durable logs and outcomes for the same scripted provider. +Crash simulation is `close()` immediately before or after a chosen action, then reopening the same backend and resuming. Crash sites are derived mechanically, not hand-picked: drive each section 6 trace in manual mode, capture the backend before and after **every** `executeAction()` — atomic storage append, hook, provider/fetch, individual tool, or timer — and before and after every ungated lane-surface append, then reopen every boundary case and `resume()`. A multi-write append is one action and one crash boundary; no test or recovery prefix may expose one of its logical mutations without the others. For each reopened case, drive recovery through the same manual gate. Whenever recovery commits one entry, record, lane move, or fact, close immediately, reopen that new prefix, and continue; recovery effects also receive the ordinary before/after-action treatment. Every recovery write is therefore a crash boundary. Running recovery twice only after one whole recovery invocation is not a substitute. New effects or recovery writes added to a trace get crash coverage automatically. Coverage: **both orders of every race-catalog row (section 15)**, input injected between arbitrary actions, abort while a cancellable effect is parked and while it runs, and automatic versus manual drive producing identical durable logs and outcomes for the same scripted provider. Gate invariants, asserted across Tier C: -- After every `resume()` outcome, the recomputed reduction's `laneState` equals live `LaneState` (the section 15 fixed-point self-check fired and passed). +- After every released durable action and `resume()` outcome, the test performs the bounded reads and fresh pure reduction; its `laneState` equals live `LaneState`. Production performs no such reread. +- Both orders of abort versus assistant response append and structural commit are driven explicitly; repeated abort while parked does not change the next action or durable log. - `peekAction()` has no side effect and is stable until `executeAction()`. - `executeAction()` releases exactly the peeked action, never a later one. - Stopping before an action leaves exactly the preceding durable prefix. +- After each recovery write, close/reopen reduction skips that completed repair and selects the next ordinary action without duplicating an id, provider/tool effect, or persisted hook result. - While parked, zero storage writes and zero provider or tool calls happen (construction rule, section 15). - Every accepted operation gets exactly one `operation_finished` unless it suspends. - A faulted append leaves a valid prefix and faults the whole harness. @@ -3165,18 +4314,24 @@ Gate invariants, asserted across Tier C: ### Other suites - The telemetry reference adapter and every third-party adapter run the exported conformance cases for synchronous admission, result/rejection identity, automatic and explicit status, attribute merging, event order, post-settlement behavior, parentage, and unreadable-payload suppression. -- Runtime telemetry tests use the in-memory reference to assert exact schema-conforming span trees and independently valid start/end/event bags on every status path. End attributes remain optional. Content and secret fixtures assert absence, not merely redaction. +- Runtime telemetry tests use the in-memory reference to assert exact schema-conforming span trees and independently valid start/end/event bags on every status path. Assistant, generated compaction, generated branch-summary, and deferred-fetch attempts carry the stable durable `pi.step.id`, correct kind, and numbered attempt. Hook-sourced structural results emit hook/write spans but no step or AI-request span; a prepared branch-summary write and post-move append likewise emit no provider span. Unmarked `aborted` responses produce retry/failed step outcomes and never an aborted operation outcome; marker-backed settlement produces the abort outcome. End attributes remain optional. Content and secret fixtures assert absence, not merely redaction. - The existing `agent-loop` and `agent` suites pass unchanged — the section 14 compatibility criterion. -- Event ordering per section 10, including `message_end` after commit. -- Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`, durable summary `fromHook` provenance, and no harness interpretation of hook-owned summary details. -- Ledger completeness and the match invariant: every provider request leaves exactly one `usage` record per physical request (split-turn: two per attempt; a pending deferred fetch that reports no usage writes none); failed compaction series and discarded overflow responses lose no recorded cost; each usage-bearing entry's snapshot equals the newest non-adjustment record(s) bound to its id; a replayed tool records both executions; adjustments never alter entries and sum into read-time effective cost; `getStats()` token and cost fields equal the ledger sum and the `usage` event's totals after every commit; fork token and cost fields start at zero while `messageCount` includes all copied message entries; v3 conversion preserves totals through the aggregate import adjustment. -- Overflow classification against the reported provider shapes: prompt 268,009 of a 272,000 window and 81,217 of 84,500 (recoverable), non-zero reasoning-only output, cache-write-heavy usage, a Codex-style provider that rejects `max_output_tokens`, a genuine 1,024-token cap fully used (not recoverable), and `length → length` stopping after exactly one recovery per conversational input. -- v3 fixtures: labels and session info mid-chain and at end of file, old `firstKeptEntryId` compactions, and preserved `fromHook` provenance on compaction and branch-summary entries — all open as one normalized idle `main` lane. +- Session/storage lifecycle and fork conformance runs against Memory, JSONL, and SQLite: one-mutation and non-empty-array `append()` returns, lane-free entry `LogItem`s with positional input correlation, consecutive non-interleaved sequence assignment; all-or-none validation, projection, and event publication in logical order; expanded `getLog()` results; exact immutable batched entry lookup; configured-lane `[lane, config]` and labeled-navigation `[fact, finish]` appends with neither half observable; separate built-in/custom fact namespaces; name/label/custom deletion versus JSON null; close during idle and queued appends; JSONL ordinary-object and physical array lines, whole torn-array removal, and complete-invalid transaction rejection; SQLite renewal stop and owner/fence-matched release; one coherent fork snapshot while source writes race; current facts/pointers/configurations from that snapshot; no copied orchestration or usage records; and zero fork token/cost totals with copied entry display usage intact. +- Event ordering per section 10: direct and tool-result messages emit immediate start/end before append; streamed assistant/fetch responses run `after_response` before `message_end`; every successful message append then emits `entry_added`, and a fault after `message_end` but before append emits no such confirmation. Multi-write appends emit nothing before full success and then publish in logical order; labeled navigation emits `fact_update` before its operation-end event. Abort cases cover one `run_abort`, optional assistant message lifecycle only for an existing attempt, required reconciliation `entry_added` events, then `run_end` with matching paired optional final fields. Internal compaction and branch-summary streams emit no assistant-message events; only the committed typed entry emits `entry_added`. +- Deferred polling: repeated pending responses with the same complete handle append distinct accounted entries and advance source lineage; interrupted/unknown polls retain the exact source and obey the copied per-source cap; each resume performs zero or one `wait: 0` fetch; ready tool calls use copied active names; returned/rejected terminal errors persist and never start replacement generation; unmarked complete-handle mismatches fault after persistence; no poll emits retry lifecycle events; abort cancels the newest persisted handle best-effort. +- Total lane configuration: fresh-main initialization, atomic configured lane creation in every backend, immediate setters during running and aborting operations, whole-value replacement, immutable seed use for later lanes, generation-step snapshots across retries, no anchor/source-lane inheritance, model resolution in `getModel()`, environmental tool implementations, and fork records containing values from the same coherent source snapshot but no source history. +- Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`, navigation custom-instruction delivery, complete hook summary persistence on `step_started`, no decision-hook replay after that record, durable `fromHook` provenance, and no harness interpretation of hook-owned summary details. +- Ledger completeness and the match invariant: every assistant-generation and deferred-fetch attempt that settles appends its response entry and then exactly its preplanned `usage` record, including retryable errors, overflow, aborted, deferred, and zero-usage pending polls; a response-without-usage crash reconstructs the same id and payload before classification; split-turn structural work writes two usage records per attempt; failed structural series retain reported cost even when their typed result never appears; hook usage is repaired from the complete step payload before entry or abort completion; generated branch-summary usage precedes its prepared payload; assistant/fetch and structural entry snapshots match their producing request records; a real tool writes its finalized execution's reported usage before its planned result and that result displays only its own `AgentToolResult.usage`; usage-without-result recovery retains the ledger charge, a replay adds its own record without folding either execution into the result snapshot, and a synthetic result has no usage snapshot; adjustments never alter entries and sum into read-time effective cost; `getStats()` token and cost fields equal the ledger sum and the `usage` event's totals after every commit; fork token and cost fields start at zero while `messageCount` includes all copied message entries; v3 conversion preserves totals through the aggregate import adjustment. +- Abort settlement and projection: marker-first active assistant/fetch settlement under the existing response id with normalized `aborted`, synthetic zero-usage recovery under that id when absent, response-first stop-reason preservation, no later attempt or tool plan after the marker, no assistant entry between steps/backoff/deferred/tool-only/structural cancellation, stable repeated-abort payloads, planned tool result completion without abort-time replay, pending-write completion before the terminal record, missing-identity abort-only recovery, and structural commit-point ordering. Without a marker, durable `aborted` assistant/fetch responses retry under captured policy and fail rather than abort at the cap. All durable aborted responses remain omitted from provider context. +- Response classification and projection: explicit context-limit error strings with non-overflow exclusions; `stop` responses whose reported input plus cache-read tokens exceed captured windows (268,009 of 272,000 and 81,217 of 84,500); the existing Xiaomi zero-output/full-window signal; non-zero reasoning-only output; cache-write-heavy usage; a Codex-style provider that rejects `max_output_tokens`; a genuine 1,024-token cap fully used and retained in provider context; one explanatory error per genuine-length tool call with zero tool effects; omission of `error`, `aborted`, and `deferred`; exact overflow-response omission from preparation and retained tail; no tool plan for overflow; and `length → length` stopping after exactly one linked recovery per `triggerMessageId`. Fork tests copy a self-contained compaction tail without classification records and verify that a fork before the compaction uses ordinary entry-local projection. +- v3 fixtures: labels, session info, and legacy model/thinking/active-tool entries mid-chain and at end of file; old `firstKeptEntryId` compactions; and preserved `fromHook` provenance on compaction and branch-summary entries — all open as one normalized idle `main` lane, with legacy configuration absent and the harness options seed used on attachment. ## 20. Implementation status and work packages Work is limited to `packages/agent`, `packages/session-backends/sqlite-node`, `packages/telemetry`, and the telemetry request-option surface in `packages/ai`. Other package source is off limits. In particular, this plan does not migrate `packages/coding-agent`; I0's completed dependency wiring is the only exception. Coding-agent v3 compatibility means only that the new JSONL repository can read supported v3 sessions. +Checked package entries are historical: they describe the contract that actually landed, even where this document later replaced that contract. An unchecked convergence or runtime package owns every delta to the final design; a checked package is never retroactively expanded by a later section rewrite. + ### Claiming and completing a package 1. Sync with `main`. A package is claimable only when its checkbox is empty, every dependency is checked, and no active reservation owns the package or overlapping primary files. @@ -3205,7 +4360,7 @@ This table is exhaustive. A package does not remove `HarnessNotImplemented` from |---|---| | scaffold-safe `name`, `getLeafId`, record-free create, runtime settings | F0 | | `AgentHarness.create()` restore and `suspended` inventory | R3 | -| `lane`, `createLane`, `lanes`, lane facades, lane-bound session reads | H0 | +| `lane`, `createLane`, `lanes`, lane facades, lane-bound session reads and global facts | H0 | | resources, stream/retry/compaction settings, queue modes | F0 | | tool registry plus persisted active-tool selection | H4 | | `prompt`, `skill`, `promptFromTemplate` | H1 | @@ -3240,71 +4395,82 @@ Implementation packages derive their tests from this design and do not use the p ### Track R — recovery query, reducer, and restore -These packages merge R0 → R1 → R2 → R3. R1 and R2 add a reducer module instead of growing `agent-harness.ts`. R3 is the first package in this track that owns `agent-harness.ts` and therefore runs after F0. +R0 → R1 → R2 land first and add a reducer module instead of growing `agent-harness.ts`. D0 then converges that landed reducer and the landed JSONL implementation on the final durable contract. R3 is the first package in this track that owns `agent-harness.ts` and therefore runs after both F0 and D0. - [x] **R0 — recovery-query contract.** Dependencies: none. - Primary files: `packages/agent/src/harness/session/types.ts`, `session.ts`, `memory.ts`, SQLite record storage/repository files, backend conformance, and focused recovery-query tests. - - Add `RecordQuery.operationKind` and `findOpenOperations(lane, { limit })` exactly as specified in sections 7, 12, and 13. Memory maintains the projection, JSONL will derive it during replay, and SQLite answers it from the lane open-operation projection. - - Prove that zero/one open operations are distinguishable, that normal writes cannot start a second operation on a busy lane, and that the latest run-kind start is an indexed query. Add the lane open-operation projection. - - Acceptance: memory and SQLite have identical query behavior, invalid query combinations reject, and no restore algorithm needs a full historical scan. + - Landed `RecordQuery.operationKind` and `findOpenOperations(lane, { limit })` for the pre-convergence recovery contract. Memory maintains the projection, JSONL derives it during replay, and SQLite answers it from the lane open-operation projection. + - Proved that zero/one open operations are distinguishable, normal writes cannot start a second operation on a busy lane, and the latest run-kind start is an indexed query. Added the lane open-operation projection. + - Acceptance at landing: memory and SQLite had identical query behavior, invalid query combinations rejected, and restore no longer needed a full historical scan. - [x] **R1 — pure record-log validity.** Dependencies: R0. - Primary files: `packages/agent/src/harness/reducer.ts`, `packages/agent/test/harness/reducer.test.ts`. - - Validate the section 5 corruption rules from discovered open starts, bounded records, and point-looked-up entries, with no writes or effects. - - Acceptance: one focused rejection test per validity bullet, plus valid prefixes at every section 6 crash point. + - Landed pure validity for the pre-convergence section 5 record log from discovered open starts, bounded records, and point-looked-up entries, with no writes or effects. + - Acceptance at landing: focused rejection tests covered that contract's validity bullets and valid prefixes from its section 6 crash catalog. - [x] **R2 — pure lane-state reduction.** Dependencies: R1. - Primary files: `packages/agent/src/harness/reducer.ts`, `packages/agent/test/harness/reducer.test.ts`. - - Implement the section 15 `LaneReductionInput` → `LaneReductionResult` contract. Derive pending queues/writes, attempts, tool batches, deferred handles, structural targets, and idle next-run state into `laneState`; derive effective configuration and terminal-failure provenance beside it from the same section 7 query inputs. - - Keep `LaneState` limited to orchestration state. Reduction exclusively owns all three outputs; later recovery packages consume `LaneReductionResult` and do not re-reduce tool or operation records. - - Acceptance: table-driven tests cover idle and every suspended state, configuration fallback/override, and terminal-failure provenance; reduction is deterministic and performs no writes. + - Landed the pre-convergence `LaneReductionInput` → `LaneReductionResult` contract. It derived pending queues/writes, attempts, tool batches, deferred handles, structural targets, and idle next-run state into `laneState`, plus effective configuration and terminal-failure provenance from the then-current recovery inputs. + - Kept `LaneState` limited to orchestration state. Reduction owned all three outputs so later recovery code did not re-reduce tool or operation records. + - Acceptance at landing: table-driven tests covered idle and suspended states, configuration fallback/override, and terminal-failure provenance; reduction was deterministic and performed no writes. -- [ ] **R3 — harness restore inventory.** Dependencies: F0, R2. +- [ ] **R3 — harness restore inventory.** Dependencies: F0, D0. - Primary files: `packages/agent/src/harness/agent-harness.ts`, reducer integration helpers, and restore tests. - - Wire `AgentHarness.create()` to use indexed open-operation discovery, bounded idle/open scans, explicit provisioned-id point lookups, and bounded configuration lookups. Return accurate `SuspendedOperation[]` without starting effects. - - Acceptance: idle and multi-lane restore write nothing, multiple open operations reject as corruption, suspended metadata is complete, and one lane never scans another lane's traffic. `resume()` may still reject as unimplemented. + - Wire `AgentHarness.create()` to use indexed configuration/open-operation/newest-run discovery, one exact run-id-bounded open slice, the independent next-run slice for every lane, complete entry-plan construction, and one batched `getEntries` call per lane. Return accurate `SuspendedOperation[]` without walking an operation branch, reading tree configuration, starting effects, or writing during restore. + - Acceptance: idle and multi-lane restore write nothing; multiple open operations reject; suspended metadata and effect-specific missing identities are complete; next-run survives under open compaction/navigation; query instrumentation proves one entry batch and no branch, completed-history, or other-lane scan. `resume()` may still reject as unimplemented. + +### Track D — durable-contract convergence + +R1/R2 and J1–J3 landed before the final durable record contract in this document was approved. D0 is the single deliberate convergence package: it updates those landed foundations before restore or runtime integration builds on them. It owns the reducer and JSONL primary files while active; R3 and J4 must not overlap it. + +- [ ] **D0 — converge the durable contract.** Dependencies: R2, J3. + - Primary files: `packages/agent/src/harness/session/types.ts`, `session.ts`, `memory.ts`, `packages/agent/src/harness/session/jsonl/**`, `packages/agent/src/harness/reducer.ts`, their focused tests, and only the SQLite storage/conformance files required for backend-neutral parity. + - Converge the landed session/storage foundation on sections 7, 12, and 13: replace individual low-level write methods with the overloaded atomic `append(SessionMutation | NonEmptySessionMutations)` contract and expanded `LogItem` returns; implement one-job Memory arrays, ordinary-object/physical-array JSONL lines with whole-tail truncation, and one-transaction SQLite arrays; indexed latest total `lane_config`; exact immutable `getEntries(ids)`; run-id-bounded open slices and independent newest-run/next-run reads; separate built-in and custom facts with name/label/custom deletion distinct from JSON null and no fact ids; `[lane create, initial config]` configured lanes; draining close and SQLite fenced lease release; coherent forks with fresh current configurations/facts, no orchestration or ledger records, and zero cost totals; and matching backend behavior. + - Replace the landed orchestration record shapes with the final sections 5–7 contract: operation starts without duplicated configuration and navigation intents forbid a label on the root target; stable `step_started`, numbered `step_attempt`, and structural `step_failed`; assistant/fetch response and usage ids provisioned before effects; exact overflow response/trigger links and request limits; complete `tool_batch_started` plans plus effect-only `tool_started`; authoritative marker-only abort; self-contained deferred-fetch configuration, policy, and source lineage; complete hook structural results; and generated `branch_summary_prepared` with no compaction equivalent. Update the record union, append validation, entry planning, bounded validity/reduction, JSONL format-4 codec/replay, usage-ledger projections, and backend parity together. + - Add no format-4 migration or compatibility decoder. Do not wire restore or operation execution; R3 and later packages own those semantics. No later package may temporarily accept both the old and final contracts. + - Acceptance: every final record and mutation variant round-trips through format-4 storage; single and non-empty-array appends return expanded ordered `LogItem`s, entry items contain no lane while remaining positionally correlated with their routing mutations, assign consecutive non-interleaved sequences, publish all-or-none, and expand through `getLog()` identically across backends; JSONL accepts ordinary-object and physical-array lines, discards a whole torn final array, and rejects complete-invalid or malformed interior transactions; every section 19 Tier A prefix reduces or rejects exactly as specified; exact configuration, query, fact, configured-lane, close, fork, and ledger conformance agrees; stale old durable fields are absent outside v3 decoder vocabulary; and the package leaves one final reducer/storage contract for R3, I3, J4, and H0. ### Track J — JSONL storage -**In progress and reserved: @davidbrai.** The work began before this plan was split into J0–J6. Before merge, the track owner must include or rebase onto R0's recovery-query contract and report which J packages are complete. Other agents must not pick a J package while this ownership marker remains. +**In progress and reserved: @davidbrai.** The work began before this plan was split into J0–J6. Because D0 edits the same JSONL files, it is part of this serial reservation and lands after J3 before J4. Other agents must not pick D0 or a J package while this ownership marker remains. -These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete `JsonlSessionRepo` export, and `packages/agent/test/harness/session/jsonl*.test.ts`. They merge J0 → J1 → J2 → J3 → J4 → J5 → J6 and may proceed in parallel with tracks L and I after R0. +These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete `JsonlSessionRepo` export, and `packages/agent/test/harness/session/jsonl*.test.ts`. Their serial order is J0 → J1 → J2 → J3 → D0 → J4 → J5 → J6; tracks L and non-overlapping I packages may proceed independently. - [x] **J0 — JSONL metadata and codec contracts.** Dependencies: R0. - Primary files: JSONL type/codec modules and focused codec tests; no public repository export yet. - Implement the `JsonlSessionMetadata`, create/list options, format-4 header, line discriminants, `modifiedAt`, metadata, and parent-id/legacy-parent-path rules from section 13. - Acceptance: type and codec round trips cover every header field and line kind; no filesystem lifecycle yet. - [x] **J1 — format-4 per-session storage.** Dependencies: J0. - - Implement one-session replay/write support for entries, records, lanes, facts, statistics, branch queries, operation-kind queries, and open-operation projection. - - Keep it internal; do not export a partially implemented repository. - - Acceptance: focused round-trip tests cover every mutation, shared `seq`, query bounds, immutable reads, and JSON validation. + - Landed one-session replay/write support for the pre-convergence entries, records, lanes, facts, statistics, branch queries, operation-kind queries, and open-operation projection. + - Kept it internal; did not export a partially implemented repository. + - Acceptance at landing: focused round-trip tests covered every then-current mutation, shared `seq`, query bounds, immutable reads, and JSON validation. - [x] **J2 — format-4 repository lifecycle and forks.** Dependencies: J1. - - Add create/open/list/delete, one writer queue per session, metadata ordering/filtering, branch/tree forks, and the concrete public `JsonlSessionRepo` export. - - Acceptance: the complete backend-neutral conformance suite passes against JSONL, including concurrent lane writes and forks. + - Landed create/open/list/delete, one writer queue per session, metadata ordering/filtering, the pre-convergence branch/tree forks, and the concrete public `JsonlSessionRepo` export. + - Acceptance at landing: the then-current backend-neutral conformance suite passed against JSONL, including concurrent lane writes and forks. - [x] **J3 — format-4 crash and corruption behavior.** Dependencies: J2. - Add torn-tail truncation, malformed-interior rejection, missing-reference rejection, and lifecycle/concurrency edge cases. - Acceptance: acknowledged writes survive reopen and malformed non-tail data is never silently repaired. -- [ ] **J4 — read-only v3 normalization.** Dependencies: J3. - - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, discarded-entry reparenting, old compactions, summary `fromHook` provenance, timestamps, parent mapping, and idle `main` at the final retained logical entry. +- [ ] **J4 — read-only v3 normalization.** Dependencies: D0. + - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, discarded legacy model/thinking/active-tool entries without using them as lane configuration, discarded-entry reparenting, old compactions, summary `fromHook` provenance, timestamps, parent mapping, and idle unconfigured `main` at the final retained logical entry. - A read-only open must not modify the physical file. No coding-agent source or test is changed. - Acceptance: fixture tests cover every normalization rule in section 12, including `fromHook` true and false plus absent v3 values normalizing to false, and malformed v3 input. - [ ] **J5 — first-write v3 conversion.** Dependencies: J4. - - Rewrite through a temporary format-4 file on the first mutation, preserve metadata/facts/tree and resolved or legacy parent linkage, and add the aggregate v3 usage adjustment. - - Acceptance: crash-safe conversion tests cover failure before rename, successful reopen, statistics preservation, unresolved legacy parent paths, and no second conversion. + - Rewrite through a temporary format-4 file on the first mutation, preserve metadata/facts/tree and resolved or legacy parent linkage, add the aggregate v3 usage adjustment, and persist a harness-initializing `lane_config` as an ordinary new record without reviving discarded legacy configuration. + - Acceptance: crash-safe conversion tests cover failure before rename, successful reopen with the immutable options seed, statistics preservation, unresolved legacy parent paths, and no second conversion. - [ ] **J6 — schema-based durable payload validation.** Dependencies: J5. - - Define shared TypeBox schemas for format-4 JSON and derive session types from them, including runtime schema registration for application-defined `AgentMessage` variants. - - Acceptance: malformed durable payloads are rejected consistently and JSONL decoding uses the shared schemas. + - Define shared TypeBox schemas for format-4 JSON and derive session types from them, including the entry/record/fact/lane `SessionMutation` union, non-empty JSONL mutation arrays, expanded lane-free entry `LogItem`s, navigation intents that forbid labels on the root target, discriminated stable-step/attempt/failure records, generated versus complete hook-result structural sources with preplanned hook usage, dedicated generated branch-summary prepared records and no prepared compaction variant, copied deferred-fetch retry policy, required exact-response/trigger fields only on overflow compaction starts, assistant/fetch request fields and preplanned ids, complete source-indexed tool-batch plans, effect-only tool starts, separate built-in/custom fact variants whose custom `value` omission is distinct from JSON null, plus runtime schema registration for application-defined `AgentMessage` variants. + - Acceptance: malformed durable payloads, invalid step/attempt variants, and incomplete or mismatched tool plans/starts are rejected consistently and JSONL decoding uses the shared schemas. ### Track I — primitives -I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins after R2 fixes the `LaneState` shape. These packages use separate modules with focused unit tests; I5 remains primitive-only and does not edit `agent-harness.ts`. +I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins after D0 fixes the final `LaneState` shape. These packages use separate modules with focused unit tests; I5 remains primitive-only and does not edit `agent-harness.ts`. - [x] **I0 — telemetry contracts, typed schemas, and no-op context.** Dependencies: none. - Primary files: `packages/telemetry/src/index.ts`, `packages/telemetry/src/memory.ts`, `packages/telemetry/src/testing/`, and focused tests; pi-ai request-option types/propagation and focused tests; `packages/agent/src/harness/telemetry.ts`, `packages/agent/src/index.ts`, focused tests, package scripts, `packages/agent/scripts/generate-telemetry-docs.ts`, and generated `packages/agent/docs/telemetry-schema.md`. Do not edit `agent-harness.ts`; its canonical context type is landed, while H0 owns option renaming/defaulting/storage and execution threading after convergence. - In telemetry, implement the one canonical section 18 callback-based `TelemetryContext` / `TelemetrySpan` contract, shared no-op context, deterministic in-memory reference adapter, runner-independent adapter conformance cases, serializable `defineTelemetrySchema()` machinery, and `createTypedSpanStarter(context, schemas)` composition with child-bound starters. - In pi-ai, add optional `telemetryContext` to `ProviderRequestOptions` so every stream, deferred, and image option inherits it; provider, `Models`, `ImagesModels`, direct dispatch, and simple-option conversion preserve it. Pi-ai owns no domain schema or helper. - - In agent, define the complete normative `AI_TELEMETRY_SCHEMA` and `HARNESS_TELEMETRY_SCHEMA`, their inferred types, the readonly `AGENT_TELEMETRY_SCHEMAS` composition tuple, and typed `startAiSpan()` / `startHarnessSpan()` helpers. Export both schemas, the tuple, and helpers, and re-export the generic telemetry surface from the agent package root. Do not duplicate the generic contract and do not adopt OTel or another external semantic convention. + - In agent, define the landed `AI_TELEMETRY_SCHEMA` and `HARNESS_TELEMETRY_SCHEMA`, their inferred types, the readonly `AGENT_TELEMETRY_SCHEMAS` composition tuple, and typed `startAiSpan()` / `startHarnessSpan()` helpers. Export both schemas, the tuple, and helpers, and re-export the generic telemetry surface from the agent package root. Do not duplicate the generic contract and do not adopt OTel or another external semantic convention. - Generate the combined repository-only Markdown reference from the runtime schema values with the named agent package scripts. Production helpers perform no runtime schema validation; schemas compile-time-check each pi-written start/end/event call and remain importable as machine-readable data. - Wire telemetry before pi-ai in workspace, local-release, publish, profiling, and coding-agent binary build order; add source-test aliases and refresh workspace/generated dependency locks. - Landed coverage: focused tests exercise no-op synchronous admission, returned-value and sync/async rejection preservation, explicit no-op child propagation, one shared frozen inert span with no payload inspection, exact start/optional-end inference, multi-schema vocabulary composition, child-starter parent propagation, rejection of duplicate span names and missing, unknown, empty-schema, and invalid closed-set attributes, absence of declared span events, schema JSON serialization, the in-memory reference against every exported adapter conformance case, option propagation across provider/`Models` stream and deferred dispatch, direct and `ImagesModels` image dispatch, built-in simple-option conversion, and generated-document freshness. O2 will use the reference adapter to test pi's runtime status and nesting behavior with captured spans. @@ -3319,14 +4485,14 @@ I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins - Primary files: `packages/agent/src/harness/events.ts`, `packages/agent/test/harness/events.test.ts`. - Implement passive listener isolation and the snapshot/start/unsubscribe buffer primitive used by lane and session watchers. - Acceptance: no snapshot/event gap, ordered one-time flush, independent watchers, and `handler_error` recursion safety; no operation wiring yet. -- [ ] **I3 — lane mutation line.** Dependencies: R2. +- [ ] **I3 — lane mutation line.** Dependencies: D0. - Primary files: `packages/agent/src/harness/lane-runtime.ts`, focused mutation-line tests. - - Implement the per-lane FIFO and state-update discipline with test-only jobs for every conditional history in section 15. - - Acceptance: jobs never interleave, rejected jobs do not poison the queue, and no external effect runs inside a job. + - Implement the per-lane FIFO and state-update discipline with test-only jobs for every conditional history in section 15, including immediate total configuration replacement versus generation-step start. + - Acceptance: jobs never interleave, rejected jobs do not poison the queue, configuration snapshots see exactly one whole replacement, and no external effect runs inside a job. - [ ] **I4 — automatic `Effects` implementation.** Dependencies: I0, I1, I3, L3. - Primary files: `packages/agent/src/harness/effects.ts`, focused effects tests. - - Implement durable writes, conditional commits, provider/tool/hook adapters, sleep, fault propagation, and live-state updates behind the complete `Effects` interface. - - Acceptance: every external effect and durable write crosses `Effects`, and a failed write faults the whole harness. + - Implement semantic durable effects over `Session.append()`, atomic abort-aware `startStep` configuration/policy capture or complete hook-result persistence, abort-aware attempt/tool intents and structural `step_failed`, assistant/fetch response settlement, generated branch-summary preparation, structural commit races, conditional finishes including `[label fact, operation_finished]`, provider/tool/hook adapters, sleep, fault propagation, and ordered live-state/event updates after append success. + - Acceptance: every external effect and storage append crosses `Effects`, semantic methods recover typed entries/records from `LogItem`, multi-write finish is one gate/crash/telemetry boundary, `step_started` snapshots exactly one total configuration at its commit, and a failed append faults the whole harness without partial publication. - [ ] **I5 — manual gate primitive.** Dependencies: I4. - Primary files: `packages/agent/src/harness/gated-effects.ts`, focused gate tests. - Implement `GatedEffects` action descriptions, stable peek, exactly-one release, reentrant nested actions, run-through, and parked rejection without wiring public lane controls yet. @@ -3342,83 +4508,85 @@ These packages all own `packages/agent/src/agent-loop.ts` and therefore merge st - Add `streamAssistant()` and `StreamAssistantConfig`, including explicit telemetry context; route the compatibility loop's request path through it without changing events or results. - Acceptance: focused stream tests cover settled-result narrowing (a final `pending` value is a defect), plus unchanged existing loop tests. - [ ] **L2 — extract tool-call phases.** Dependencies: L1. - - Add `prepareToolCall()`, `executeToolCall()`, `finalizeToolCall()`, result helpers, replay declaration, explicit telemetry contexts, and durability callbacks without changing batch behavior. - - Acceptance: phase tests cover validation, blocking, abort, callback failure, updates, and patches. + - Add `prepareToolCall()`, `executeToolCall()`, `finalizeToolCall()`, result helpers, replay declaration, explicit telemetry contexts, and durability callbacks that retain the original source call object without changing batch behavior. + - Acceptance: phase tests cover validation, blocking, abort, callback failure, updates, patches, and independent phase-two invocation. - [ ] **L3 — compose tool batches and compatibility wrappers.** Dependencies: L2. - - Add `executeToolBatch()` with sequential/parallel source ordering, truncation, abort, and `terminate` rules; make every legacy loop export a thin composition using the no-op context. - - Acceptance: source-order and parallelism tests plus unchanged `agent-loop` and `agent` suites. + - Add `executeToolBatch()` with sequential source preparation, optional injected phase-two dispatch per call, source-ordered parallel dispatch and finalization, genuine output-limit `length` producing one explanatory error per call with no clearance or execution, abort, and `terminate` rules. Make every legacy loop export a thin composition using the no-op context and default direct phase-two dispatch. The harness does not call the batch for an overflow-classified response. + - Acceptance: concurrent settlement with source-ordered starts/results, one injected phase-two call per real invocation, blocked/invalid/length calls with no phase-two call, and unchanged `agent-loop` and `agent` suites. ### Track H — harness integration and run execution H0 converges restore and primitives into `agent-harness.ts`. H0–H8 then merge strictly in order. Each package adds its Tier A recovery cases, Tier B exact trace, relevant events/hooks, and Tier C interleavings rather than deferring testing to the end. - [ ] **H0 — lane facades and primitive integration.** Dependencies: R3, I2, I5. - - Wire durable lane lookup/creation/inventory, equivalent name-bound facades, canonical hook/event/telemetry types, rename `AgentHarnessOptions.context` to `telemetryContext` with the no-op default and stored root context, public manual-drive controls, and ownership/close plumbing. - - Acceptance: repeated facades are equivalent, lanes remain isolated, public drive controls match gate actions, and no placeholder operation is accidentally enabled. + - Capture the immutable total lane seed, initialize a fresh or normalized-v3 `main`, and wire durable lane lookup plus `[lane create, initial config]` creation/inventory, equivalent name-bound facades, canonical hook/event/telemetry types, separate built-in/custom global-fact APIs and events over `Session.append()`, rename `AgentHarnessOptions.context` to `telemetryContext` with the no-op default and stored root context, public manual-drive controls, and Harness/Session/Storage ownership and close plumbing. Existing configured lanes read only their latest `lane_config`; anchors and other lanes never initialize them. + - Acceptance: repeated facades are equivalent, lanes remain isolated and start with the captured seed, configured creation exposes neither half alone, custom-fact deletion differs from JSON null, public drive controls match gate actions, a closed session releases only its own writer claim after admitted writes drain, and no placeholder operation is accidentally enabled. - [ ] **H1 — one successful no-tool run.** Dependencies: H0, L3, I1. - - Implement `prompt`, skill/template expansion, run acceptance, capture of already-pending next-run items, initial appends, one assistant step, usage record, message commit, conditional finish, result, and basic run/turn/message events/hooks. + - Implement `prompt`, skill/template expansion, run acceptance, capture of already-pending next-run items, initial appends, one assistant `step_started` with stable id/configuration/policy/trigger, one `step_attempt` with preplanned response and usage ids plus request limits, `after_response` transformation, `message_end` before persistence, complete response entry plus `entry_added`, usage commit, conditional finish, result, and basic run/turn/message events/hooks. - H3 later owns public next-run enqueue/cancel/race behavior; H1 owns capture into `operation_started.initialMessages`. - - Acceptance: automatic/manual durable logs are identical; closing after every released action restores the expected suspended prefix. + - Acceptance: the exact order is step start, attempt intent, provider effect, response entry, usage, then finish; automatic/manual durable logs are identical; closing after every released action restores the expected suspended prefix, including response without usage. - [ ] **H2 — retry, run resume, and terminal failure.** Dependencies: H1. - - Add durable attempt counts, retry policy/backoff/events, unfinished-assistant resume, give-up error entries, terminal-failure drain, and fixed-point checks for these states. - - Acceptance: retry caps survive reopen; failed attempts record usage but no message; half-completed recovery is idempotent. + - Add numbered attempts under one stable assistant step, captured retry policy/backoff/events, durable responses for retryable and terminal errors, response-without-usage repair, post-usage pure classification with linked-transition detection, unknown-effect resume under fresh ids, synthetic interruption under the already-provisioned id at the cap, unmarked `aborted` responses as retryable provider interruptions rather than operation abort, terminal-failure drain, and test-only live/reducer fixed-point assertions for these states. Provider context omits durable error and aborted responses. + - Acceptance: retry caps survive reopen; unmarked `aborted` retries below the cap and fails at the cap without `run_abort` or outcome `aborted`; every settled attempt has one message entry and its exact preplanned usage record; no response or terminal id is invented after an attempt; half-completed recovery is idempotent. - [ ] **H3 — queues and checkpoints.** Dependencies: H2. - - Add next-run/steer/follow-up acceptance and modes, cancellation, checkpoint consumption, queue events, and finish-boundary conditionals. Consume the queue state produced exclusively by R2. + - Add next-run/steer/follow-up acceptance and modes, cancellation, checkpoint consumption, queue events, and finish-boundary conditionals. Keep `QueueResult`/`NoActiveRun` on steer and follow-up only; `NextRunResult` works idle or during any operation and its acceptance starts no run. Consume the final queue state produced by D0's converged reducer. - Acceptance: both orders of race rows 2, 5, 7, and 12; provider context grows only at the tail. -- [ ] **H4 — deferred writes, persisted configuration, and adjustments.** Dependencies: H3. - - Add deferred lane-view tree/configuration writes, direct idle writes, model/thinking/active-tool persistence and lookup, `recordUsage`, pending-write snapshots/events, and finish conditionals. - - Acceptance: both orders of race rows 3 and 9; accepted writes survive crashes and abort markers; adjustments affect ledger totals but never entries. +- [ ] **H4 — deferred tree writes, total lane configuration, and adjustments.** Dependencies: H3. + - Add deferred lane-view tree writes, direct idle tree writes, immediate total `lane_config` setters/getters for model/thinking/active-tool names, `recordUsage`, pending-write snapshots/events, and finish conditionals. Direct and applied message writes emit message start/end before append and `entry_added` after commit; all other committed entries also emit `entry_added`. Keep `setTools()` limited to the environmental implementation registry and keep `getModel(): Promise`. + - Acceptance: both orders of race rows 3 and 9; accepted tree writes and immediate configuration replacements survive crashes and abort markers; retries retain their generation-step snapshot; adjustments affect ledger totals but never entries. - [ ] **H5 — abort, wait, run-when-idle, and close.** Dependencies: H4. - - Add durable abort acceptance, queue draining, pending-write application, synthetic closure messages/results, suspended abort, idle waiters/callbacks, and process-local close settlement. - - Acceptance: both orders of race rows 4, 6, 8, and 10 and crash/reopen after every abort action. + - Add one authoritative, idempotent abort marker; stable queue draining; one signal/event; pending-write application; marker-backed active assistant settlement under its existing attempt id; synthetic missing-attempt settlement under that same id; preserve H2's unmarked `aborted` interruption classification; between-turn/backoff and suspended abort without an assistant closure; missing-identity abort-only recovery; optional aborted-run final message fields; idle waiters/callbacks; and process-local close settlement. Close stops admission, signals effects, rejects parked/local operation promises, drains admitted storage writes through `Session.close()`, and leaves durable operations open. Never start a provider request or allocate an assistant response id for termination. + - Acceptance: both orders of race rows 4, 6, 8, and 10; repeated abort returns the same payload without a write/event/signal; crash/reopen after every abort action; every marker-backed aborted run ends in `operation_finished` after required writes and may have no assistant response; close releases only the matching writer claim after queue drain; an unmarked `aborted` response never enters this path. - [ ] **H6 — live durable tool batches.** Dependencies: H5. - - Wire section 14 tool callbacks through `Effects`; write `tool_started` before execution, persist finalized results and `terminate`, report usage, and emit tool events. - - Acceptance: exact one-tool and parallel-batch traces; no blocked/invalid tool writes an intent; source-order finalization is stable. + - After response accounting and classification, commit one `tool_batch_started` with every source-index/result-id pair before clearance. Wire section 14 callbacks through `Effects`; write effect-only `tool_started` immediately before each individual `fx.executeTool`, emit each finalized tool-result message start/end before persistence, write reported usage before the planned result, emit `entry_added` after that result commits, persist finalized `terminate`, and emit existing tool events without exposing source indices. On live abort, signal started effects and preserve their finalized real/error results while appending planned synthetic aborted results for calls that never started; start no assistant request for closure. A genuine output-limit `length` response appends its planned explanatory errors in source order, starts no clearance or tool effect, and forces another assistant turn; an overflow-classified response gets no plan. + - Acceptance: exact one-tool, blocked, invalid, genuine-length, abort, and parallel-batch traces; no result id is allocated in a callback; no blocked/invalid/genuine-length call writes `tool_started`; a real result displays only its finalized execution's own usage and synthetics display none; phase-two effects overlap but dispatch, finalization, usage, and results obey the specified source ordering. - [ ] **H7 — tool recovery.** Dependencies: H6. - - Consume R2's X1–X5 reduced state and reconcile it; replay only when persisted and current declarations are safe, preserve ordinals, and handle truncated batches without execution. Do not duplicate reducer logic. - - Acceptance: complete tool crash matrix, changed replay declarations, parallel-prefix crashes, and idempotent second recovery. + - Consume D0's final X1–X7 planned-call state and reconcile it in source order. Without abort, a no-start call reruns clearance against its existing id and a started call replays persisted args only when persisted and current declarations are safe, otherwise it gets the planned interrupted result. With abort, never replay: an unresolved started call gets its planned interrupted result and an unstarted call gets its planned aborted result. Retain usage-without-result in the ledger without folding it into a later entry, keep a replay result's display snapshot to that replay's own usage, complete usage-free genuine-length and aborted planned synthetics without execution, and do not duplicate reducer logic. + - Acceptance: complete tool crash matrix, changed replay declarations, blocked/invalid decisions rerun under the same id, parallel starts beyond the result prefix, usage-before-result crashes, and idempotent second recovery. - [ ] **H8 — deferred provider redemption.** Dependencies: H7. - - Integrate the already-landed pi-ai deferred APIs: suspend, pending re-park, ready continuation, terminal/rejected fetch failure, handle mismatch, and best-effort cancellation. - - Select and document whether `resume()` uses a non-zero `fetchDeferred` wait or checks once and re-parks immediately. - - Acceptance: one fetch per resume; pending writes nothing except reported usage; terminal errors never start replacement requests. + - Primary files: `packages/agent/src/harness/agent-harness.ts` and focused deferred harness tests. The deferred provider, fetch, cancellation, and authenticated `Models` APIs in `packages/ai` are already landed and receive no new work here. + - Integrate one stable `deferred_fetch` step per original deferred response and its later pending responses. Copy its total configuration and normalized retry policy from the original assistant generation step exactly once; use the exact source handle's provider/model for fetches and the copied active names for ready tool calls. Number poll attempts across the step, record exact source plus response/usage ids before each fetch, persist pending/ready/terminal/interrupted responses before usage and classification, advance equal-handle pending source entries, retain interrupted/unknown sources, reject unmarked complete-handle mismatch, and best-effort cancel the newest persisted handle. An unmarked `aborted` response re-parks on its source below the copied per-source cap and fails at that cap without retry lifecycle events. Suspended abort retains deferred entries and adds no assistant closure; an active marker-backed fetch settles or synthesizes only under its existing attempt id. + - `resume()` always calls `fetchDeferred` with `wait: 0`: one check, then re-park immediately when pending. Poll cadence belongs to the application and can use `pollAfterMs`. + - Acceptance: at most one fetch per resume; repeated pending polls with a completely equal handle create distinct durable messages and advance exact source lineage; every pending/ready/terminal/interrupted poll has its preplanned usage record; ready tool calls use copied active names despite later lane configuration changes; returned and rejected terminal errors never start replacement requests; unmarked interruptions retry only on later resumes under copied policy and never abort the operation; no deferred poll emits retry lifecycle events; cancellation targets the newest persisted handle and remains best effort. ### Track C/N — structural operations These packages also own `agent-harness.ts` and merge after H8, in order C1 → C2 → C3 → N1. - [ ] **C1 — manual compaction operation.** Dependencies: H8. - - Add acceptance, hook decision, durable summary attempts/usage, complete `retainedTail`, result entry, abort/failure, and structural resume. - - Acceptance: exact manual-compaction traces and every crash boundary; hook-supplied summaries obey the same persisted entry contract and persist `fromHook: true`. + - Add acceptance and hook decision; persist either one complete hook-sourced result on `step_started` or one generated structural source with a stable typed result id and captured config/policy. Add preplanned hook usage, numbered generated summary attempts/usage, `step_failed`, complete `retainedTail`, result entry, abort/failure, and structural resume. Generated compaction uses no prepared-result record. Abort before the result-entry commit finishes aborted with no assistant entry or `step_failed`; abort after that commit completes the compaction. + - Acceptance: exact manual-compaction traces and every crash boundary; all generated attempts share one result id, terminal failure writes no assistant entry, hook output and usage survive without rerunning the hook with `fromHook: true`, and internal streams emit no public assistant-message lifecycle. - [ ] **C2 — threshold auto-compaction.** Dependencies: C1, H4. - Run compaction inside the active run at checkpoints without a nested operation and continue the assistant loop. - Acceptance: append-only context holds except at the compaction boundary; repeated compaction retains the previous checkpoint tail. - [ ] **C3 — overflow recovery.** Dependencies: C2, H2. - - Classify recoverable overflow/length results, discard them after usage accounting, compact, retry once per conversational input, and fail boundedly. - - Acceptance: every provider shape and crash row from sections 6 and 20, including hook decline and `length → length`. + - Extend post-persistence pure classification with explicit context-limit errors, reported input plus cache-read greater than the attempt's captured window, the existing provider-specific context-pressure signal, and recoverable `length` from the captured intended output limit. Link each overflow compaction to the exact superseded response and `triggerMessageId`, omit that response from preparation and retained tail, retry once for that trigger, and fail boundedly without deleting or replacing either response. Start no tool plan for overflow; keep genuine output-limit `length` in provider context. + - Acceptance: every provider shape and crash row from sections 6 and 19, exact-link validity and omission, hook decline, no overflow tool plan, genuine-length projection, and `length → length` bounded by one recovery for the same trigger. - [ ] **N1 — move-first navigation.** Dependencies: C3. - - Add acceptance, abandoned-branch preparation, hook/generated summary, move commit, post-move summary/fact writes, abort/failure, and structural resume. - - Acceptance: every navigation crash row, including regeneration after a post-move crash and target/source validation; hook-supplied summaries persist `fromHook: true`. + - Add pre-acceptance validation, abandoned-branch preparation, and navigation custom-instruction delivery. Return `InvalidNavigation` without a hook or durable append when the target is the current leaf or when a label is supplied for the `null` root target. Persist a complete hook summary on `step_started`, or use a generated branch-summary start with one typed result id across numbered attempts and `step_failed`, then persist one complete `branch_summary_prepared` before the move. After move, append the exact durable payload; labeled completion uses one `[label fact, operation_finished]` append, publishes both only after success in logical order, and never reruns a hook or provider. Abort before the move finishes aborted with no assistant entry; abort after the move completes summary/fact writes and navigation outcome. + - Acceptance: both `InvalidNavigation` cases append and invoke nothing; `[label fact, operation_finished]` has consecutive sequence positions, no interleaving or internal crash prefix, and emits fact then operation-end events only after full success; every source/target/summary leaf-result row, exact generated preparation-before-move, no post-move regeneration or model requirement, no generic compaction prepared record, completion-winning label races without fact ids, hook usage and `fromHook: true`, generated `fromHook: false`, custom instructions, no structural assistant events, and non-null target existence validation. ### Track O — observability and core completion These packages merge O1 → O2 → O3 → O4 after N1, with QA3 between O2 and O3. QA3 also requires J6. They may not modify `packages/coding-agent/**`. - [ ] **O1 — snapshots and event completeness.** Dependencies: N1, I2. - - Finish live lane/session snapshots, event filtering, streaming/running-tool state, and all section 10 event insertion points. - - Acceptance: event nesting/order tests and attach-mid-operation snapshot tests with no subscription gap. + - Finish live lane/session snapshots, exact lane-versus-global event filtering, assistant drafts retained through the `message_end`-to-`entry_added` window, running-tool state, abort snapshots/results with no synthetic assistant requirement, `entry_added` for every committed entry, structural typed-entry events without internal summary-stream message events, and all section 10 event insertion points. + - Acceptance: event nesting/order tests cover passive post-hook values, direct/tool-result lifecycles, `message_end` without a commit guarantee, and `entry_added` confirmation; attach-mid-operation and attach-between-end-and-commit snapshot tests have no subscription gap. - [ ] **O2 — runtime telemetry instrumentation.** Dependencies: O1, I0. - - Insert operation/checkpoint/turn/step wrappers at their procedure scopes, effect and passive-handler spans at their owning boundaries with `startHarnessSpan()`, and logical model-request spans with `startAiSpan()`. Populate only schema-declared attributes, including parallel tool children and resumed operation correlation; expected in-band failures set error status explicitly. - - Acceptance: captured telemetry has exact schema-conforming span trees for success, failure, suspend/resume, retry, compaction, and parallel tools; every emitted start/end/event bag conforms independently, callback spans settle exactly once, and no undeclared names, content, or secrets appear in defaults. + - Extend the landed harness schema only where the final execution model requires it: add stable `pi.step.id`, add `deferred_fetch` to the step kinds, reconcile the final step outcomes, add `multi` for one atomic append containing several logical mutations, and regenerate the schema reference. Do not otherwise redesign the landed schema. + - Insert operation/checkpoint/turn/attempt wrappers at their procedure scopes, effect and passive-handler spans at their owning boundaries with `startHarnessSpan()`, and logical model-request spans with `startAiSpan()`. Every provider-attempt span carries its durable `pi.step.id`, numbered attempt, and final kind including `deferred_fetch`; hook-sourced structural steps and post-move prepared-result completion emit no fabricated attempt span. Populate only schema-declared attributes, including parallel tool children, resumed operation correlation, marker-backed active-attempt abort, unmarked `aborted` interruption retry/failure, and abort-only recovery with no fabricated provider/step span; expected in-band failures set error status explicitly. + - Acceptance: the generated schema reference is current; captured telemetry has exact schema-conforming span trees for success, failure, suspend/resume, retry, compaction, and parallel tools; every emitted start/end/event bag conforms independently, callback spans settle exactly once, and no undeclared names, content, or secrets appear in defaults. - [ ] **O3 — action-prefix and race audit.** Dependencies: O2, QA3. - - Complete Tier C for every race row, mechanically reopen every action prefix, compare automatic/manual logs, and verify reducer/live-state fixed points. - - Acceptance: every race row has both orders and no documented crash action lacks a reopen test. + - Complete Tier C for every race row, mechanically reopen every live action prefix and every prefix created by an individual recovery write, compare automatic/manual logs, and verify reducer/live-state fixed points. + - Acceptance: every race row has both orders, every durable/effect boundary has a reopen case, and no recovery write can occur without a close/reopen continuation test from its resulting prefix. - [ ] **O4 — backend parity and final core audit.** Dependencies: J6, O3. - - Run the complete storage/recovery matrix across memory, JSONL, and SQLite; remove dead agent/storage declarations and compatibility comments; verify exports/declarations and `./node`; update changelogs and core documentation. - - Acceptance: all non-e2e tests and `npm run check` pass, no active harness operation remains scaffolded, `packages/coding-agent/**` is unchanged, and the worktree is clean. + - Run the complete storage/recovery matrix across memory, JSONL, and SQLite, including one/many atomic append parity and expanded logs, exact batched entry reads, custom fact null/deletion, configured lane and labeled-finish arrays, coherent forks, JSONL object/array replay and close drain, and SQLite fenced lease release; remove dead agent/storage declarations and compatibility comments; verify exports/declarations and `./node`; update changelogs and core documentation. + - Acceptance: all non-e2e tests and `npm run check` pass, backend lifecycle/fact/fork conformance agrees, no active harness operation remains scaffolded, `packages/coding-agent/**` is unchanged, and the worktree is clean. ### Dependency, priority, and merge summary -The serial storage lane is **R0 → J0 → J1 → J2 → J3 → J4 → J5 → J6**. The reducer lane is **R0 → R1 → R2 → R3**. The loop lane is **I0 → L1 → L2 → L3**. The effects lane is **R2 → I3 → I4 → I5**, with I4 also requiring I0, I1, and L3. Before H0, the convergence gate is **F0 + R3 + I2 + I5**. +The storage/reducer foundations join at D0: **R0 → J0 → J1 → J2 → J3**, **R0 → R1 → R2**, then **R2 + J3 → D0**. After D0, storage continues **D0 → J4 → J5 → J6** and restore continues **F0 + D0 → R3**. The loop lane is **I0 → L1 → L2 → L3**. The effects lane is **D0 → I3 → I4 → I5**, with I4 also requiring I0, I1, and L3. Before H0, the integration gate is **F0 + D0 + R3 + I2 + I5**. The runtime merge lane is strictly **H0 → H1 → H2 → H3 → H4 → H5 → H6 → H7 → H8 → C1 → C2 → C3 → N1 → O1 → O2 → QA3 → O3 → O4**. J6 may land independently at any time before QA3. This ordering prevents concurrent rewrites of `agent-harness.ts`, assigns every public method, and ensures every live path lands only after its reducer, telemetry, interception, and effect boundaries exist. @@ -3426,7 +4594,7 @@ The runtime merge lane is strictly **H0 → H1 → H2 → H3 → H4 → H5 → H For a fresh implementation session, in this order. This document wins over older harness designs. -1. `packages/agent/docs/harness-v2.md` — this document. +1. `packages/agent/docs/harness-v2-merged.md` — this document. 2. `packages/agent/src/harness/session/types.ts` — v4 entries, records, storage, and repository contracts. 3. `packages/agent/src/harness/session/session.ts` — session validation and lane-bound views. 4. `packages/agent/src/harness/session/memory.ts` — reference backend. From 4181f66e6b3ccbef760c2966ecd8b596b926fec6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 8 Aug 2026 20:44:59 +0200 Subject: [PATCH 065/284] docs(agent): tighten durable harness design --- packages/agent/docs/harness-v2.md | 418 +++++++++++++++--------------- 1 file changed, 208 insertions(+), 210 deletions(-) diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index 8a7a9bad23b..c07173b1ff0 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -1,6 +1,6 @@ # Durable AgentHarness design -> **Compatibility policy.** Old coding-agent v3 JSONL sessions must open and restore idle. This is the only backward-compatibility requirement. All other formats and APIs in `packages/agent/src/harness` and `packages/session-backends/sqlite-node` (and their respective tests) may break. We do not write migrations, schema versioning, or conversion paths for anything else. +> **Compatibility policy.** Only coding-agent v3 JSONL sessions require backward compatibility: they must open and restore idle. Other formats, APIs, and their tests in `packages/agent/src/harness` and `packages/session-backends/sqlite-node` may break without migrations, schema versioning, or conversion paths. ```mermaid flowchart TD @@ -16,15 +16,15 @@ flowchart TD Harness -.->|telemetry| Obs[Observability] ``` -The harness executes runs against one session. The session holds four kinds of state (section 2). Lanes execute in parallel inside one harness (section 3). Storage backends encode the session (Part III). +One harness executes runs against one session. The session has four kinds of state (section 2), its lanes execute in parallel (section 3), and storage backends encode it (Part III). # Part I — Concepts ## 1. Goals - **Durable runs.** An accepted prompt is a durable operation. After a crash, a new process reconstructs the operation from its records and resumes from the last durable boundary. Every state that a crash can produce is recoverable. -- **Durable responses.** Partial stream state is process-local, but every settled assistant-generation and deferred-fetch response is appended completely before the harness decides what it means. Retryable errors, overflow responses, deferred responses, and aborted responses are durable outcomes of their attempts. An `aborted` response means operation abort only when an earlier `abort_requested` won its append race; without that marker it is an ordinary interrupted provider response. -- **Lanes.** A session hosts one or more lanes. A lane is a named position in the conversation tree. Each lane runs at most one operation at a time. Lanes run in parallel. A run and its queued messages belong to the lane that accepted them. Example: a Slack channel is a session; each thread is a lane. Interactive pi uses one lane and does not show the concept in its UI. Extensions get the full harness API, including lanes. Example: a subagent tool runs on a second lane of its parent's session. +- **Durable responses.** Partial streams are process-local. Every settled assistant-generation and deferred-fetch response is appended completely before classification, including retryable errors, overflow, deferred, and aborted responses. An `aborted` response means operation abort only when an earlier `abort_requested` won its append race; otherwise it is a provider interruption. +- **Lanes.** A lane is a named position in the conversation tree with at most one operation. Lanes run in parallel; runs and queued messages stay on their accepting lane. A Slack channel can be a session with one lane per thread. Interactive pi uses one hidden lane. Extensions receive the full lane-aware harness API; for example, a subagent may use another lane in its parent's session. - **No partial outcomes.** A crash inside any operation — run, compaction, navigation — leaves one of two states: the operation has not happened, or recovery can complete it. Nothing in between is observable. - **Harness API.** Events observe execution and cannot change it. Hooks intercept execution and can change it: context, requests, tools, run boundaries. Extensions build on events and hooks. - **Deterministic stepping.** Every effect — durable write, provider request, tool execution, hook, timer — crosses one injected boundary. In `drive: "manual"` the harness parks before each effect and a test drives it call by call: stop at any boundary, inject input, or close and reopen to simulate a crash. Production and tests run the same procedures; the drive mode only controls the boundary (section 15). @@ -36,14 +36,14 @@ The harness executes runs against one session. The session holds four kinds of s ## Non-goals - **Exactly-once hook side effects.** A hook result becomes durable when the record or entry that consumes it commits. A crash before that commit can run the hook again (section 11 replay table). Side effects a hook makes on its own are invisible to the harness: HTTP calls, file writes. A hook that needs crash-safe external effects must be idempotent, for example keyed by operation id. -- **Provider stream resumption.** Partial streams are never persisted or resumed. If a process dies while streaming, the durable attempt intent has no response, so recovery treats the provider effect as unknown and starts a later numbered attempt only when policy permits. If an abort marker exists, it instead settles that attempt once as synthetic `aborted` under the already-provisioned id and never repeats the provider effect. A stream that settles is different: its complete response is persisted before retry, overflow, or abort classification. Deferred requests are also different and are in scope: the provider returns a handle at once and serves the result later (e.g. `background: true` on a Responses API, batch APIs). pi-ai returns an assistant message with stop reason `deferred` that carries the handle; it is persisted like any assistant message. Redemption of that original response and every later pending response returned while polling it uses one stable deferred-fetch step. The step copies the original generation step's total configuration and normalized retry policy once; later polls need no repeated lookup of that generation step. Each `resume()` makes at most one numbered, check-once poll attempt and persists the returned assistant message, including another `deferred` message when the work is still pending. Recovery polls the newest persisted source entry instead of starting a replacement generation request. +- **Provider stream resumption.** Partial streams are never persisted or resumed. After a crash, an attempt without a response is an unknown provider effect: recovery starts a later numbered attempt only when policy permits, or, with an abort marker, settles the existing attempt once as synthetic `aborted` under its provisioned id without repeating the effect. A settled stream is persisted before classification. Deferred requests remain in scope: pi-ai persists the provider handle in a `deferred` assistant message. One stable deferred-fetch step redeems that response and later pending responses, copying the original generation step's total configuration and normalized retry policy once. Each `resume()` performs at most one numbered check and persists its response, including another pending `deferred` response. Recovery polls the newest persisted source instead of starting replacement generation. - **Multiple writers.** Two processes on one session are out of scope. The serving layer routes all traffic for a session to the process that holds its harness. Lanes cover the workloads that look like multi-writer: parallel threads over shared history. - **Replication.** A session lives in one place. Coordination-free sync of diverging copies is a different design. Nothing forecloses it later. - **Coding-agent migration.** Migrating coding-agent to `AgentHarness` is out of scope. Compatibility means the new JSONL repository can read supported coding-agent v3 files. ## 2. What a session is -A session is durable state with four parts: +A session has four durable parts: 1. **The tree** — the conversation. Entries with `parentId` links: messages, compaction summaries, branch summaries, custom entries. The tree is shared and passive. It belongs to no lane. It only grows; entries are never changed or deleted. 2. **Lanes** — where work happens. A lane is a name plus a leaf: the entry that future work extends. Every session has the lane `main`. Applications create more, keyed by external identity (a Slack thread id, an email thread id). @@ -63,9 +63,7 @@ global facts: name = "Refactor auth", label(b) = "checkpoint-1", ### Active and passive -The tree and the global facts are passive: shared data, readable by anything. - -A lane is active. It owns its leaf, current total configuration, operation log (at most one open operation), queues, and pending writes. Two lanes never share any of these. Every durable action of a lane produces entries chained to its leaf or records in its own record sequence. +The tree and global facts are passive shared data. A lane is active: it owns its leaf, total configuration, operation log, queues, and pending writes. Lanes share none of these. A lane's durable actions append entries at its leaf or records to its sequence. ### Invariants @@ -80,9 +78,9 @@ Lane records describe configuration or execution, not conversation. They never e ## 3. Lanes -A lane is a named position in the tree plus the work serialized on it. The closest existing concept is a git branch checked out in its own worktree: a name attached to a position, advanced by new work, movable to any entry without rewriting history, and never checked out twice. One difference to git intuition: navigation moves a lane to any entry, not only forward. +A lane is a named tree position plus the work serialized on it. It resembles a git branch in its own worktree: new work advances it, and navigation moves it to any existing entry without rewriting history. -Every session has the lane `main`. Applications create further lanes with a name and an anchor entry. Lane names are permanent application keys: a Slack thread id, an email thread id. No UI lists lanes in the abstract; the platform's own UI (the thread list) plays that role. +Every session has `main`. Applications create more lanes from a name and anchor entry. Lane names are permanent application keys, such as Slack or email thread ids; the platform UI can provide their inventory. A lane owns: @@ -109,17 +107,17 @@ An operation is the unit of durable work on a lane. Three kinds: - **Compaction** — replaces old context with a summary entry. - **Navigation** — moves the lane's leaf to an existing entry, optionally with a branch summary. -An operation is accepted before it executes. Acceptance is durable: after a crash, an accepted operation is either completed by recovery or explicitly closed. When an operation finishes, exactly one `operation_finished` record is its terminal durable state; no separate tree entry is universal. Every accepted run finishes `completed`, `failed`, or `aborted` (stopped by abort). Compaction and navigation may additionally finish `declined` when their decision hook vetoes the accepted structural operation before its effect. +Acceptance precedes execution and is durable: after a crash, recovery completes or explicitly closes the operation. Exactly one `operation_finished` record is terminal; no tree entry is universal. Runs finish `completed`, `failed`, or `aborted`. Compaction and navigation may also finish `declined` when their decision hook vetoes the effect. ### Runs, turns, and steps A run is a sequence of turns. A turn is one assistant generation step plus the complete tool batch requested by the accepted assistant response. -A **step** is a durable logical unit of work inside an operation. A **generation step** is a step whose task starts a new LLM generation request: produce an assistant response, a compaction summary, or a branch summary. Committing its `step_started` record assigns a stable `stepId` and snapshots the values shared by all its attempts: the lane's total configuration and the normalized retry policy. Provider executions are numbered attempts of that same step, and the durable count survives restarts. A deferred-fetch step is not a generation step because it polls provider work that already exists; it still has one stable `stepId` and numbered poll attempts, and copies the original generation step's total configuration and normalized retry policy once so interrupted polls are self-contained. +A **step** is a durable logical unit within an operation. A **generation step** starts new LLM generation for an assistant response, compaction summary, or branch summary. Its `step_started` record assigns a stable `stepId` and snapshots the total lane configuration and normalized retry policy shared by its numbered provider attempts. The durable attempt count survives restarts. A deferred-fetch step instead polls existing provider work, but also has a stable `stepId`, numbered attempts, and one copy of the original generation step's configuration and policy so polls are self-contained. Structural decision hooks create a step without generation when they supply the result themselves. That `step_started` stores the complete provisioned compaction or branch-summary entry and optional hook-usage intent, so recovery never reruns the decision. A generated compaction commits by appending its result entry directly; it has no prepared-result record. A generated branch summary must survive a later navigation move, so its complete payload becomes a dedicated durable prepared record before the move. Structural provider streams are internal and never appear as public assistant-message events. -Before an assistant-generation or deferred-fetch attempt starts its provider effect, it provisions the id of its response entry. Once the stream settles, the complete response is appended under that id for every stop reason, before later orchestration. Classification may decide to retry, compact, suspend, fail, abort, or accept the response, but it does not erase it. If a crash leaves an attempt without its response, the external effect is unknown: without abort, recovery starts the next numbered attempt when policy permits or appends a synthetic interruption response under the already-provisioned id at the cap; with an abort marker, it appends a synthetic aborted response under that same id and never retries. +Before an assistant-generation or deferred-fetch provider effect, its attempt provisions the response entry id. The complete settled response is appended under that id for every stop reason before classification can retry, compact, suspend, fail, abort, or accept it. A crash that leaves no response makes the effect unknown: without abort, recovery starts the next numbered attempt when allowed or appends a synthetic interruption under the provisioned id at the cap; with an abort marker, it appends synthetic `aborted` under that id and never retries. For an assistant generation, `triggerMessageId` is the id of the newest consumed message that projects as user context and caused that generation. A prompt, consumed steer or follow-up, or another run-owned user-context message can supply it. It bounds overflow recovery to one compaction for that user input: @@ -143,7 +141,7 @@ Two mechanisms carry input into a running lane. They differ in abort behavior: - **Queues** carry conversational intent: `steer` corrects the current work, `followUp` adds work for when the model would stop, `nextRun` seeds the lane's next run. Steering and follow-ups die on abort; their payloads are returned to the caller. Next-run messages survive. - **Deferred writes** carry tree additions requested while a step is in flight. They survive abort and are applied even during cancellation. -Both are durable at acceptance: the accepting call writes a record with the full payload to the lane's operation log, then resolves. The tree entry is written later, when the item is applied or consumed — the position where the model first sees it. If the process dies between acceptance and the tree write, recovery reads the record and performs the append. Accepted input is never lost. +Both become durable when acceptance records their full payload. Their tree entry is appended later at application or consumption, where the model first sees it. Recovery completes an accepted item whose entry is absent. Lane configuration updates do not use deferred writes. A setter commits an immediate total `lane_config` replacement on the mutation line, so later generation steps see it while an already-started step and all its retries retain their captured configuration. @@ -186,7 +184,7 @@ stateDiagram-v2 ### Resume -Resume continues the open operation. It never starts a new one and never relies on a persisted program counter. The harness reduces the lane's durable records and planned entry ids, then re-enters the ordinary operation procedure at the state they describe: settle an attempt whose response is missing, classify a durable response whose next transition is missing, reconcile a planned tool batch, perform at most one numbered deferred-fetch poll, or continue at the next checkpoint. Every deferred poll response is durable, including another pending `deferred` response, so a later resume polls from the newest persisted source entry. Queued messages and deferred writes accepted before the crash are still pending and apply normally. +Resume continues, but never starts, an operation and uses no persisted program counter. The harness reduces durable records and planned entry ids, then re-enters the ordinary procedure at its first unfinished transition: settle a missing attempt response, classify an accounted response, reconcile a tool batch, perform at most one deferred poll, or continue a checkpoint. Every poll response is durable, so later resumes use the newest persisted source. Accepted queues and deferred writes remain pending. For deferred polling, the **source lineage** is the response-entry chain that identifies what each poll redeems: the original deferred response is the first source, each pending poll response becomes the next source, and an interrupted or unknown poll retains its existing source. **Complete handle equality** means every required field, every optional field's presence and value, and the JSON value in `data` match; section 16 defines the field-level comparison. @@ -511,13 +509,13 @@ type NewRecord = T extends LaneRecord ? Omit : never; ``` -The batch plan is the intent for every call outcome, including calls that never execute. Blocked and invalid calls write no `tool_started`; they append an `isError: true` tool-result entry under the id already assigned to their source index. A crash before that entry reruns clearance — including `before_tool` — against the same planned id. A genuine output-limit `length`, abort before a call starts, or recovery of an unsafe started call likewise appends its explanatory, aborted, or interrupted synthetic result under that id. +The batch plan covers every call outcome, even without execution. Blocked and invalid calls write no `tool_started`; they append an `isError: true` result under their source index's planned id. A crash before that entry reruns clearance, including `before_tool`, against the same id. Genuine output-limit `length`, abort before start, and unsafe started-call recovery likewise append explanatory, aborted, or interrupted synthetic results under planned ids. -A tool batch needs no outcome record. Its result entries are its complete durable outcomes, including each finalized `terminate` decision (section 12). A real call that reports usage writes a `usage` record bound to its planned result id before appending the result entry. That real result keeps only the finalized execution's own `AgentToolResult.usage` as its immutable display snapshot. A crash after execution or after that usage record but before the result follows the replay policy (section 6); re-finalization runs `after_tool` again after a safe replay, which the section 1 non-goal explicitly permits, and another real execution may write another usage record. Earlier execution and replay records remain separate in the durable ledger and effective cost sums them. A synthetic result has no usage snapshot, even when an earlier lost execution left a usage record. Public hooks, events, snapshots, and tool execution context continue to use provider `toolCallId` and tool name. Source index remains internal and is used only for source ordering and planned-result lookup. +A tool batch needs no outcome record: its result entries durably store every outcome and finalized `terminate` decision (section 12). A real call writes reported usage against its planned result id before the result entry; the entry snapshots only that finalized execution's `AgentToolResult.usage`. A crash before the result follows section 6 replay policy. Safe replay reruns `after_tool` as section 1 permits and may add another usage record; the ledger retains and sums each execution. Synthetic results have no usage snapshot, even if a lost execution left usage. Public hooks, events, snapshots, and tool context use provider `toolCallId` and tool name; source index remains private ordering and planned-result correlation. -Cost is the one concern where settlement requires a second durable object: **cost durability must not depend on later classification**. Every settled assistant-generation and deferred-fetch response is first appended under its attempt's `responseEntryId`, then its preplanned `usage` record is appended before retry, overflow, suspension, failure, abort, or acceptance logic. A crash in between loses no cost: recovery rebuilds the exact record id and payload from the attempt and the response's immutable usage. Structural requests have no durable assistant response; each reported request usage is written immediately, before a generated compaction entry or `branch_summary_prepared`, and the provider-settle-to-usage-write crash window remains. A successful structural result's immutable usage snapshot is the sum of the successful attempt's request records; failed-attempt records remain ledger-only. Tool-reported usage is written before its planned result entry. A hook-reported structural usage record uses the id provisioned by the hook-sourced `step_started` and is repaired from its persisted result before that result entry commits, including when abort prevents the entry. Applications append `adjustment` records for anything the harness cannot see. +Settlement needs a second durable object because **cost durability must not depend on classification**. Every assistant-generation and deferred-fetch response is appended under its planned id, followed by its preplanned `usage` record before any retry, overflow, suspension, failure, abort, or acceptance logic. Recovery reconstructs a missing record from the attempt and immutable response usage. Structural requests have no assistant entry, so each reported usage is written immediately after its request and before a generated compaction entry or `branch_summary_prepared`; the provider-settle-to-write crash window remains. A successful structural result snapshots the sum of its successful attempt's request usage records, while failed-attempt records remain ledger-only. Tool usage precedes its planned result. Hook structural usage uses the id provisioned on `step_started` and is repaired from the stored result before result commit, including when abort prevents that commit. Applications use `adjustment` for unseen cost. -A harness-written `usage` record binds `entryId` to the entry its measurement belongs to. For assistant and deferred-fetch work that entry always exists before the usage record. Structural failed attempts can still bind usage to a typed result id that never materializes. Three layers separate cleanly: an entry's `usage` field is an **immutable display snapshot** of the response that produced that entry, written once at append and never touched again; the **effective cost of an entry** is a read-time query — the sum of all lanes' `usage` records bound to its id, base plus adjustments; the **session's cost** is the sum of all `usage` records. Recovery can honestly bill twice — a later numbered provider attempt or a replayed tool writes one record per execution. A real tool-result snapshot contains only its finalized execution's usage, not earlier lost executions or replays, while the ledger retains and sums every record bound to the planned result id. A synthetic tool result has no usage snapshot. +A harness-written `usage.entryId` identifies the measured entry. Assistant and deferred-fetch entries exist before usage; a failed structural attempt may bind usage to a typed result id that never materializes. An entry's `usage` is an immutable display snapshot written at append. Its **effective cost** is the read-time sum of all lanes' usage and adjustment records bound to its id; **session cost** sums the whole ledger. A later provider attempt or replay writes another record because another billable execution occurred. ### Validity @@ -593,7 +591,7 @@ H before_run_end nothing pending, returns nothing R operation_finished completed ``` -A crash between any two lines is recoverable. An assistant/fetch attempt without its response is an unknown effect and never reuses that attempt's id; absent abort it advances under policy, while an abort marker settles it as synthetic `aborted` under that id. A response without usage gets its exact preplanned record before classification. A generated compaction without its typed result and a generated branch summary without its prepared payload continue under captured policy or close with `step_failed`; a hook source already contains its complete result. A provider response or generated structural result without its planning step/attempt cannot exist. +A crash between any two lines is recoverable. An assistant/fetch attempt without its response is an unknown effect. Its provider effect never repeats under that attempt number, and its ids are never assigned to a later attempt; absent abort it advances under policy, while an abort marker settles synthetic `aborted` under that attempt's planned response id. A response without usage gets its exact preplanned record before classification. A generated compaction without its typed result and a generated branch summary without its prepared payload continue under captured policy or close with `step_failed`; a hook source already contains its complete result. A provider response or generated structural result without its planning step/attempt cannot exist. ### Retry @@ -670,13 +668,13 @@ Every prefix around settlement and transition has one interpretation: | response and usage present, no linked transition | run the pure classifier on that same response; an abort marker selects reconciliation | | response and usage present, linked transition present | absent abort, resume the represented retry, overflow compaction, tool batch, suspension, or finish; with abort, preserve the response and reconcile instead | -An existing abort marker selects abort before following or creating an ordinary transition, whatever stop reason a response that committed first retained. Otherwise overflow classification runs before interruption and retryable-error classification; a context-limit error must compact rather than retry the same oversized request. `deferred` suspends. An unmarked `aborted` response is an ordinary provider interruption: below the captured cap it retries, and at the cap it fails the run. A retryable `error` below the cap retries, any other `error` fails, and `stop`, `toolUse`, or a genuine output-limit `length` is accepted. The same function and ordering run live and after reopen. +An abort marker takes priority over ordinary transitions, regardless of a response's preserved stop reason. Otherwise classification checks overflow before interruption or retryable error, so an oversized request compacts rather than retries unchanged. `deferred` suspends. Unmarked `aborted` retries below the captured cap and fails at it. Retryable `error` retries below the cap; other errors fail. `stop`, `toolUse`, and genuine output-limit `length` are accepted. Live and reopened execution use this order. ### Context overflow at an assistant step Overflow classification has three explicit inputs, all durable on the response and attempt: -1. **Explicit provider context-limit error.** The response has `stopReason: "error"`, and its durable `errorMessage` matches pi-ai's existing provider context-limit patterns after known non-overflow patterns such as throttling and rate limits are excluded. Examples include "prompt is too long", "exceeds the context window", and DashScope/Qwen's "Range of input length should be". No transient exception or HTTP object is needed after reopen. +1. **Explicit provider context-limit error.** The response has `stopReason: "error"`, and its durable `errorMessage` matches pi-ai's context-limit patterns after exclusions such as throttling and rate limits. Examples: "prompt is too long", "exceeds the context window", and DashScope/Qwen's "Range of input length should be". Reopen needs no transient exception or HTTP object. 2. **Reported input exceeds the captured window.** The response has `stopReason: "stop"`, `attempt.contextWindow > 0`, and `message.usage.input + message.usage.cacheRead > attempt.contextWindow`. This preserves the existing successful-response check without introducing a separate named condition or durable outcome. 3. **A recoverable `length`.** The response either matches the existing Xiaomi MiMo-compatible context-pressure signal — zero output and reported input plus cache-read tokens at least 99% of the captured non-zero window — or ended below the request's persisted intended output limit: @@ -688,9 +686,9 @@ function isRecoverableLength(message: AssistantMessage, intendedOutputLimit: num } ``` -`usage.output` already includes reported reasoning tokens. `intendedOutputLimit` is the caller-supplied `maxTokens` when set, else `model.maxTokens`, captured before any context clamping. The value actually sent cannot be the reference: some providers reject an explicit output cap outright (the OpenAI Codex backend returns HTTP 400 for `max_output_tokens`), and pi clamps others to the remaining context. This covers a context-clamped request that returns 16 reasoning tokens against a 128k intent, a zero-output Xiaomi/Qwen-style response, and an explicit 1,024 cap fully used as a genuine stop. The Xiaomi compatibility signal is the only percentage check; there is no general context-percentage heuristic. +`usage.output` includes reported reasoning tokens. `intendedOutputLimit` is the caller's `maxTokens`, or `model.maxTokens`, captured before context clamping. The sent value cannot be the reference: some providers reject explicit caps (OpenAI Codex returns HTTP 400 for `max_output_tokens`), while pi clamps others to remaining context. Thus 16 reasoning tokens against a 128k intent and zero-output Xiaomi/Qwen pressure are recoverable, but a fully used explicit 1,024 cap is genuine. Xiaomi compatibility is the only percentage check; there is no general percentage heuristic. -A recoverable response remains a complete transcript entry. After its usage record commits, classification chooses overflow recovery and starts no tool-batch plan, even if the response contains tool calls. The exact response is then named by the overflow compaction and omitted from both summary preparation and retained-tail construction. **Compaction preparation** is the summary input and proposed retained tail given to `before_compaction` and, if needed, the structural provider request; the final `CompactionEntry.retainedTail` is built from that same filtered preparation. The response remains queryable in the tree; omission affects model and compaction context, not history. +A recoverable response remains a complete transcript entry. After usage commits, overflow classification starts no tool plan. The overflow compaction names that response and omits it from summary preparation and retained-tail construction. **Compaction preparation** is the summary input and proposed tail passed to `before_compaction` and, when needed, structural generation; `CompactionEntry.retainedTail` uses the same filtered preparation. The response remains queryable; omission affects context, not history. ```text R step_started assistant S1; trigger U; captured policy/config @@ -707,7 +705,7 @@ E assistant message R usage ``` -**One recovery per conversational input.** An overflow compaction may commit only when no earlier overflow compaction in the run carries the same `triggerMessageId`. A second recoverable response with that trigger remains durable and accounted, starts no tool batch, and fails through the drain path. A `length` response never resets the guard. Consuming a newer prompt, steer, follow-up, or other user-context message gives the next assistant step a new trigger and permits one new overflow compaction. A `before_compaction` decline or an empty overflow preparation is equally terminal because the request cannot fit without compaction. +**One recovery per conversational input.** An overflow compaction requires that no earlier one in the run has the same `triggerMessageId`. A second recoverable response for that trigger remains durable and accounted, starts no tool batch, and enters failure drain; `length` does not reset the guard. Consuming newer prompt, steer, follow-up, or other user-context input gives the next assistant step a new trigger and one new allowance. Hook decline or empty preparation is terminal because the request cannot fit without compaction. Per crash site: @@ -721,7 +719,7 @@ Per crash site: | compaction `step_attempt` | structural effect unknown | start the next numbered attempt below the cap; otherwise append `step_failed` | | compaction entry | structural step closed by its typed result | checkpoint path; a fresh assistant step with the same trigger follows | -A genuine output-limit `length` response follows the accepted-response path and remains in transcript **and provider context**. If it has no tool calls, the run reaches its normal checkpoint. If it has tool calls, the harness commits the ordinary durable tool-batch plan, executes none of them, and appends one planned `isError: true` result per source call in source order. Each result explains that the call was not executed because the response was truncated before completion and its arguments may be incomplete. These synthetic results do not terminate the batch, so another assistant turn follows and sees both the genuine `length` response and the explanatory results. A crash before the plan reclassifies the durable response and creates the plan; a crash after the plan resumes its missing planned results without executing tools. +A genuine output-limit `length` response is accepted and remains in transcript **and provider context**. Without tool calls it reaches the normal checkpoint. With calls, the harness plans the batch, executes none, and appends one planned `isError: true` result per call in source order, explaining that truncation may have left arguments incomplete. The errors do not terminate, so another assistant turn sees the response and results. A crash before planning reclassifies and plans; a crash after planning fills missing results without execution. ### Steering while a tool runs @@ -776,7 +774,7 @@ E user message R operation_finished ``` -Deferred writes and abort use the same ordering. A deferred write accepted before finish must be applied before the run can close; one accepted after finish observes an idle lane and appends directly. `abort_requested` before finish selects abort reconciliation; abort after finish returns `NoActiveOperation`. There is no third history — that is the entire mechanism. +Deferred writes and abort use the same ordering. A write accepted before finish applies before close; after finish it appends on the idle lane. `abort_requested` before finish selects reconciliation; abort after finish returns `NoActiveOperation`. No third history exists. ### Deferred write mid-turn @@ -802,17 +800,17 @@ R step_attempt S attempt 1; response R1, usage U1 provider stream active abort() first call resolves after the next record R abort_requested queues drain; stream is signalled -E assistant message R1 same attempt id; stop reason normalized to aborted +E assistant message R1 planned response id; stop reason normalized to aborted R usage U1 measured usage from the settled stream E pending deferred writes accepted run-owned writes still apply R operation_finished aborted; no separate tree closure ``` -Response settlement is one mutation-line job. If `abort_requested` commits first, that job keeps the settled message and usage but normalizes its stop reason to `aborted`, clearing any deferred-only handle field, before appending it under the attempt's existing `responseEntryId`. If a crash leaves that response absent, recovery appends a synthetic zero-usage `aborted` response under that same id, then the attempt's preplanned usage record. It never retries the attempt and never allocates another assistant id. If the response entry commits first, a later abort preserves its original stop reason; recovery repairs missing usage and the marker prevents retry, overflow recovery, tool planning, or any other ordinary transition. The stop reason alone is not abort authority: without an earlier marker, an `aborted` response follows ordinary interruption retry/failure classification and never finishes the operation aborted. +Response settlement is one mutation-line job. If `abort_requested` committed first, it clears deferred-only handle data, normalizes the settled stop reason to `aborted`, and appends under the attempt's `responseEntryId`. If that response is absent after a crash, recovery appends synthetic zero-usage `aborted` under the same id, then the preplanned usage. It never retries or allocates another assistant id. If the response committed first, abort preserves its stop reason; recovery repairs usage, while the marker prevents retry, overflow, tool planning, and other ordinary transitions. Stop reason alone is not abort authority: unmarked `aborted` follows interruption retry/failure and never aborts the operation. -An abort between assistant steps has no response id to settle and appends no assistant message. An abort during retry delay cancels the sleep and starts no later attempt; already-emitted retry events are not followed by events for an attempt that never starts. Repeating `abort()` while reconciliation is open writes no second marker, does not signal again, emits no second `run_abort`, and returns copies of the same drained steer/follow-up payloads. If `operation_finished` won first, the later call instead returns `NoActiveOperation`. +Between assistant steps, abort has no response id and appends no assistant message. During retry delay it cancels sleep and starts no later attempt or events for that attempt. Repeated `abort()` during reconciliation writes, signals, and emits nothing again, returning copies of the same drained steer/follow-up payloads. If finish won first, it returns `NoActiveOperation`. -During a planned tool batch, effects that already started are signalled and allowed to settle. Their real or error results, including `after_tool` finalization and reported usage, keep their planned ids. Calls whose effects never started get planned synthetic `aborted` results. After a crash, a started call with no result is an unknown effect and gets its planned synthetic `interrupted` result; abort-only recovery never replays it. For example: +During a planned tool batch, started effects are signalled and may settle; their finalized real/error results keep planned ids, and usage remains bound to those ids. Unstarted calls get planned synthetic `aborted` results. After a crash, a started call without a result gets planned synthetic `interrupted`; abort-only recovery never replays it: ```text E assistant message [tool calls c1, c2] @@ -828,7 +826,7 @@ E pending deferred writes R operation_finished aborted; no assistant request is started ``` -Crash after `abort_requested`: recovery completes the same planned results and pending deferred writes, then appends the terminal record. Queued steer/follow-up items are not applied. A response whose tool calls had not yet received a batch plan gets no plan after abort, so there are no promised tool results to manufacture. +After a crash following `abort_requested`, recovery completes planned results and deferred writes, then appends the terminal record; steer/follow-up items are not applied. A response without a pre-abort batch plan has no promised tool results. On a suspended deferred response, abort best-effort cancels the newest persisted handle, retains every deferred response entry, applies pending writes, and finishes aborted without another assistant message. A live deferred-fetch attempt follows the same existing-response-id settlement rule as an active assistant attempt. Cancellation failure is telemetry only and cannot block reconciliation; a crash may repeat the best-effort cancellation, but repeated `abort()` in one process does not. @@ -865,7 +863,7 @@ X7 result durable c1 complete Blocked and invalid calls go directly from X2/X3 to their planned error result and never write `tool_started`. Genuine output-limit `length` does the same for every source call without running clearance. During live abort, a started effect that settles keeps its real/error result; after a crash, any unresolved started call gets an `interrupted` result without replay. A planned call that never started gets an `aborted` result. Every synthetic result has `isError: true` and `terminate: false`. -Preparation is sequential in source order in both execution modes. Sequential mode then writes `tool_started`, executes, finalizes, writes usage when present, and appends the result before preparing the next call. Parallel mode prepares each call, writes `tool_started` for a real call, and dispatches its individual `fx.executeTool` call in source order without awaiting earlier effects. Effects may settle concurrently. Finalization, optional usage writes, and result appends await those dispatched effects in source order, so durable results always form a source-order prefix. Started records can be ahead of that prefix and can skip source positions occupied by blocked or invalid calls. Recovery reduces each planned call independently and settles missing results in source order. +Both modes prepare sequentially in source order. Sequential mode then starts, executes, finalizes, optionally writes usage, and appends each result before the next preparation. Parallel mode prepares, starts, and dispatches each real `fx.executeTool` in source order without awaiting earlier effects; effects may settle concurrently, but finalization, usage, and result appends await them in source order. Durable results therefore form a source-order prefix. Starts may lead that prefix and skip blocked or invalid positions. Recovery reduces calls independently and settles missing results in source order. ### Auto-compaction at a checkpoint @@ -913,7 +911,7 @@ E branch summary entry exact prepared payload; appends from targe A [G label, R operation_finished] one append; consecutive seq, completed ``` -When the hook supplies a summary, `step_started` stores the complete provisioned `BranchSummaryEntry` with `fromHook: true` and its optional preplanned hook-usage id; there is no `step_attempt` or `branch_summary_prepared`. Both sources therefore have one durable complete payload before the move. Generated payloads have `fromHook: false`. The move commits first among tree/fact effects, and the later entry append chains off the durable target. No multi-object atomic write exists. +For a hook summary, `step_started` stores the complete provisioned `BranchSummaryEntry`, `fromHook: true`, and optional usage id; no attempt or prepared record exists. Generated payloads use `fromHook: false`. Either source is durable before the move. The move is the first tree/fact effect; the later entry chains from its target. These writes are not atomic together. Acceptance returns `InvalidNavigation` before writing `operation_started` when `target === sourceLeafId` or when `target === null` and a label is present. Root is not an entry and has no label fact. With `summarize: true`, the durable states and actions are exhaustive: @@ -929,9 +927,9 @@ Acceptance returns `InvalidNavigation` before writing `operation_started` when ` A target leaf without a durable payload, a summary entry whose id is not the leaf, or any other leaf is corruption. With `summarize: false`, only source-before-move and target-after-move are valid; no structural step or summary entry exists. A pre-move abort finishes aborted and leaves any prepared payload only in records. Once the move commits, abort cannot undo it: recovery appends the durable summary when required, writes the label, and finishes completed without model or hook lookup. -The accepted label and `operation_finished` are consecutive logical mutations in one atomic append. No write can interleave, and no crash prefix exists between them. The accepted label therefore wins over every label write ordered before navigation completion, while a write ordered after the terminal record remains newer. No fact id or operation-specific fact identity is needed. +The accepted label and `operation_finished` are consecutive mutations in one atomic append, with no interleaving or internal crash prefix. The label wins over earlier writes; writes after the terminal record remain newer. No fact or operation-specific fact id is needed. -Between the move and `operation_finished`, readers see the target or summary leaf with an open navigation — a recoverable state, not an invalid one. The lane runs nothing else meanwhile; one operation per lane already guarantees that. +Between move and finish, readers see the target or summary leaf with an open, recoverable navigation. The lane runs nothing else. ### Deferred provider request @@ -963,18 +961,18 @@ R usage U4 ready continues; interrupted suspends; terminal fails ``` -The suspended lane is indistinguishable from a crashed one in storage: an open operation with an unredeemed deferred source. Restore lists it as suspended. The first `resume()` for this provider work creates one stable `deferred_fetch` step and copies the total configuration and normalized retry policy from G, the assistant generation step that persisted D1. Later polls read those copies from F and never look up G again. The exact source handle supplies the provider and model for each fetch. If a ready response contains tool calls, F's copied active tool names select the environmental implementations even when the lane configuration changed while suspended. +Storage represents deliberate suspension and a crash alike: an open operation with an unredeemed source. Restore lists either as suspended. The first `resume()` creates stable deferred-fetch step F and copies configuration and normalized retry policy from generation step G. Later polls use F without rereading G. Each source handle supplies provider/model; F's copied active names select tools for a ready response despite later lane changes. -Every poll attempt records its exact source entry plus fresh response and usage ids before fetching. Attempts are numbered consecutively across F. A pending response advances the source lineage to its own distinct entry even when its complete handle is unchanged; an unmarked interrupted response leaves its named source unredeemed. A committed response prevents the same attempt from fetching again. +Before fetching, each consecutively numbered F attempt records its exact source and fresh response/usage ids. A pending response advances lineage to its distinct entry even with an unchanged handle; an unmarked interruption retains its source. A committed response prevents refetch by that attempt. -Each `resume()` performs at most one fetch and always calls `fetchDeferred` with `wait: 0`; it checks once rather than long-polling. A caller schedules later resumes, using `pollAfterMs` when present. Deferred polling emits no `retry_scheduled`, `retry_start`, or `retry_end` events. Four outcomes: +Each `resume()` performs at most one `fetchDeferred(..., { wait: 0 })` check. The caller schedules another, optionally using `pollAfterMs`. Polling emits no `retry_scheduled`, `retry_start`, or `retry_end`. Four outcomes: -- **pending** — the provider returns stop reason `deferred` again. The complete response entry and its usage record are appended, its complete handle must equal the source handle, and the lane re-suspends on this new source entry. Repeated pending answers therefore produce durable D2, D3, and so on, and each later poll names the newest one instead of D1. -- **interrupted** — the provider returns stop reason `aborted` and no earlier `abort_requested` exists. The complete response and usage are durable and omitted from context. Count provider attempts that name this same `sourceEntryId`; below the copied policy cap, re-suspend on that exact source handle and let the next `resume()` wait the captured backoff before making one later numbered attempt. An intervening pending response creates a new source entry and resets this per-source attempt count. At the cap, fail the run. -- **ready** — a normal assistant message. It and its usage record are appended, then the run continues. Tool calls use the active names copied onto F; provider/model fetch identity still comes from the exact source handle. -- **terminal** — the provider returns stop reason `error` (expired, unknown, consumed), or the fetch itself rejects; the harness converts a rejection to the same durable error-message form. The response entry and usage record are appended, then the run finishes failed. Redemption failure never starts an automatic replacement generation request; steering or follow-up input already accepted for this run can still start a later turn. +- **pending** — append and account the new `deferred` response, require complete handle equality, and re-suspend on its entry. Repeated pending answers produce durable D2, D3, and so on; each poll names the newest. +- **interrupted** — an unmarked `aborted` response and usage are durable but omitted from context. Count attempts naming its `sourceEntryId`. Below the copied cap, retain that source and let the next `resume()` wait captured backoff before one later attempt; at the cap, fail. A pending response creates a new source and resets this per-source count. +- **ready** — append and account the normal assistant response, then continue. Tool calls use F's copied active names; fetch identity came from the exact source handle. +- **terminal** — append and account a returned error (expired, unknown, consumed) or rejection converted to the same durable message, then fail. Never start replacement generation; already-accepted steering or follow-up can still start a later turn. -`abort()` on a suspended lane writes `abort_requested`, best-effort cancels the newest handle at the provider, applies accepted deferred writes, then writes `operation_finished` aborted. Existing deferred entries stay in the transcript and no assistant closure is added. Missing provider/model implementations do not block this abort-only path: cancellation is best effort and the persisted handle identifies it when the capability exists. +On a suspended lane, `abort()` writes its marker, best-effort cancels the newest handle, applies deferred writes, then finishes aborted. Deferred entries remain and no assistant closure is added. Missing provider/model implementations do not block this path; cancellation runs only when resolvable. Deferred assistant messages carry a handle, not content; they project to nothing in provider context. @@ -982,7 +980,7 @@ Deferred assistant messages carry a handle, not content; they project to nothing ### Restore -Opening a session restores every lane independently. After the one-time pre-restore initialization of an unconfigured `main` described in section 8, restore reads only; it never appends and never starts provider, tool, hook, or timer effects. +Opening restores each lane independently. After section 8's one-time initialization of an unconfigured `main`, restore only reads; it starts no writes, providers, tools, hooks, or timers. Recovery uses indexed, bounded reads. For each lane: @@ -990,12 +988,12 @@ Recovery uses indexed, bounded reads. For each lane: 2. Call `findOpenOperations(lane, { limit: 2 })`. Zero results means idle, one means suspended, and two means corruption. Backends answer from replayed or indexed open-operation state rather than by scanning starts and finishes. 3. Independently find the newest run-kind `operation_started`. From its `seq` exclusively, or from the start of the lane when no run has started, read only `queue_enqueued` and `queue_cancelled` records needed to reduce `nextRun`. This query runs for every lane, including when a newer compaction or navigation is open. Structural operations never consume or hide next-run input. 4. If an operation is open, read its records by exact `runId`, oldest first. The slice begins with the discovered `operation_started` and contains only that operation's records; completed operation history is not read. -5. Build the lane's complete **entry plan** from those record slices, then make one `getEntries(ids)` call for the plan. The plan contains every entry id whose presence can affect reduction: run initial-message ids; structural result ids from the operation intent, `step_started`, and `branch_summary_prepared`; assistant/fetch response ids from `step_attempt`; tool-result ids from `tool_batch_started`; operation queue and deferred-write target ids; and next-run queue target ids. A hook result and generated prepared branch summary are inline record payloads, not extra entry reads. Source handles, overflow links, and queue cancellations must refer to ids already supplied by those records and add no unplanned lookup. Existing entries are returned in one immutable map; absent ids remain legitimate crash-prefix state. +5. Build the complete **entry plan**, then call `getEntries(ids)` once. Plan every id whose presence affects reduction: run initial messages; structural results from operation intent, `step_started`, and `branch_summary_prepared`; assistant/fetch responses from `step_attempt`; tool results from `tool_batch_started`; operation queue and deferred-write targets; and next-run targets. Hook and prepared branch-summary payloads are inline records, not extra reads. Source handles, overflow links, and cancellations must reference already-planned ids. One immutable map returns existing entries; absence may be a valid crash prefix. 6. Pass the lane pointer, indexed configuration, bounded records, plan, and returned entries to the pure reducer. -The planner performs only record-local checks needed to construct a safe exact-id query: discriminants agree, referenced plans exist, and provisioned ids are unique in the roles that require uniqueness. The reducer then applies the relevant section 5 relationships to the bounded prefix and returned entries. Restore does not walk from the lane leaf to `sourceLeafId`, read the operation's conversation branch, inspect a completed operation, scan unrelated adjustment records, or read another lane's traffic. Ordinary context or structural preparation may perform a branch query later, when the resumed procedure actually reaches that work; that is execution, not restore. +The planner checks only what a safe exact-id query needs: matching discriminants, existing referenced plans, and role-specific id uniqueness. The reducer applies section 5 relationships to that bounded prefix. Restore never walks leaf-to-`sourceLeafId`, reads the operation branch or completed operations, scans unrelated adjustments, or reads another lane. Ordinary execution may query a branch later for context or structural preparation. -Next-run reduction is deliberately separate from the open-operation slice. A pending next-run item is an enqueue after the newest run-start boundary whose target entry is absent and which no matching cancellation retracts. Items before that boundary belong to the newest run's captured `initialMessages`, even if a later compaction or navigation is now open. This gives the structural-open trace one interpretation: +Next-run reduction is separate from the open-operation slice. An item is pending when enqueued after the newest run start, absent from the tree, and not cancelled. Earlier items belong to that run's captured `initialMessages`, even if a later structural operation is open: ```text R operation_started run A captures all earlier nextRun items @@ -1008,7 +1006,7 @@ X crash ### Entry-plan reduction -Entry-plan construction and lane reduction are pure functions: they perform no storage reads, writes, effects, id allocation, or runtime model/tool lookup. Equal inputs produce equal outputs. Existing planned entries are indexed by id and ordered by their storage `seq` only where chronology matters; reduction never infers operation ownership from a tree path. +Entry planning and lane reduction are pure: no storage access, effects, id allocation, or runtime identity lookup. Equal inputs produce equal outputs. Planned entries are indexed by id and use storage `seq` only where chronology matters; tree paths never imply operation ownership. The reducer derives: @@ -1024,13 +1022,13 @@ The reducer derives: - **structural state** — source kind, complete hook result, generated `branch_summary_prepared`, planned result presence, `step_failed`, and lane-pointer equality determine compaction completion and every pre-move/post-move navigation prefix. For summarized navigation, source leaf means not moved, target leaf means moved but summary not appended, and summary-entry leaf means moved and appended. The reducer rejects a move without a durable payload. No branch content is required and no post-move generation state exists. - **newest own entry and terminal failure** — the highest-`seq` existing entry in the open operation's plan is its newest own entry. Only a step-produced assistant error or an unmarked `aborted` response at its applicable captured cap becomes terminal-failure provenance; an arbitrary deferred-write message cannot. -Section 5 validity is enforced only for the discovered open operation, the independent still-relevant next-run slice, and the planned entries needed to interpret or repeat them. Restore does not re-audit completed records, unrelated tree entries, historical facts, or another lane. Append-time validation and focused conformance tests enforce the full write contract; bounded restore asks only whether its current prefix is safe and unambiguous. +Restore enforces section 5 validity only for the open operation, relevant next-run slice, and entries needed to interpret them. It does not re-audit completed records, unrelated entries, historical facts, or other lanes. Append validation and conformance tests enforce the full write contract; restore asks only whether the current prefix is safe and unambiguous. -Live execution installs the same state transitions in memory after each commit. Fresh reduction of that durable prefix must equal live state, but this fixed-point comparison is a test invariant. Production does not reread storage after settlement, suspension, or finish. +Live commits apply the same transitions in memory. Fresh reduction must match live state in tests; production does not reread storage after settlement, suspension, or finish. ### Ordinary procedure re-entry -`resume()` does not dispatch to recovery-only continuations and no program counter is persisted. It invokes the same run, compaction, or navigation procedure as live execution. Each procedure reads the reduced state and reaches the first unfinished ordinary transition: +`resume()` persists no program counter and dispatches no recovery-only continuation. It invokes the live run, compaction, or navigation procedure, which reads reduced state and reaches its first unfinished transition: | reduced prefix | ordinary re-entry | |---|---| @@ -1055,11 +1053,11 @@ Live execution installs the same state transitions in memory after each commit. | terminal assistant/fetch failure | the ordinary failure-drain checkpoint applies writes and consumes eligible input; absent new work it finishes failed | | no unfinished transition | the ordinary checkpoint or structural finish boundary conditionally appends `operation_finished` | -An abort marker takes priority after missing initial messages and response accounting are repaired. If a compaction result or navigation move already committed, the ordinary structural procedure completes that committed structure. Otherwise `abortPath` settles a missing active assistant/fetch response under its existing id, completes planned tool results without replay, best-effort cancels a deferred handle, applies pending writes, and finishes aborted. No unrelated assistant entry is appended. +An abort marker takes priority after missing initial messages and response accounting are repaired. If a compaction result or navigation move already committed, the ordinary structural procedure completes that committed structure. Otherwise `abortPath` settles a missing active assistant/fetch response under its planned id, completes planned tool results without replay, best-effort cancels a deferred handle, applies pending writes, and finishes aborted. No unrelated assistant entry is appended. -Recovery appends use the planned-entry presence already returned by the single batched lookup. The in-memory state is updated after each recovery write, so re-entry skips an entry that now exists and verifies that existing planned content is compatible. A crash during recovery therefore leaves a shorter prefix for the same ordinary procedure; a second recovery is safe. No recovery write occurs during restore itself. +Recovery uses entry presence from the one batched lookup. Each write updates memory, so re-entry skips newly existing entries after verifying their content. A crash leaves a shorter prefix for the same procedure; repeating recovery is safe. Restore itself writes nothing. -Runtime identities are checked only immediately before the ordinary effect that needs them: the exact captured/source model before a provider request or required fetch, and the relevant tool implementation before an invocation or safe replay. Synthetic settlement, usage repair, persisted hook-result commit, prepared-summary append, queue/write application, finish, and non-replay tool reconciliation do not require those identities. Abort-only reconciliation bypasses model/tool checks entirely; best-effort deferred cancellation runs only when its capability can be resolved. Navigation performs every provider or hook effect before its move, so completing a committed move never needs runtime model identity. `SuspendedOperation.missing` is the forecast for the next required effect, not the union of every name present in lane configuration. +Check runtime identities immediately before the effect that needs them: the captured/source model before request or fetch, and the tool before invocation or safe replay. Synthetic settlement, usage repair, persisted structural commits, queue/write application, finish, and non-replay reconciliation need none. Abort-only reconciliation bypasses model/tool checks; deferred cancellation runs only when resolvable. Navigation performs provider/hook work before moving, so post-move completion needs no model. `SuspendedOperation.missing` forecasts the next effect, not every configured name. Interrupted hook handlers follow the section 11 replay table. Old v3 sessions contain no durable operation records, so restore reports normalized `main` idle at its final retained logical entry; legacy configuration entries never initialize the v4 lane configuration. @@ -1069,7 +1067,7 @@ Interrupted hook handlers follow the section 11 replay table. Old v3 sessions co ### The lane surface -`AgentLane` is the operation surface of one lane. `AgentHarness` implements it for `main`: `harness.prompt(...)` is main's prompt. Every method is async, including getters an in-process implementation answers from memory: the interface must be implementable by a remote proxy, so no signature may promise synchronicity that only the local implementation can keep. Sync exceptions: `name`, and listener registration (`hooks.on`, `events.on`) — a server bridges events over its own transport, not registrations. +`AgentLane` is one lane's operation surface; `AgentHarness` implements it for `main`. Methods, including getters, are async so remote proxies can implement them. Only `name` and listener registration (`hooks.on`, `events.on`) are synchronous; servers bridge event delivery, not registration. ```ts interface AgentLane { @@ -1132,7 +1130,7 @@ interface AgentLane { } ``` -All prompt overloads normalize to `AgentMessage[]`. Text plus images becomes one user message; an input message array keeps its order after validation. Skill and template expansion happens before normalization is stored. This normalized array is `OperationStartedRecord.intent.originalPrompt`; it excludes captured `nextRun` items and hook injections. +Prompt overloads normalize to ordered `AgentMessage[]`; text plus images becomes one user message. Skill/template expansion precedes storage. `OperationStartedRecord.intent.originalPrompt` contains this array, excluding captured `nextRun` items and hook injections. ### The harness @@ -1242,13 +1240,13 @@ interface AgentHarnessOptions { } ``` -`AgentHarness.create()` normalizes the three option fields into one immutable `LaneConfiguration`, copying the active-name array and storing the model as `{ provider, modelId }`. A fresh session and a normalized v3 session have an unconfigured `main`; before the restore phase, the harness appends the seed as `main`'s first `lane_config`. Every already-configured format-4 lane is read only from its newest `lane_config`; the options seed never overrides it. Any additional format-4 lane without a configuration record is corruption. +`AgentHarness.create()` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Before restore, it appends the seed as the first `lane_config` for fresh or normalized-v3 `main`. Existing format-4 lanes use only their newest config; the seed never overrides them. Any other config-less format-4 lane is corrupt. -`createLane(name, at)` uses the same captured seed even if `main` or another lane has since changed. Its one storage commit creates the pointer and first `lane_config` atomically. Later setters replace only the targeted lane's total value and do not mutate the seed. Therefore reopening with different options can affect lanes created by that new harness instance, but cannot change an existing lane without a setter. +`createLane(name, at)` atomically writes its pointer and the original captured seed, regardless of later lane changes. Setters replace only their lane's total value, never the seed. Reopen options can seed new lanes but cannot alter existing ones without a setter. ### Results and tagged errors -The public API uses a small vendored subset of the `better-result` v3 pattern. `packages/agent` does not take a runtime dependency on `better-result`. +The public API vendors this small `better-result` v3 pattern without a runtime dependency: The subset contains only: @@ -1301,9 +1299,9 @@ export declare function matchError, R>( ): R; ``` -The implementation is expected to stay under about 80 lines, excluding tests. It has no mapping combinators, generator composition, promise wrappers, retry helpers, collection helpers, or `Panic` class. Promise remains the async boundary. `HarnessFault` uses native throwing and promise rejection for defects. +Keep the implementation under about 80 lines excluding tests. Do not add combinators, generator composition, promise wrappers, retry/collection helpers, or `Panic`; Promise is the async boundary, and defects throw or reject with `HarnessFault`. -Each expected rejection is one class. Its tag is a string literal. Its fields carry the data a caller needs. Use the v3 class form shown below; do not add a trailing `()` after the property type: +Each expected rejection is a class with a literal tag and caller-relevant fields. Use this v3 form without trailing `()` after the property type: ```ts class LaneBusy extends TaggedError("LaneBusy")<{ @@ -1339,7 +1337,7 @@ The remaining classes use the same base: | `NothingToCompact` | `lane` | | `Closed` | none | -A transport serializes an error as `{ _tag, message, ...payload }` and reconstructs the class at the proxy boundary. Adding a rejection class changes the corresponding error union. An exhaustive `matchError` call then fails to type-check until its caller handles the new tag. +Transports serialize `{ _tag, message, ...payload }` and reconstruct the class at the proxy. Adding a rejection class changes its error union, forcing exhaustive `matchError` callers to handle the tag. An `Err` means the call did not create or accept the requested work. While the harness remains open and writable, every accepted operation resolves with `Ok`, including `aborted`, `failed`, and `suspended`: @@ -1405,11 +1403,11 @@ type ResumeResult = Result; type CreateLaneResult = Result; ``` -`QueueResult` is the active-run contract for `steer` and `followUp`; `NoActiveRun` belongs only to that contract. `NextRunResult` has no active-operation rejection: while the harness is open, valid input is accepted while idle or during a run, compaction, or navigation. Acceptance appends only the operation-independent queue record. It does not accept or start a run; the next run acceptance captures the item. +`steer`/`followUp` use the active-run `QueueResult`; only it includes `NoActiveRun`. `NextRunResult` accepts valid input while open and idle or during any operation. It appends only an operation-independent queue record; later run acceptance captures it. -`navigateTree()` returns `InvalidNavigation` before its decision hook or any durable write when the target is the current leaf, or when a label is supplied for the `null` root target. A non-null unknown entry instead returns `UnknownTarget`. Root-label facts are not added. +`navigateTree()` returns `InvalidNavigation` before hooks or writes for the current leaf or labeled root target. An unknown non-null entry returns `UnknownTarget`. Root-label facts do not exist. -`cancelQueued` outcomes mirror the mutation-line histories: `cancelled` means the entry will never be appended; `already_consumed` means the entry exists (the model saw or will see it); `already_cleared` means abort drained the item or an earlier cancel won. +`cancelQueued` reports `cancelled` when append is prevented, `already_consumed` when the entry exists, and `already_cleared` when abort or an earlier cancel removed it. A storage write failure is not an `Err`. It faults the harness and rejects the promise with `HarnessFault`: @@ -1432,9 +1430,9 @@ class HarnessClosed extends Error { } ``` -Calls on a faulted harness reject with the same `HarnessFault` instance until the session is reopened. `close()` rejects process-local promises for accepted operations with `HarnessClosed`; their durable operations remain open and resumable. Result-returning calls made after `close()` return `Err(new Closed(...))`; other calls reject with `HarnessClosed`. An invariant violation also rejects. Promise rejection therefore means a defect or a dead harness, not an expected operation outcome. These errors do not belong to public `Result` error unions. +A faulted harness rejects with the same `HarnessFault` until reopen. `close()` rejects local accepted-operation promises with `HarnessClosed`, leaving durable operations resumable. Afterwards, result-returning calls return `Err(Closed)` and others reject with `HarnessClosed`. Invariant violations also reject. Promise rejection means a defect or dead harness, never an expected outcome; these errors are outside public `Result` unions. -`finalMessage` is the run's newest durable assistant response and `finalEntryId` is that response entry's id. On an aborted or failed run both fields are absent when no assistant attempt settled; otherwise both refer to the newest existing response, which need not have stop reason `aborted`. `leafId` is the lane's leaf when the operation finished — the race-free anchor for branch queries (`findEntriesOnBranch({ start: leafId })`). It can differ from `finalEntryId` when a deferred write or required tool result was appended later. Full transcripts are not duplicated into results; they are in the session and were delivered as events. +`finalMessage` and `finalEntryId` identify the newest durable assistant response. Failed or aborted runs omit both if none settled; otherwise both identify the newest response, regardless of stop reason. `leafId` is the finish-time lane leaf and race-free branch-query anchor; later deferred writes or tool results can make it differ from `finalEntryId`. Results do not duplicate transcripts. **Type provenance.** Core conversation and tool types (`AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, `ThinkingLevel`) come from `packages/agent/src/types.ts`. Provider types (`Model`, `Models`, `Usage`, `RetryPolicy`, stream options, deferred handles) come from `packages/ai`. The generic telemetry contract and schema machinery come from `packages/telemetry`; the AI-request and harness span schemas come from `packages/agent/src/harness/telemetry.ts`. Session, harness, hook, event, result, snapshot, navigation, and durable-record types are defined under `packages/agent/src/harness/`. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runPlannedToolCall`, and request/option bags such as `AssistantRequest`) are constructive implementation detail, not contract. @@ -1510,7 +1508,7 @@ for (const lane of s.snapshot.lanes) { ## 9. Snapshots and subscription -A UI needs current state plus every change after it, with no gap. This includes the transport gap: a server that proxies a harness must deliver the snapshot to its client before any event reaches the wire. `watch()` buffers until the consumer arms delivery: +A UI needs current state and every later change without a gap. A proxy must put the snapshot on the wire before events, so `watch()` buffers until armed: ```ts const { snapshot, start, unsubscribe } = await lane.watch(); // harness.watch() = main's @@ -1519,9 +1517,9 @@ await send(client, { kind: "snapshot", snapshot }); // snapshot is on the wire start((event) => send(client, event)); // flush buffer in order, then live ``` -`watch()` captures the snapshot and starts buffering in one step. `start(listener)` flushes the buffer in order and switches to live delivery. Each event arrives exactly once, in order. No sequence numbers, no registration race. `unsubscribe()` drops the subscription and its buffer; a watcher that never calls `start()` buffers without bound. +`watch()` atomically snapshots and begins buffering. `start(listener)` flushes in order, then delivers live; each event arrives once and in order, without sequence numbers or registration races. `unsubscribe()` drops the watcher and buffer. A never-started watcher buffers without bound. -`watch()` is lane-scoped: this lane's transcript, operation state, queues, and pending writes, plus this lane's events and the harness-global events defined below. A Slack thread renderer sees no other lane's transcript or lane-scoped activity. `watchSession()` is the session-wide observer: lane inventory, no transcripts, unfiltered event stream. A dashboard composes both: `watchSession()` for the overview, `lane.watch()` per opened thread. +`watch()` contains one lane's transcript, operation, queues, pending writes, and scoped/global events. `watchSession()` contains lane inventory, no transcripts, and the unfiltered stream. A dashboard can use the latter for overview and `lane.watch()` for open lanes. ```ts interface QueuedItem { @@ -1581,16 +1579,16 @@ Rules: ## 10. Events -One flat stream. `events.on(type, listener)` receives matching events across the harness; lane watchers apply the lane/global filter in section 9. +Events form one flat stream. `events.on(type, listener)` matches across the harness; lane watchers apply section 9 filtering. Guarantees: -- Passive. Listeners cannot replace or mutate execution values; event payloads are isolated from the objects retained by the procedure. A throwing listener is caught and reported as a `handler_error` event plus telemetry; it never affects execution. A listener that throws while handling `handler_error` goes to telemetry only. Hooks are the only interception surface. -- Ordered. Delivery follows process order, identical for watchers and `events.on`. Concurrent lanes do not promise `seq`-ordered passive delivery; durable consumers use `getLog()`. +- Passive. Listeners cannot mutate execution; payloads are isolated from procedure objects. Throws produce `handler_error` plus telemetry and never affect execution. A `handler_error` listener throw goes only to telemetry. Only hooks intercept. +- Ordered. Watchers and `events.on` receive process order. Concurrent lanes do not promise `seq` order; durable consumers use `getLog()`. - Not persisted, not replayed. Reconnect means a new `watch()`. -- Events whose contracts announce durable facts fire after commit. In particular, `entry_added` means its entry is queryable. For a multi-write append, no commit event fires until the whole append succeeds; events then follow logical mutation order. Labeled navigation therefore emits its `fact_update` before its operation-end event, with both durable before either event. Lifecycle events describe process-local work and need not be durable: `message_end` explicitly precedes the attempted entry append. -- Completion events report final values after the relevant transformation hook. Streaming updates are intermediate; `after_response` produces the final streamed response before `message_end`, and `after_tool` produces the final tool result before `tool_end` and its tool-result message lifecycle. If an abort marker wins after `message_end` but before response append, the later `entry_added` carries the required normalized `aborted` response and is authoritative. -- Payloads are JSON-serializable and secret-free; a server can proxy them verbatim. Live objects (models, tools) are referenced by name, never embedded. +- Durable-fact events fire after commit: `entry_added` means queryable. Multi-write events wait for full success, then follow mutation order; labeled navigation emits `fact_update` before operation end, with both already durable. Process-local lifecycle events need not be durable: `message_end` precedes entry append. +- Completion events follow transformation hooks. Streaming updates are intermediate; `after_response` precedes `message_end`, and `after_tool` precedes `tool_end` and result-message events. If abort wins between `message_end` and append, `entry_added` carries the normalized durable response and is authoritative. +- Payloads are secret-free JSON. Models and tools are named, never embedded. - Lane-scoped events carry `lane: string` (omitted below); harness-global events omit it — except `usage`, which is delivered harness-globally and carries the record's lane in its payload. Operation-scoped events carry `runId`; turn-scoped events carry `turnId`; recovered work carries `recovery: true`. ### Catalog @@ -1688,17 +1686,17 @@ run_start run_end ``` -A UI's busy indicator spans `run_start`..`run_end`, and the `compaction_start`/`navigation_start` brackets for standalone operations. Resumed structural operations re-emit their start event (`recovery: true`) so brackets always balance. Compaction and branch-summary provider streams are internal and emit no public `message_start`, `message_update`, or `message_end`; their committed typed result emits `entry_added`, and structural start/end plus retry events describe their orchestration. +Busy UI spans `run_start`..`run_end` or standalone structural brackets. Resumed structural work re-emits its start with `recovery: true` to balance brackets. Internal compaction/branch-summary streams emit no message lifecycle; their typed result emits `entry_added`, while structural and retry events describe orchestration. -For a streamed assistant-generation or deferred-fetch response, the exact order is: `message_start`, zero or more `message_update`, `after_response`, `message_end` with the final transformed stream value and optional intended id, response-entry append, `entry_added`, preplanned usage commit, then classification. Thus `message_end` can be the last response event even when the append later faults; only `entry_added` proves durability. If `abort_requested` wins between end and append, settlement normalizes the committed response as section 6 requires, and `entry_added` reports that durable value. A direct message, synthetic response, or finalized tool result has no updates but keeps the same immediate `message_start` → `message_end` before its append, followed by `entry_added` only if that append commits. Existing entries skipped during recovery emit nothing because events are not replayed. +Streamed assistant/fetch order is: `message_start`, `message_update`*, `after_response`, `message_end` with final value and optional intended id, response append, `entry_added`, usage commit, classification. Only `entry_added` proves durability. If abort wins before append, `entry_added` reports the normalized committed value. Direct messages, synthetic responses, and finalized tool results omit updates but emit start/end before append and `entry_added` only after success. Recovery emits nothing for existing entries. -A durable retryable assistant response below the captured cap — including an unmarked `aborted` provider interruption — emits `retry_scheduled`, then `retry_start`, then `retry_end` when the later numbered attempt resolves either way. An abort during the delay starts no later attempt and therefore emits no lifecycle events for one. Deferred-fetch polls never emit those retry lifecycle events: a pending poll emits `run_suspend` with its new equal-handle source, and an interrupted poll emits `run_suspend` with its retained source; a later `run_resume` may make the next poll. +A retryable assistant response below cap, including unmarked `aborted`, emits `retry_scheduled`, then `retry_start`/`retry_end` around the later attempt. Abort during delay starts no attempt or events for it. Deferred polls emit no retry lifecycle: pending suspends on its new source, interrupted on its retained source, and a later resume may poll again. Abort emits `run_abort` once and eventually `run_end`. Assistant message events occur only when an already-intended assistant/fetch attempt settles or is synthetically settled under its provisioned id. An abort between steps, during tool work, or while suspended can therefore have no abort-specific assistant event; `run_end.finalMessage`/`finalEntryId` then refer to the newest response that already exists or are both absent. Planned tool-result message lifecycles, their `entry_added` confirmations, and accepted deferred-write events still precede `run_end` when reconciliation appends those entries. Structural operations emit no assistant-message lifecycle for abort: `compaction_end` / `navigation_end` reports `aborted` when the marker won before the commit point and `completed` when the structural commit won first. ## 11. Hooks -Hooks are awaited interception points. Registration mirrors events, with an optional stable registration id: +Hooks are awaited interception points. Registration mirrors events and may use a stable id: ```ts const off = harness.hooks.on("before_tool", async (event) => { @@ -1713,12 +1711,12 @@ harness.hooks.on("before_run", async () => ({ Semantics, uniform across all hooks: - Registration is harness-global. Every hook event carries `lane` (omitted below); a handler scopes itself. -- `before_run` and `before_resume` registrations require a stable `id`. An id is unique within one hook name; duplicate registration rejects synchronously. The same extension uses the same id for both hooks across restarts. The runner stores each `before_run` handler's `resumeData` under its id and hands each `before_resume` handler only the value under the same id. -- `before_run` runs on the normalized caller prompt, outside the lane mutation line, before acceptance. It does not see captured nextRun items; the acceptance mutation captures those afterwards (section 15). A rejected acceptance (busy lane) discards the hook output. -- Handlers run sequentially in registration order. Each transformation handler sees the output of the previous one; returned `messages` append and a returned `systemPrompt` replaces the current value. -- A throwing handler does not fail the run: it is skipped, reported via `handler_error`, and the remaining handlers run. One exception: `before_tool` fails closed — a throwing handler blocks the tool. A skipped policy handler must not allow a tool it might have blocked. -- Hook results that feed durable state are persisted before execution proceeds: `before_run` output lands in the `operation_started` record, `before_tool` effective arguments in the `tool_started` record, and the finalized `after_tool` result plus `terminate` decision in the tool-result entry. The hook's return alone is not durable; a crash before that commit can run it again. -- Events report post-hook values; observers never see pre-hook state. Event listeners are passive and cannot transform those values. An extension that needs to replace a response uses `after_response`, not `message_end`. +- `before_run` and `before_resume` require a stable `id`, unique within each hook name; duplicates reject synchronously. An extension reuses its id across both hooks and restarts. The runner stores `resumeData` by id and gives each resume handler only its value. +- `before_run` runs before acceptance, outside the mutation line, on the normalized caller prompt. It does not see `nextRun` items, which acceptance captures later. Rejected acceptance discards its output. +- Handlers run in registration order, each seeing the prior output. Transformations compose: `messages` append and `systemPrompt` replaces. +- A throw emits `handler_error`, skips that handler, and lets the remaining handlers continue without failing the run. `before_tool` instead fails closed and blocks the tool. +- Durable hook outputs commit before execution continues: `before_run` in `operation_started`, effective `before_tool` args in `tool_started`, and finalized `after_tool` result/`terminate` in its entry. A return alone is not durable; a pre-commit crash may rerun it. +- Events expose post-hook values. Passive listeners cannot transform them; response replacement uses `after_response`, not `message_end`. ### Catalog @@ -1872,11 +1870,11 @@ interface CustomEntry extends EntryBase { type: "custom"; customType: type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; ``` -A harness-written assistant `MessageEntry` always contains a `SettledAssistantMessage`; `pending` is rejected before any durable write. A v4 tool-result `MessageEntry` additionally persists the finalized batch-control decision as `terminate?: true` beside `message`. It is orchestration state for the reduction (section 7), never model context; the projection to provider messages ignores it. `AgentToolResult.terminate` exists at the tool API level but `ToolResultMessage` does not carry it, so the entry field is the durable form. +Harness assistant entries always contain `SettledAssistantMessage`; reject `pending` before writing. V4 tool-result entries persist `terminate?: true` beside `message` for reduction, never provider context. Because `AgentToolResult.terminate` exists but `ToolResultMessage` omits it, the entry field is its durable form. -For compaction and branch-summary entries, `fromHook: true` means the summary was supplied by `before_compaction` or `before_navigation`; `false` means the harness generated it. The field is required on every v4 entry. This durable provenance is also an ownership boundary for `details`: harness-generated summaries may use a harness-owned shape that later summary preparation can interpret (for example, cumulative file tracking), while hook-supplied details are opaque and must never be interpreted by the harness. +Every v4 compaction/branch-summary entry requires `fromHook`: true for hook output, false for generation. It also defines `details` ownership. The harness may interpret its generated shape, such as cumulative file tracking, but hook-supplied details remain opaque. -Every v4 compaction — generated or hook-supplied — stores the complete `retainedTail`; an empty tail is `[]`, never omission. The compaction entry is a self-contained checkpoint: context builds never read past it. For an overflow compaction, both summary preparation and this stored tail omit the exact `supersededResponseEntryId` named by its `step_started`; the response remains a separate tree entry. Entry `usage` fields — on assistant messages, tool results, compactions, and branch summaries — are immutable display snapshots of the response that produced that entry: an assistant/fetch message entry supplies the payload for its attempt's preplanned usage record; a real planned tool result keeps only that finalized execution's own `AgentToolResult.usage`, while a synthetic result has none; a compaction or branch-summary entry carries its successful attempt's request(s), never failed attempts. The durable ledger separately retains every execution and replay `usage` record; effective cost including later adjustments is a read-time ledger query by `entryId` (sections 5, 13). +Every v4 compaction stores complete `retainedTail`, using `[]` when empty, and context never reads past this self-contained checkpoint. Overflow preparation and tail omit the exact superseded response named on `step_started`; the response stays in the tree. Entry `usage` fields are immutable display snapshots: assistant/fetch usage feeds the preplanned record; a real tool result shows only its finalized execution, a synthetic none; structural entries show the sum of successful-attempt request usage, never failed attempts. The ledger separately retains every execution/replay and supplies adjusted effective cost by `entryId` (sections 5, 13). v3 files additionally contain `custom_message`, `label`, `session_info`, `model_change`, `thinking_level_change`, and `active_tools_change` entries, plus old compaction entries that use `firstKeptEntryId`. These names are decoder vocabulary only; format 4 exposes no configuration entry type. Load normalizes them before exposing the v4 tree: @@ -1889,11 +1887,11 @@ v3 files additionally contain `custom_message`, `label`, `session_info`, `model_ - Existing `details` and `usage` on compaction and branch-summary entries are preserved unchanged. Existing `fromHook` provenance is preserved; an absent v3 value normalizes to `false`. - v3 entry timestamps are ISO strings and convert to Unix milliseconds. -Read-only opens keep the physical v3 file unchanged; the first v4 write persists the normalized form (section 13). +Read-only v3 open leaves the file unchanged; the first v4 write persists normalization (section 13). ### SessionTree -The tree-facing contract. Each lane exposes one view (`lane.session`); `Session` itself implements it for `main`. Reads pass through always. A tree-entry write through a lane view enters that lane's mutation line: while a run is open — including suspension and cancellation — it becomes a durable deferred write; during compaction or navigation it waits for the operation to end; on an idle lane it appends directly. Global-fact setters instead commit immediately as one semantic fact mutation through `Session.append()`; they neither enter a lane queue nor move a leaf. Writes on a standalone `Session` (no harness attached) apply immediately. +Each lane exposes this tree view as `lane.session`; `Session` implements it for `main`. Reads pass through. A lane-view entry write enters the mutation line: during a run, including suspension/cancellation, it becomes a durable deferred write; during structural work it waits; while idle it appends. Fact setters commit immediately through `Session.append()` without queues or leaf movement. Standalone Session writes are immediate. ```ts interface EntryQuery { @@ -1943,15 +1941,15 @@ interface SessionTree { } ``` -Query semantics: a branch scan takes the path from `start` to root, walks it in `order` direction, stops after a `stopAt` match (inclusive), filters, then applies `limit` and `cursor`. +A branch query takes the `start`-to-root path, walks in `order`, stops inclusively at `stopAt`, filters, then applies `limit` and `cursor`. - `newestFirst` with `stopAtType: "compaction"` ends at the newest compaction: the context window. - `type` and `customType` filter results; a `stopAt` entry is returned only if it passes the filter. - Extension patterns: effective state = `findEntryOnBranch({ type: "custom", customType })`; collections = `findEntriesOnBranch(...)`; global inventory = `findEntries(...)`. -- Context build is a branch scan with `stopAtType: "compaction"`. Its base sequence is the compaction summary, the materialized `retainedTail`, then entries after the compaction; nothing before the compaction is read. Before `transform_context` and `toProviderMessages`, harness projection omits assistant messages whose stop reason is `error`, `aborted`, or `deferred`. A genuine output-limit `length` message projects normally. A response classified as overflow is not generically marked on its entry: the linked overflow compaction omits that exact entry from its preparation and materialized `retainedTail`. Custom entries then project through `entryProjectors`, and the resulting `AgentMessage[]` passes through `toProviderMessages`. +- Context uses a branch scan stopping at compaction: summary, materialized tail, then later entries; nothing earlier is read. Before `transform_context` and `toProviderMessages`, projection omits `error`, `aborted`, and `deferred` assistant responses but retains genuine `length`. Overflow is not entry-marked; its linked compaction omits the exact response from preparation and tail. Custom entries pass through `entryProjectors`, then all messages through `toProviderMessages`. - `SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. -Read consistency: finders and `getEntry` return committed entries only. A deferred write is not in the tree until applied; a handler that appends and immediately queries does not see its own write. Pending writes are visible in the snapshot, correlated by provisioned id. When a `SessionTree` is attached to a harness, a direct or later-applied message write emits its immediate message lifecycle before the append and `entry_added` after commit; a standalone `Session` has no harness event stream. +Finders and `getEntry` return only committed entries. A deferred write is invisible to tree queries until applied but appears in snapshots by provisioned id. Harness-attached message writes emit their immediate lifecycle before append and `entry_added` after commit; standalone Session has no harness events. ### Session @@ -2060,17 +2058,17 @@ interface RecordQuery { } ``` -Semantic Session and SessionTree methods construct mutations and inspect the returned `LogItem` discriminants. Array results correspond positionally to input mutations, so processing can retain an entry mutation's routing lane without copying it into the returned item. `createLane()` extracts the second array item as its `LaneConfigRecord`; entry/record Effects similarly recover their committed typed payloads while keeping their semantic return types. Storage itself exposes no parallel typed write methods. +Semantic methods construct mutations and inspect returned `LogItem` discriminants. Array results align positionally, preserving an entry mutation's routing correlation without copying its lane into the result. `createLane()` extracts the second item as its config; Effects similarly recover typed payloads. Storage exposes no parallel typed writes. `Session` exposes no `getStorage()` escape hatch: all writes flow through `Session`, which is the single writer the storage contract assumes. -**Ownership rule:** after an application passes a `Session` to `AgentHarness.create()`, it mutates that session only through the harness and its lane views until `close()` resolves. Concurrent writes through the original standalone reference are unsupported caller misuse; the harness adds no machinery for it. Harness close calls `Session.close()`, so the closed object is not reused; a later process or harness reopens the same durable session through its repository. +**Ownership:** after passing a Session to `AgentHarness.create()`, mutate it only through the harness/lane views until `close()` resolves. Concurrent standalone writes are unsupported. Harness close closes that Session object; later use reopens durable state through the repository. ## 13. Storage ### Contract -One session per storage instance. Storage persists and answers queries; `Session` owns validation and view binding. Storage never executes operations, queues, or recovery. Record payloads are opaque except for indexed query columns, the latest lane-configuration projection, and the required open-operation recovery projection. Exact entry-plan lookup is by the entry id index and does not interpret entry payloads. +One storage instance serves one session. Storage persists and queries; `Session` validates and binds views. Storage runs no operations, queues, or recovery. Record payloads are opaque except for query columns and latest-config/open-operation projections. Exact entry lookup uses only the id index. ```ts interface SessionStorage { @@ -2113,24 +2111,24 @@ interface SessionStorage { Contract rules, all backends: - One monotonic `seq` across entries, records, facts, and lane changes. -- `append()` accepts one mutation or a non-empty array. Session validates and JSON-checks the complete input before dispatch. The backend then validates against transactional intermediate state, applies every logical mutation in array order, assigns consecutive `seq` values with no interleaving, and commits all or none. A later mutation in the same call observes earlier lane, entry, record, fact, open-operation, configuration, and statistics changes. -- An entry mutation's `lane` is routing envelope data, not part of `Entry` or its committed `LogItem`. Storage assigns `parentId`, `seq`, and `timestamp`; its parent is that lane's leaf after preceding mutations in the same append, and the entry becomes the new leaf. The returned item occupies the same position as its input mutation, which preserves routing correlation during append processing without persisting or returning lane ownership. -- A record mutation receives its assigned `seq` and `timestamp`. Fact and lane mutations receive sequence positions represented by their returned `LogItem`. `getLog()` expands every append into its logical items and applies ordering, cursor, and limit to those expanded items. -- A configured lane has at least one `lane_config`; the newest is its whole current value. A setter is one record mutation. A lane-create mutation is valid only when the next mutation in the same append is that lane's first total `lane_config`. `Session.createLane()` emits exactly `[lane create, initial lane_config record]`, with consecutive positions in that order, and returns the second item's committed record. Neither half can be observed alone. -- A completed navigation that accepted a label is valid only when its exact label fact immediately precedes `operation_finished` in the same append. `finishOperation()` emits `[label fact, operation_finished]`, with consecutive positions in that order. No other write can interleave, so the accepted label wins over every fact write ordered before completion. Unlabeled completion appends only the terminal record. -- A hook-sourced structural `step_started` stores its complete provisioned typed result and optional preplanned hook-usage id. A generated `branch_summary_prepared` stores its complete result payload before navigation moves; generated compaction has no prepared record. Both are ordinary record payloads and need no backend-specific transaction or index. -- A `tool_batch_started` is one ordinary atomic record whose payload contains the complete source-index/result-id plan. Tool starts and tool usage are later ordinary records; planned results are ordinary entry appends. Backends need no batch transaction or tool-specific backend index. -- Storage linearizes append calls from all lanes of the session; callers never read, reserve, or increment `seq`. Append promises resolve in commit order. The lane mutation line (section 15) serializes decisions; this rule serializes storage underneath them — both are needed, neither replaces the other. -- An append is durable when its promise resolves. Returned `LogItem`s and nested payloads are immutable. Session installs all returned items into live projections only after success, then emits commit events in logical mutation order. No observer sees part of a multi-write append. Process-local message/tool lifecycle events may still precede the entry mutation they announce. +- `append()` accepts one mutation or a non-empty array. Session validates and JSON-checks all input first. The backend validates against intermediate state, applies in order with consecutive `seq` and no interleaving, and commits all-or-none. Later items observe all earlier transactional changes. +- Entry `lane` is routing envelope data, absent from `Entry` and committed `LogItem`. Storage assigns `parentId`, `seq`, and `timestamp` from the lane leaf after prior mutations, then advances the leaf. Positional results preserve routing correlation without persisted ownership. +- Records receive `seq`/`timestamp`; fact and lane `LogItem`s receive sequence positions. `getLog()` expands appends before ordering, cursor, and limit. +- A configured lane's newest `lane_config` is its whole value; setters append one record. Lane create is valid only immediately before that lane's first total config in the same append. `Session.createLane()` emits exactly `[lane create, initial lane_config]` and returns the second item. Neither half is observable. +- Labeled navigation completion requires its exact label fact immediately before `operation_finished` in one append. `finishOperation()` emits `[label fact, operation_finished]`; no interleaving is possible, so the accepted label wins over earlier facts. Unlabeled completion appends only finish. +- Hook structural starts store the complete typed result and optional usage id. Generated `branch_summary_prepared` stores its complete payload before move; compaction has no prepared record. Both need no special transaction or index. +- `tool_batch_started` atomically stores the full source-index/result-id plan. Starts, usage, and result entries are later ordinary writes; no tool-specific backend transaction/index is needed. +- Storage linearizes all lane appends; callers never manage `seq`, and promises resolve in commit order. The lane mutation line serializes decisions while storage serializes commits; both are required. +- Promise resolution means durable append. Returned items are deeply immutable. Session installs them only after success, then emits commit events in mutation order; observers never see a partial array. Process-local lifecycle events may precede their entry. - `Session` and the harness provision ids with `session.idGenerator`; storage enforces per-session uniqueness across the existing state and the full append input. -- Every durable payload must be JSON-serializable. `Session` validates the whole append before dispatch so Memory, JSONL, and SQLite accept the same values; Memory does not retain values JSONL would reject. -- Reads return immutable data. `getEntries(ids)` is one backend call for an exact unique-id set; a backend may chunk internally for parameter limits, but it returns one immutable map containing only existing requested entries. +- Every payload is JSON-serializable. Session validates before dispatch so all backends accept the same values. +- Reads are immutable. `getEntries(ids)` makes one backend call for unique ids; internal chunking is allowed, but one map returns only requested existing entries. - `findOpenOperations` is a required recovery projection: Memory maintains it with its record state, JSONL derives it while replaying the file, and SQLite answers it from the lane's current open-operation projection. It returns unfinished starts newest first and must expose a second result when a replayed/imported backend observes multiple open operations so recovery can reject corruption. Backends with conditional current-state projections may reject a second `operation_started` append instead of creating that corruption through their normal write API. -- No general conditional writes exist. Single-writer plus the lane mutation line make compare-and-set unnecessary for normal appends and pointer/fact updates. The lane open-operation projection is the narrow exception: starting an operation conditionally sets the lane's open operation from `null` to the run id, and a failed update means the lane is already busy. -- One writer per session, enforced by the serving layer; SQLite additionally rejects a second writer itself. Per session, not per backend: one SQLite database hosts many sessions, each with its own single writer. -- Any append failure faults the harness (section 4). Live state and events publish nothing from the failed call. Memory and SQLite roll it back; after a JSONL I/O failure or process death, reopen may observe either the preceding prefix or the whole append, but never only some logical mutations. Recovery treats that all-or-none storage outcome normally. -- Global-fact and lane-move history is kept, never rewritten: latest by `seq` wins. Built-in name and label facts use distinct kinds from application facts; an application key is interpreted only within the `custom` kind. A missing name/label/custom value is a deletion tombstone, while a present JSON `null` is a value. Facts have no ids and share no identity namespace with entries or records. History is the cheaper implementation (insert, never update), and lane-move history is a reflog if anyone ever wants one. -- `close()` first rejects new calls, then waits for every append already admitted to the backend's session-wide queue. Only after that queue drains does it stop renewal and release resources or a writer claim. Release is fenced: an instance may release only the owner/fence pair it acquired, so a stale close cannot release a newer writer. `Session.close()` first closes its own admission and lets already-admitted Session appends settle, then delegates to storage close; `AgentHarness.close()` first stops lane admission and signals process-local effects. No close method writes a session record, finishes an operation, or makes a durable open operation non-resumable. +- No general conditional writes exist. Single-writer plus mutation lines avoid compare-and-set for normal appends and pointer/fact updates. Only operation start conditionally changes the lane's open-operation projection from null to run id; failure means busy. +- One writer per session is serving-layer policy; SQLite also enforces it. One database may host many independently owned sessions. +- Any append failure faults the harness without publishing state/events. Memory and SQLite roll back. After JSONL I/O failure or death, reopen sees the prior prefix or whole append, never part; recovery handles either. +- Fact and lane-move history is append-only; latest `seq` wins. Name, label, and custom kinds are distinct. Omitted values are tombstones; custom JSON `null` is a value. Facts have no ids or shared identity namespace. Lane-move history also serves as a reflog. +- `close()` rejects new calls, drains admitted appends, then stops renewal and releases resources/claim. Fencing allows release only by the acquired owner/fence pair. Session closes admission and settles admitted appends before closing storage; harness first stops lane admission and signals local effects. Close writes no record, finishes no operation, and leaves open operations resumable. - For format-4 sessions, the token and cost fields returned by `getStats()` are the sum of `usage` records across all lanes — one rule, no entry-derived billing, and no double counting by construction. `messageCount` counts all message entries in the session tree, including entries copied into a fork. A fork initializes the count from its copied entries, then increments it for newly appended message entries. Backends maintain both as running projections, so reads and the `usage` event's totals are O(1). Format-3 sessions have no records; their usage stats stay entry-derived. The one-time v4 conversion writes one aggregate `adjustment` record (`details: { source: "v3-import" }`) summing the v3 entries' usage, so totals survive conversion. Outside the ledger's claim: the settle-to-write crash window, unreported mid-stream billing, tools that die without reporting, and extension-private LLM calls (section 1 non-goal) — though `adjustment` records let an application close even those after the fact. ### Memory @@ -2157,11 +2155,11 @@ interface JsonlSessionCreateOptions extends SessionCreateOptions { interface JsonlSessionListOptions { cwd?: string; } ``` -A v3 `parentSession` path resolves to the parent header's id when that file is available. If it is unavailable, metadata retains `legacyParentSessionPath`; first-write conversion preserves that optional header field rather than silently dropping the relationship. Format-4 code uses `parentSessionId` for repository relationships. `modifiedAt` is read from the filesystem and is not a sequenced session mutation. +A v3 `parentSession` path resolves to the available parent header id; otherwise metadata and first-write conversion retain `legacyParentSessionPath`. Format 4 uses `parentSessionId`. Filesystem `modifiedAt` is not sequenced. -The repository layout matches coding-agent v3. Under `sessionsRoot`, each resolved cwd uses a directory named `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`. New files are named `${createdAtIso.replace(/[:.]/g, "-")}_${sessionId}.jsonl`. `list({ cwd })` scans that cwd's directory; `list()` scans every direct child directory. Listing reads only each file's header and filesystem metadata; it does not open or replay the session. A file with a missing or malformed header is omitted from the result. First-write v3 conversion replaces the original file in place and never changes its directory or filename. +Layout matches coding-agent v3. Under `sessionsRoot`, cwd directory is `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; files are `${createdAtIso.replace(/[:.]/g, "-")}_${sessionId}.jsonl`. `list({ cwd })` scans one directory; `list()` scans all direct children. Listing reads only headers/filesystem metadata and omits malformed headers. V3 conversion replaces in place without renaming. -One file per session: a header line, then one physical JSON line per `SessionStorage.append()` call. One logical mutation, including a length-one array input, is written as its ordinary object. Two or more mutations are one non-empty JSON array line containing those same ordinary objects in order; there is no batch id, checksum, length, or other metadata. Physical lines are ordered by their first logical `seq`, and array elements occupy consecutive positions. +Each session file has a header, then one physical line per `SessionStorage.append()`. One mutation, even via a length-one array, encodes as its ordinary object; multiple mutations encode as one ordered JSON array without batch metadata. Lines order by first logical `seq`; array elements are consecutive. ```text {"kind":"header", "version":4, id, createdAt, cwd, parentSessionId?, legacyParentSessionPath?, metadata?} @@ -2178,13 +2176,13 @@ One file per session: a header line, then one physical JSON line per `SessionSto The displayed configured-lane array is one physical line; it is wrapped above only for readability. -- Open reads the whole file into memory; all queries, including one exact-map `getEntries` lookup, run against that state. One session-wide queue serializes append calls from every lane. Each call allocates its consecutive logical positions, serializes one object or one array plus the trailing newline into one buffer, and issues one `appendFile` call. The array overload still returns `LogItem[]` for a length-one input even though its physical encoding is the ordinary object. Replay expands arrays in element order for projections and `getLog()`. -- A complete array line is one transaction. Replay validates every element and all cross-element relationships against temporary state before publishing any; an invalid element, empty array, or complete transaction that violates ordering or references is corruption. A custom fact with `value` omitted is a deletion; `"value": null` is retained as JSON null. -- The repository does not retain created or opened storage instances. It knows how to locate and load sessions, then transfers each storage and its append queue to the returned `Session`. `close()` prevents another enqueue, waits until every previously enqueued append has settled, then releases the storage; no acknowledged or already-accepted append is skipped. Reopening loads a fresh storage instance; the serving layer's single-writer ownership rule prevents concurrent opens for writing. Repository operations are not serialized, so callers await operations with ordering dependencies. -- The optional `lane` on an encoded entry object is routing envelope metadata and dies at decode. `SessionStorage.append()` always supplies it: replay requires the committed `parentId` to equal that lane's current leaf, then advances the lane. A lane-less entry object is reserved for repository-private fork construction and advances no lane; it is not a `SessionMutation` and cannot appear in an append array. Both forms produce the same lane-free entry `LogItem`; entries expose `seq` but no lane. -- Torn tail: a malformed final object or array line is the append that died mid-write. Open discards that whole physical line, so no element of a torn array survives. A malformed interior line, or a complete but invalid object/array transaction, is corruption and open rejects. +- Open loads the file; all queries use that state. One session-wide queue serializes lanes. Each append allocates consecutive positions and issues one `appendFile` with one object/array and newline. The array overload returns `LogItem[]` even at length one. Replay expands arrays for projections and `getLog()`. +- A complete array is one transaction. Replay validates all elements and relationships in temporary state before publication. Invalid/empty arrays or bad ordering/references are corruption. Omitted custom `value` deletes; null remains JSON null. +- The repository locates/loads sessions, then transfers storage and its queue to `Session`; it retains no opened instance. Close rejects enqueue, drains accepted appends, then releases. Reopen creates a fresh instance; serving enforces one writer. Repository operations are not serialized, so callers await dependencies. +- Encoded entry `lane` is routing metadata discarded at decode. Normal append always supplies it; replay verifies `parentId` against that leaf and advances it. Lane-less entries are private fork imports, advance no lane, and cannot appear in append arrays. Both decode to lane-free `LogItem`s. +- A malformed final line is torn and discarded wholly, including every array element. Malformed interior or complete-invalid transactions are corruption. - Durability is process-crash level: a resolved append call. No fsync promise; if power-loss durability is ever needed, it becomes an explicit capability. -- v3 files: entries only, no `kind` tags. Open builds the normalized logical tree from section 12; every entry belongs to `main`, and `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. Before the first format-4 mutation, the file is rewritten once with a v4 header (write temp, rename). This is the single conversion the compatibility policy allows. Read-only opens and close without mutation never rewrite. +- V3 files have untagged entries. Open builds section 12's normalized tree on `main`, whose leaf is the final physical entry resolved to its nearest retained ancestor. The first format-4 mutation rewrites once via temp/rename. Read-only open/close never rewrites. ### SQLite @@ -2212,13 +2210,13 @@ branch_entries: (session_id, branch_id, entry_type, entry_seq) (session_id, entry_id) -- reverse lookup: entry → branches ``` -`records.run_id` stores the effective operation identity used by `RecordQuery.runId`: an `operation_started` row stores its own `id`, an operation-owned row stores its payload `runId`, and an operation-independent row stores null. The `(session_id, lane, run_id, seq)` index therefore returns the whole open slice, including its start, without an `OR` scan. +`records.run_id` is the effective operation identity: a start stores its own id, an owned record its payload `runId`, and independent records null. The index therefore returns the whole open slice, including start, without `OR`. -`writer_leases` enforces one writer per session with expiring, fenced claims. Storage renews the claim inside every append transaction and while idle. After its admitted transaction queue drains, `close()` stops renewal and deletes the claim only with a matching `(session_id, owner_id, fence)` predicate. A stale owner therefore cannot release a replacement writer's claim. Each `append()` is one SQLite transaction: allocate one consecutive sequence range, validate and apply its logical mutations in order, update every projection, and commit all or none. Configured lane creation is therefore the ordinary `[lane create, lane_config record]` append, and the existing record index answers `getLaneConfig`. The facts index returns each latest built-in value, custom JSON value, or deletion tombstone without conflating SQL null with JSON null. +`writer_leases` provides expiring fenced ownership. Storage renews on appends and while idle; after queue drain, close stops renewal and deletes only its matching owner/fence, so stale owners cannot release replacements. Each append transaction allocates one consecutive sequence range, applies mutations/projections in order, and commits all-or-none. Configured lane creation uses ordinary `[lane create, lane_config]`; existing indexes answer config and distinguish fact tombstones, SQL null, and custom JSON null. -`open()` acquires that writer claim. `list()` never acquires or renews writer leases: it reads every matching session directly from the session catalog and projects the latest name fact into the top-level `SqliteSessionMetadata.name` field for server-side inventory. Application-owned `SqliteSessionMetadata.metadata` remains unchanged. +`open()` acquires the claim. `list()` does not: it reads the catalog and projects the latest name into `SqliteSessionMetadata.name` for inventory without changing application `metadata`. -`branch_entries` and `branch_tips` are a private read cache. No interface exposes them; no other backend has them; rebuilding them from parent pointers is an explicit repair operation, never a runtime fallback. +`branch_entries` and `branch_tips` are private SQLite caches. Only explicit repair rebuilds them from parents; runtime never falls back. Two invariants carry the whole design: @@ -2274,7 +2272,7 @@ Case 4 — a branch still ends at an entry that has children. Stale branches (no lane resolves through them) are kept. -Every restore query is an index seek plus a bounded scan or exact lookup: `lanes.open_operation_id` discovers the open operation, `(lane, type, op_kind, seq)` finds the newest run boundary, `(lane, type, seq)` reads still-relevant next-run queue records, `(lane, run_id, seq)` reads the open operation, and `(session_id, id)` serves its one batched entry-plan lookup. Restore does not use `branch_entries`; branch indexes remain for ordinary context and structural preparation. No query touches another lane's traffic. +Restore uses indexed bounded/exact queries: open-operation projection, newest-run index, relevant next-run index, run-id slice, and one id-batched entry lookup. It does not use branch caches or touch another lane; branch indexes serve later context/structural work. SQLite implementation follow-ups: @@ -2285,7 +2283,7 @@ SQLite implementation follow-ups: ## 14. Agent-loop building blocks -`agent-loop.ts` exposes building blocks that own no durable state and know nothing about sessions, records, or lanes. The harness composes them and inserts durability writes between their phases. +`agent-loop.ts` exposes stateless, session-agnostic blocks. The harness composes them with durability writes between phases. ### Streaming one assistant response @@ -2337,7 +2335,7 @@ interface AgentTool { } ``` -Three phases per call are exposed separately because the harness writes `tool_started` between phases 1 and 2 and recovery needs phases 2 and 3 without phase 1. The batch driver owns no durable ids: before calling it, the harness commits one `tool_batch_started` for every source call. The driver keeps passing the original `AgentToolCall` object to its callbacks, letting the harness map it to the source index without adding an index to hooks, events, or tool context: +Calls expose three phases so `tool_started` fits between clearance and effect, and recovery can run effect/finalization without clearance. The batch driver owns no durable ids; the harness plans all calls first. Callbacks receive the original `AgentToolCall`, allowing private source-index lookup without exposing that index: ```ts type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; tool: AgentTool; args: unknown }; @@ -2431,21 +2429,21 @@ export function executeToolBatch( ### Compatibility wrapper -The existing public interface of `agent-loop.ts` does not break. Every export keeps its signature and behavior: `agentLoop`, `agentLoopContinue`, `runAgentLoop`, `runAgentLoopContinue`, `AgentEventSink`, and the config surface they consume (`getSteeringMessages`, `getFollowUpMessages`, `prepareNextTurn`, `shouldStopAfterTurn`, `beforeToolCall`, `afterToolCall`, event order included). They compose `streamAssistant` and `executeToolBatch` with the no-op `TelemetryContext` and omit `ToolCallbacks.executeTool`, so phase 2 uses `executeToolCall()` directly. They add no durability and preserve existing event order and results. The existing `agent-loop` and `agent` test suites pass unchanged. +The public `agent-loop.ts` interface, signatures, behavior, event order, and results remain unchanged for `agentLoop`, `agentLoopContinue`, `runAgentLoop`, `runAgentLoopContinue`, and `AgentEventSink`, including config callbacks `getSteeringMessages`, `getFollowUpMessages`, `prepareNextTurn`, `shouldStopAfterTurn`, `beforeToolCall`, and `afterToolCall`. They compose the new blocks with no-op telemetry and direct phase-two execution, adding no durability. Existing loop/agent tests pass unchanged. ## 15. Harness internals -The code below is the specification of harness behavior, composed from the section 14 blocks. Live calls and resume run the same procedures: `prompt()` runs `runProcedure()` after acceptance; `resume()` runs it with the operation already recorded. Everything is lane-scoped; procedures of different lanes run concurrently and meet only at the storage append path. +The following code specifies behavior using section 14 blocks. `prompt()` and `resume()` run the same procedures after fresh or existing acceptance. Lane procedures run concurrently and meet only at storage append. -Part III adds no new durability semantics over Part II. It adds two mechanisms: the **effects boundary**, which makes every crash site steppable, and the **lane mutation line**, which closes the check-then-act races between a running procedure and the public lane surface. +Part III adds no durability semantics. It implements Part II with a steppable **effects boundary** and a **lane mutation line** that closes check-then-act races. ### The effects boundary -Every effect an operation procedure performs goes through one injected `Effects` handle, `fx`: every durable write, provider request or fetch, individual tool invocation, hook invocation, and timer. In `drive: "automatic"` the handle delegates to the session, provider/tool adapters, and hook runner. In `drive: "manual"` the same handle is wrapped in a gate (below). The effect methods are the complete procedure crash-site catalog: stopping before or after one of these calls is exactly a section 6 X state. +Every procedure write, provider request/fetch, individual tool invocation, hook, and timer crosses injected `Effects` (`fx`). Automatic mode delegates; manual mode gates the same handle. Its methods are the complete crash-site catalog: each before/after boundary is a section 6 state. -Explicit lane-surface mutations are the deliberate exception to gating. Acceptance, queue/configuration calls, lane-view writes, abort, and configured lane creation enqueue directly on the same lane FIFO used by `Effects`; otherwise a parked operation could not be steered or aborted. Pre-acceptance `before_run` still invokes `fx.runHook`, but the eventual acceptance mutation is ungated. In manual mode that hook can therefore be the lane's next action before an operation record exists. +Lane-surface mutations deliberately bypass gating: acceptance, queue/config calls, lane-view writes, abort, and lane creation use the same lane FIFO directly, allowing control while parked. Pre-acceptance `before_run` still crosses `fx.runHook`; its later acceptance is ungated, so manual mode may expose a hook before any operation record. -Procedures also receive a read-only `ProcedureRuntime`: reduced `LaneState` and planned entries, branch/context readers, an injected id generator, environmental model/tool identity resolution, and the passive event sink. These operations do not write, wait on an external effect, invoke a provider/tool/hook, or enter the manual action queue. In particular, a procedure never receives `Session`, `Models`, a tool registry, or a hook runner and never calls one directly. Identity resolution happens immediately before the hook, provider, or tool path that needs it; abort-only and synthetic paths do not resolve identities. +Read-only `ProcedureRuntime` supplies reduced state/planned entries, branch/context readers, ids, environmental identity resolution, and passive events. These do not write, wait externally, invoke effects, or gate. Procedures never receive Session, Models, tool registries, or hook runners directly. Resolve identities only immediately before their hook/provider/tool path; synthetic and abort-only paths resolve none. ```ts type StructuralStepStartIntent = @@ -2564,7 +2562,7 @@ Rules: ### The lane mutation line -Every race in this design has one shape: a decision is made from lane state, an `await` passes, then a durable write commits the stale decision. The fix is structural. Each lane has one process-local FIFO — a promise chain — and every state-dependent decision commits inside one job on it: +Races arise when an `await` separates a state decision from its write. Each lane therefore has one process-local FIFO, and every state-dependent decision commits inside one job: ```ts let tail: Promise = Promise.resolve(); @@ -2576,7 +2574,7 @@ function mutateLane(job: () => Promise): Promise { } ``` -A job is: validate against live `LaneState` → at most one `Session.append()` call → install every returned logical mutation in order → publish commit events in that order. The append normally contains one logical mutation; configured lane creation and labeled navigation completion contain two. Provider requests, tool executions, hooks, and backoff never run inside a job; they run between jobs, which is exactly why every commit revalidates inside its own job. Because jobs run one at a time, two concurrent operations on a lane have exactly two possible histories — `[A, B]` or `[B, A]` — and both are defined outcomes. No third, interleaved history exists. +A job validates live `LaneState`, makes at most one `Session.append()`, installs returned mutations, then publishes commit events in order. Appends normally contain one mutation; lane creation and labeled-navigation finish contain two. External effects and backoff run between jobs, so each commit revalidates. Concurrent jobs produce only `[A,B]` or `[B,A]`, never interleaving. The jobs, by caller: @@ -2635,7 +2633,7 @@ The complete list. Each row names the two legal histories and the jobs that forc | 11 | cross-lane writes | any interleaving | storage `seq` linearization (section 13); lanes share no state | | 12 | `cancelQueued` vs consumption | consumed first: `already_consumed` · cancelled first: consumption skips, the model never sees it | cancel job + `consumeQueueItem` | -Row 10 is the one race no ordering can remove: an external effect may have happened even though its result never arrived. The design's answer is the section 5 intent record plus the replay policy — the same answer as for a crash. +Row 10 is irreducible: an external effect may occur without a returned result. Section 5 intent plus replay policy handles it like a crash. ### Drive modes @@ -2718,18 +2716,18 @@ class GatedEffects implements Effects { The public controls, on the lane (section 8): -- `peekAction()` resolves with the description of the next parked call. It also sees a pre-acceptance `before_run` hook, when no operation record exists yet. It returns `undefined` only when no call is parked and no admitted operation or pre-acceptance call can produce another action. No side effect; calling it twice returns the same action. -- `executeAction()` removes and starts exactly the parked call `peekAction()` describes; it does not await that call while hiding descendants. It waits until the released call settles, the local operation settles, or a nested action arrives, then returns the next parked action or `undefined`. It never releases two actions. -- `runToCompletion()` repeats that algorithm, releasing nested actions before waiting for their parents, until the operation or pre-acceptance call settles. +- `peekAction()` describes the next parked call, including pre-acceptance `before_run`. It returns `undefined` only when no parked or admitted work can produce an action. It is stable and side-effect free. +- `executeAction()` starts exactly the peeked call. It waits until that call or operation settles, or a nested action arrives, then returns the next action or `undefined`; it never hides descendants or releases twice. +- `runToCompletion()` repeats this, releasing nested actions before awaiting parents, until the operation or pre-acceptance call settles. - Two concurrent drivers are a programmer defect, as is calling the controls in automatic mode. Semantics that make tests deterministic: -- The gate is reentrant. A released action may call another `fx` method — notably `transform_context`, `before_payload`, and `after_response` hooks reached inside `stream_assistant`. The nested call parks as its own action. The driver observes and releases it before the outer action can continue; it never waits for the outer action while hiding the nested park. Every hook therefore remains an independent crash boundary without deadlocking manual drive. -- The gate serializes. Parallel tool batches issue phase-2 calls in source order (phase 1 is sequential, section 14); the gate parks them as separate `execute_tool` actions and manual mode runs them one at a time. Parallelism is a production optimization; source-ordered finalization already fixes the semantics, so automatic and manual modes produce the same durable log. -- The lane surface stays ungated. While the procedure is parked, a test calls `steer()`, `abort()`, `session.appendMessage()` — their jobs run on the mutation line immediately. Both orders of every race-catalog row are constructed by choosing whether to call the surface method before or after `executeAction()`. -- The first abort job also calls `abortBeforeStart()` on this lane's unreleased provider, fetch, tool, and sleep actions. None executes. Their existing attempt/tool intent remains for `abortPath` to settle; a parked sleep returns `"aborted"`. An external-effect wrapper enqueued after the marker observes the already-aborted signal and rejects/resolves the same way without parking, closing the small interval after a conditional intent commit. An already-released effect is not removed: it receives the ordinary abort signal and its real settlement races the marker. Hooks and durable writes are not discarded; their conditional follow-up commits revalidate the marker. Repeated abort performs no second gate cancellation. -- `close()` while parked: every parked call rejects with `HarnessClosed`, the local operation promise rejects, nothing else commits. The durable state is exactly the prefix of released effects — the definition of a crash site. Reopen the backend and `resume()` runs ordinary section 7 recovery. In automatic mode `close()` stops admission, signals the in-flight effect, lets the active Session write settle, drains the storage queue, and releases only its matching writer claim; open operations stay resumable either way. +- The gate is reentrant. Nested `fx` calls, notably request hooks inside `stream_assistant`, park independently. The driver releases them before their parent can continue, preserving each crash boundary without deadlock. +- The gate serializes source-ordered phase-two tool calls as separate actions. Manual mode runs them one at a time; automatic parallelism changes no durable log because finalization is source ordered. +- The lane surface remains ungated. While parked, `steer()`, `abort()`, and `session.appendMessage()` run immediately on the mutation line. Calling them before or after `executeAction()` constructs both race orders. +- First abort calls `abortBeforeStart()` on unreleased provider/fetch/tool/sleep actions, so none executes; their intent remains for reconciliation, and sleep returns `"aborted"`. Wrappers enqueued after the marker reject or resolve as aborted without parking or executing the external effect. Released effects instead receive normal cancellation and race the marker. Hooks/writes remain, with conditional commits revalidating abort. Repeated abort does not cancel again. +- Close rejects parked calls and local operation promises without further commits, leaving exactly the released-effect prefix for ordinary reopen/resume. Automatic close stops admission, signals in-flight work, settles admitted Session writes, drains storage, and releases its matching writer claim. Open operations remain resumable. ### Live lane state @@ -2982,7 +2980,7 @@ async function handleRunSignal(e: unknown): Promise { ``` -**Fixed-point test invariant.** Focused and manual-drive tests freshly execute the section 7 bounded reads and pure reduction after each durable boundary, suspension, and finish, then compare the result with live `LaneState`. Production does not perform this reread; live commits update state directly. +**Fixed-point test invariant.** After each durable boundary, suspension, and finish, focused/manual tests run section 7 reads and reduction and compare with live `LaneState`. Production updates state directly without rereading. ### The loop @@ -3193,11 +3191,11 @@ async function assistantStep(): Promise { } ``` -`continuableAssistantStep` returns the existing assistant step only when its newest attempt still needs settlement, usage repair, classification, or a numbered retry. Once that response was accepted and a newer user/tool-result message now requires generation, it returns `undefined`, so `startStep` snapshots a new trigger/configuration instead of reclassifying the old response. This distinction is derived from reduced records and planned entries, not a persisted continuation flag. +`continuableAssistantStep` returns an existing step only while its newest attempt needs settlement, usage repair, classification, or retry. After acceptance and a newer user or tool-result message, it returns `undefined`, so `startStep` snapshots a new trigger/config rather than reclassifying. Reduced records/entries, not a continuation flag, determine this. -The retry helpers preserve the public lifecycle exactly. A durable retryable response or unknown provider effect below the captured cap emits `retry_scheduled` for the next number, then the captured delay crosses `fx.sleep`. If abort cancels that delay, no event for an unstarted attempt follows. After `startAttempt` commits, the later attempt emits `retry_start` and stores only a process-local open-bracket bit; after its response entry and usage are durable and classified, it emits `retry_end` and clears that bit. If marker-backed abort instead settles or closes that started attempt, `emitRetryEndIfActive` closes the same bracket. A retryable later response ends its bracket unsuccessfully before scheduling the following number. First-attempt success emits none of these events. Resume may re-emit a schedule whose earlier process-local event was lost, but it never fabricates `retry_end` without a `retry_start` from that process. +Retry lifecycle remains exact. A durable retryable response or unknown effect below cap emits `retry_scheduled`, then waits through `fx.sleep`; abort during delay emits nothing for the unstarted attempt. After intent commits, that attempt emits `retry_start`; response, usage, and classification precede `retry_end`. Marker-backed abort closes an active bracket. A retryable attempt ends unsuccessfully before scheduling the next. First-attempt success emits none. Resume may re-emit a lost schedule but never `retry_end` without this process's `retry_start`. -`classifyDurableAssistantResponse` is the pure section 6 classifier. Its overflow predicate reads only the durable response plus `intendedOutputLimit` and `contextWindow` on the attempt: explicit context-limit error patterns, a `stop` response whose reported input plus cache-read tokens exceeds the captured window, the existing Xiaomi-compatible zero-output/full-window signal, and recoverable `length`. The classifier recognizes an abort marker and any already-linked transition before returning a new ordinary one. With no marker it evaluates overflow first, then treats `aborted` as a retryable provider interruption under the captured policy, then evaluates retryable errors. At the cap an unmarked `aborted` response returns failure, never abort. It creates no durable metadata. Provider-context projection independently omits every `error`, `aborted`, and `deferred` assistant response; genuine `length` remains, while exact overflow omission is materialized by the linked compaction. +`classifyDurableAssistantResponse` implements section 6 using only the response and attempt limits: context-error patterns, reported input/cache-read beyond the window, Xiaomi zero-output pressure, and recoverable `length`. Existing abort/linked transitions win; otherwise overflow precedes unmarked-`aborted` interruption and retryable error. At cap, unmarked `aborted` fails, never aborts. The classifier writes no metadata. Projection separately omits `error`, `aborted`, and `deferred`, retains genuine `length`, and relies on linked compaction for exact overflow omission. Generated request construction reads the `LaneConfiguration` and normalized `RetryPolicy` on `step_started`, never newer harness or lane values. `summaryStep(kind, reason, resultEntryId, overflowLink?)` accepts only a generated structural source whose start has the typed result id, captured configuration and policy, and compaction reason when applicable. An overflow compaction requires the link, persists both fields on that start, and builds summary preparation and retained-tail data with `overflowLink.supersededResponseEntryId` omitted; other structural steps reject a link. @@ -3278,11 +3276,11 @@ async function summaryStep( } ``` -The `step_attempt` is durable before the first provider request of an attempt. One attempt may make one or two non-deferred requests, and every individual request crosses `fx.streamAssistant`; each reported usage write immediately follows that request. Structural streams use a private event sink, so none emits public assistant-message lifecycle. A crash anywhere before the structural result boundary makes the whole attempt unknown and starts a later number only under the captured policy. +Structural `step_attempt` precedes its first request. An attempt may make one or two non-deferred requests; each crosses `fx.streamAssistant` and immediately writes reported usage. Its private sink emits no public assistant lifecycle. A crash before the result boundary makes the whole attempt unknown and advances only under captured policy. -Generated compaction returns the complete result in memory and the caller immediately attempts its result-entry commit under `step_started.resultEntryId`; there is deliberately no prepared-result record. Generated branch summary instead calls `fx.prepareBranchSummary` with the complete provisioned `BranchSummaryEntry`, `fromHook: false`, and the successful attempt number before navigation may move. A crash after `branch_summary_prepared` never starts another provider request. +Generated compaction immediately tries to commit its in-memory result under `step_started.resultEntryId`; no prepared record exists. Generated branch summary first persists its complete provisioned entry, `fromHook: false`, and successful attempt number via `fx.prepareBranchSummary`. After that record, navigation may move and no provider request repeats. -At the cap, or after a terminal provider failure, `failStructuralStep` conditionally appends `step_failed` and throws `RunFailed`. An abort marker that predates the compaction-entry/navigation-move commit makes that conditional return `"aborted"`, so no `step_failed` is written. The procedure never writes an assistant error into a compaction or branch-summary id. A hook-supplied result takes a separate source path: after the decision hook returns, the harness constructs the complete provisioned typed entry, including `fromHook: true`, preparation fields, details, and immutable usage snapshot, and conditionally commits it on `step_started` with a usage-record id exactly when usage exists. Re-entry repairs that exact `cause: "hook"` record before entry commit and never reruns the hook. For overflow, this hook-sourced compaction start also carries the exact response/trigger link, so it consumes the same-trigger allowance. +At cap or terminal failure, `failStructuralStep` conditionally appends `step_failed` and throws `RunFailed`; if abort wins before that conditional append, it returns `"aborted"` and writes no failure. Assistant errors never occupy structural result ids. For hook output, the harness builds the complete typed entry with `fromHook: true`, preparation, details, and immutable usage, then stores it on `step_started` with a usage id exactly when needed. Re-entry repairs that `cause: "hook"` record before entry commit and never reruns the hook. Overflow hook starts also carry the exact response/trigger link and consume the same allowance. ### Deferred redemption @@ -3356,11 +3354,11 @@ async function redeemDeferred(): Promise { `classifyDurableDeferredResponse` uses the configuration and policy copied onto F and the exact source lineage. It performs the same complete-handle check for a durable pending response before making that response the new source. A below-cap unmarked `aborted` response is itself the durable suspension transition but retains its source; a capped one is terminal failure. Neither becomes operation abort without the marker. -At most one fetch runs per `resume()`, and it is a check-once request with `wait: 0`. A pending poll appends its distinct response and usage before re-parking on that response, including when its handle equals the source handle. An unmarked interrupted poll does the same but retains its exact source; the next `resume()` applies captured backoff and may make one new attempt below the per-source attempt cap. Neither path emits retry lifecycle events. A ready response uses F's copied active tool names for clearance and execution. A terminal answer — returned or converted from a rejected fetch — lands as the error entry and fails the run through the normal drain path, which still honors input accepted before the failure (section 6). If a crash leaves a poll attempt without its response and no abort marker exists, a later resume provisions the next numbered attempt rather than reusing either preplanned id when below that cap; at the cap it settles the missing id with a synthetic interruption error and fails. With abort, recovery writes synthetic `aborted` under the missing poll's existing response id and does not fetch. +One `resume()` makes at most one `wait: 0` fetch. Pending appends response/usage and re-parks on the new entry, even with an equal handle. Unmarked interruption also persists but retains its source; a later resume backs off and may retry below the per-source cap. Neither emits retry events. Ready uses F's active tools. Returned/rejection-converted terminal errors enter normal failure drain. An unknown poll effect uses a later attempt below cap or synthetic interruption under the missing attempt's planned response id at cap; marker-backed abort instead writes synthetic `aborted` there and never fetches. ### Tools -The live path commits the complete batch plan before calling section 14 `executeToolBatch`; the batch itself is never one gated action. Provider `toolCallId` values are unique within the assistant response by contract. The helper still passes the original source call object to callbacks so `plannedCallFor` can locate its source-ordered planned result without adding an index to hooks, events, or tool context. For an ordinary batch, `runtime.identities.requireToolsForResponseStep` reads the durable step whose attempt provisioned the response and resolves only its captured active names: an assistant response uses its generation snapshot, while a fetched response uses the deferred step's copied active names. A genuine output-limit `length` batch resolves no tool implementations because it performs no clearance or invocation. Every durability callback, hook, and individual phase-two effect then routes through `fx`: +Live execution commits the full plan before `executeToolBatch`; the batch is never one gated action. Provider `toolCallId` values are response-unique. Callbacks retain the source call object so private lookup finds its planned result without exposing an index. Ordinary batches resolve only active names captured by the response's assistant step or copied fetch step. Genuine `length` resolves no tools. Every callback, hook, and phase-two call crosses `fx`: ```ts async function runToolBatch(assistant: AssistantMessage, telemetryContext: TelemetryContext): Promise { @@ -3416,7 +3414,7 @@ async function runToolBatch(assistant: AssistantMessage, telemetryContext: Telem } ``` -Ordinary `runProcedure` re-entry calls `reconcileToolBatch` for any reduced plan with missing results; there is no separate recovery dispatcher. It handles each planned call at its section 6 site in source order. `runPlannedToolCall` composes `prepareToolCall`, the `tool_started` append, `fx.executeTool`, `finalizeToolCall`, optional usage, immediate tool-result message lifecycle, and the planned result append for one call; it allocates no id. A real result keeps that finalized execution's own usage, while a synthetic has none; earlier usage records remain ledger-only. Thus a call with no start reruns clearance, while a started call never reruns clearance or derives arguments again: +Ordinary re-entry invokes `reconcileToolBatch` for missing results; no recovery dispatcher exists. In source order, `runPlannedToolCall` composes clearance, start record, effect, finalization, optional usage, immediate message lifecycle, and planned append without allocating ids. Real results show only that execution's usage; synthetics show none and prior usage stays ledger-only. Unstarted calls rerun clearance; started calls never do: ```ts async function appendReconciledToolResult(target: ProvisionedEntry): Promise { @@ -3522,11 +3520,11 @@ async function abortPath(): Promise { } ``` -Neither helper resolves runtime model/tool implementations. The synthetic assistant uses the captured model reference and the planned tool results use stored calls and ids. `bestEffortCancelDeferred` calls `fx.cancelDeferred(source)` only when the environmental capability can be resolved and suppresses expected provider cancellation failure after telemetry; `HarnessClosed` and a harness fault still unwind. No abort path calls `newId()` for an assistant response. +Neither helper resolves model/tool implementations. Synthetic assistants use captured model references; tool results use stored calls/ids. Best-effort deferred cancellation runs only when resolvable and suppresses expected provider failure after telemetry; close/fault still unwind. Abort never calls `newId()` for an assistant response. ### Close -Close is process lifecycle, not operation abort. It writes no `abort_requested` or `operation_finished` and does not run `abortPath()`: +Close is process lifecycle, not abort: it writes no marker/finish and never runs `abortPath()`. ```ts async function closeHarness(): Promise { @@ -3542,7 +3540,7 @@ async function closeHarness(): Promise { } ``` -A provider or tool effect that returns because of the close signal cannot append its response/result after admission closes; the corresponding prior attempt or tool-start intent remains the durable crash prefix. An already-entered Session append is allowed to settle and all of its live-state updates complete before `Session.close()`. Manual close rejects every unreleased action without running it. Reopen reduces the same prefix and ordinary `resume()` continues it. No shutdown-only record or recovery procedure exists. +A close-signalled provider/tool cannot append after admission closes; prior intent remains the crash prefix. Already-entered Session appends and live-state updates settle before Session close. Manual close rejects unreleased actions without running them. Reopen reduces the prefix and ordinary resume continues; no shutdown record/procedure exists. ### Structural operations @@ -3732,7 +3730,7 @@ async function handleStructuralSignal(e: unknown) { } ``` -Hook-to-block wiring, in one table: +Hook wiring: | harness hook | insertion point | |---|---| @@ -3751,16 +3749,16 @@ Hook-to-block wiring, in one table: Notes: -- Auto-compaction inside a run runs under the run's own records; no nested operation. -- There is no persisted program counter for "crashed mid-step." Without abort, an assistant attempt without its response starts a later numbered attempt or receives a synthetic interruption at the cap; with abort it receives synthetic `aborted` under that same attempt id. A generated compaction attempt without its result entry and a generated branch-summary attempt without `branch_summary_prepared` start a later attempt or close with `step_failed`, unless pre-commit abort closes the operation first. Hook-source steps never repeat their decision hook. -- Parallel batches and crash sites compose: the complete `tool_batch_started` precedes phase 1; real calls then get source-ordered `tool_started` records immediately before their individually gated dispatches. A crash may leave several started effects, including starts beyond an earlier unresolved or immediate call, but committed results are always a source-order prefix. Section 6 reduces every planned source index independently. -- Every assistant message with `stopReason: "aborted"` skips tool execution. With an earlier abort marker, `abortPath()` owns any missing planned results and never creates an assistant response id. Without the marker, assistant/fetch classification retries under captured policy or fails at the cap; it never enters `abortPath()`. -- Abort before a compaction entry or navigation move suppresses that structural commit and finishes aborted. Abort after it leaves the committed structure in place and completes its remaining writes with outcome completed. -- A crash between the navigation move and summary entry retains the complete hook result on `step_started` or generated result on `branch_summary_prepared`. Recovery appends that exact payload and never invokes the hook or provider after the move. +- Auto-compaction uses the run's records, not a nested operation. +- No program counter marks mid-step crashes. An assistant response missing without abort advances attempts or gets synthetic interruption at cap; with abort it gets synthetic `aborted` under the same planned response id. Missing generated structural results advance or end in `step_failed` unless pre-commit abort wins. Hook decisions never repeat after their start. +- Plans precede phase 1; real calls get source-ordered starts before individual dispatch. Crashes may leave starts beyond unresolved calls, but committed results form a source-order prefix. Section 6 reduces each index independently. +- Every `aborted` assistant skips tools. A marker routes missing planned results through `abortPath()` without new assistant ids; without one, classification retries or fails at cap. +- Abort before structural commit suppresses it and finishes aborted; abort after commit completes remaining writes as completed. +- After a summarized navigation move, recovery appends the exact hook/prepared summary and never invokes hook/provider. ## 16. pi-ai: deferred requests -The pi-ai deferred request, fetch, cancellation, and authenticated `Models` dispatch APIs below are already landed. Harness package H8 integrates them; it assigns no new work to `packages/ai`. +These pi-ai deferred/authenticated Models APIs are already landed. H8 only integrates them; it adds no pi-ai work. Everything is per-request; batch APIs can implement the same shape through a custom provider. @@ -3846,9 +3844,9 @@ export interface ProviderStreams { } ``` -`ProviderRequestOptions.telemetryContext` is inherited by `StreamOptions`, `SimpleStreamOptions`, `DeferredFetchOptions`, `DeferredCancelOptions`, and `ImagesOptions`; provider, `Models`, `ImagesModels`, and direct stream/image dispatch preserve it unchanged. `buildBaseOptions()` also preserves it when built-in `streamSimple()` implementations convert to provider-specific stream options. +All stream, deferred, and image options inherit `ProviderRequestOptions.telemetryContext`; providers, Models, ImagesModels, direct dispatch, and `buildBaseOptions()` preserve it unchanged. -`pending` is internal to a mutable live-stream message. Request-wrapper results use `SettledAssistantMessage`; harness-written entries, durable usage records, and settled `pi.ai.request` spans cannot contain `pending`. Telemetry normalizes terminal `toolUse` to `tool_use`. +`pending` exists only in mutable live streams. Wrapper results and harness entries use `SettledAssistantMessage`; durable usage records and settled `pi.ai.request` spans cannot contain `pending`. Telemetry spells `toolUse` as `tool_use`. The harness uses the authenticated `Models` dispatch surface rather than talking to a provider object directly: @@ -3865,19 +3863,19 @@ interface Models { } ``` -`Models.fetchDeferred` and `Models.cancelDeferred` delegate to the provider methods with normal model resolution and authentication (credential store, expiring tokens, header merge). Their options carry the normal HTTP request settings, lifecycle callbacks, and model transforms; fetch options additionally carry the provider long-poll duration. A provider that returns `stopReason: "deferred"` must implement fetch; cancellation is best effort. The harness passes `wait: 0` on every redemption, so one `resume()` checks once and re-parks when the result is still pending; the application schedules another resume and may use the persisted `pollAfterMs` hint. +Models deferred methods use normal model resolution/authentication and preserve HTTP settings, callbacks, transforms, and fetch wait. Providers returning `deferred` must implement fetch; cancel is optional. Harness redemption always uses `wait: 0`; pending re-parks for an application-scheduled resume, optionally using `pollAfterMs`. -A terminal fetch answer is final for the run: the harness appends the error message and fails the operation, never starts an automatic replacement request, and converts a rejected fetch promise into the same `stopReason: "error"` message form so expected provider and authentication failures stay in-band. A returned `aborted` response without `abort_requested` is not terminal until the copied interruption cap: it re-parks on the persisted source handle and a later `resume()` may poll once more. +A terminal fetch appends an error and fails without replacement generation; rejected fetches convert to the same in-band error message. Unmarked returned `aborted` re-parks on its source below the copied cap, allowing one later poll per resume. -On a returned still-deferred message, **complete handle equality** means equal `provider`, `modelId`, `api`, and `id`, equal presence and value for `expiresAt` and `pollAfterMs`, and JSON-deep-equal `data` with equal presence. Object key order is irrelevant; array order is not. The harness persists and accounts the pending response first, then applies this check during ordinary classification. Absent an abort marker, a mismatch is a durable invalid prefix and a harness defect; handle rotation is not supported. Equal handles still produce distinct response entries, and the newest such entry becomes the next attempt's `sourceEntryId`. +For another `deferred` response, **complete handle equality** requires equal `provider`, `modelId`, `api`, and `id`; equal presence/value of `expiresAt` and `pollAfterMs`; and equal presence plus JSON-deep equality of `data` (object order ignored, array order preserved). Persist/account first, then check during classification. Unmarked mismatch is a durable defect; handle rotation is unsupported. Equal handles still create distinct entries, newest becoming the next source. Deferred assistant messages carry a handle, not content. Session context projection omits them from provider context; durable suspension and redemption use the persisted handle. -Stop-reason normalization is the adapter's job, and the harness branches only on the normalized value. Provider adapters also guarantee that `toolCallId` is unique within one settled assistant response; core tool orchestration assumes that invariant and adds no duplicate-id handling. For OpenAI Responses: `incomplete_details.reason === "max_output_tokens"` maps to `stopReason: "length"`; `content_filter` maps to a non-retryable `stopReason: "error"`. Adapters may retain the provider's reason as `rawStopReason` for diagnostics; core logic never reads it. +Adapters normalize stop reasons and guarantee response-local unique `toolCallId`; core adds no duplicate handling. OpenAI Responses maps `max_output_tokens` incomplete details to `length` and `content_filter` to non-retryable `error`. Adapters may retain `rawStopReason`; core ignores it. ## 17. Forks and subagents -One copy primitive on the session repository. A fork first captures one immutable source snapshot containing the selected committed entries, latest visible facts, current lane pointers, and current total lane configurations at the same source prefix. Memory and JSONL capture it as one job on the source mutation queue; SQLite uses one read transaction. Writes ordered after that snapshot are absent from every copied category, so a fork cannot combine an old pointer with a newer configuration or fact. +Repository `fork` captures one immutable snapshot of selected committed entries, latest facts, lane pointers, and total configs. Memory/JSONL use one source-queue job; SQLite uses one read transaction. Later writes are absent from every category, preventing mixed-time copies. ```ts type ForkOptions = @@ -3888,28 +3886,28 @@ repo.fork(source, options & { id?, parentSessionId? }): Promise; repo.create({ id?, parentSessionId? }): Promise; ``` -- Conversation entries are copied without their source lanes, including durable assistant responses that provider-context projection omits. No operation, queue, step, tool, classification, or usage records are copied. A copied compaction entry is self-contained: its `retainedTail` already excludes any exact overflow response that the source compaction superseded, so that omission survives without copying the link record. A fork point before that compaction has no copied overflow transition and uses the ordinary entry-local projection rules: `error`, `aborted`, and `deferred` assistant messages are omitted, while `stop` and general `length` messages project. The fork starts idle and its token and cost statistics start at zero — cost belongs to the session that incurred it; entry usage snapshots still display. Its `messageCount` is initialized from all copied message entries. -- Lanes: `scope: "branch"` → the fork has only `main`, at the fork point, with a fresh total `lane_config` equal to source `main`'s configuration in the captured snapshot. `scope: "tree"` → every lane name and snapshot leaf pointer is copied, and each receives a fresh total record equal to that source lane's configuration in the same snapshot. Each destination pointer and first config are established through atomic configured-lane creation. These are new records in the fork, not copied record history, and configuration is never derived from the copied anchor entry. No other lane records are copied, so every forked lane is idle. -- Facts: `scope: "tree"` copies the current name, every current label, and every current custom application fact. `scope: "branch"` copies the current name and all current custom facts, but only labels whose target entry was copied. Deleted names, labels, and custom facts are absent; JSON-null custom facts remain values. The destination writes fresh fact history with no source fact ids because facts have no ids. -- The fork point may be any message entry. A copy whose tip sits mid-tool-batch is still promptable: pi-ai's transformMessages inserts synthetic empty results for orphaned tool calls at request build time. -- The source is untouched; copying while it runs reads only the committed prefix captured by the coherent snapshot. An open operation's already-committed entries may be copied, but its records and promised future entries are not. +- Copy conversation entries without source lanes, including non-projecting responses, but no orchestration/classification/usage records. A copied compaction's self-contained tail preserves exact overflow omission without its link. A fork before it uses entry-local projection: omit `error`/`aborted`/`deferred`, retain `stop`/general `length`. The fork is idle with zero token/cost ledger, visible entry usage snapshots, and `messageCount` from copied messages. +- `scope: "branch"` creates only `main` at the fork point with a fresh config equal to snapshot source `main`. `scope: "tree"` copies every lane name/leaf and gives each its snapshot config. Atomic configured-lane creation writes new destination records, never source history or anchor-derived config. No other lane records copy, so all lanes are idle. +- Tree scope copies current name, labels, and custom facts. Branch scope copies name/custom facts and labels only for copied targets. Deletions stay absent; custom JSON null remains. Destination writes fresh history; facts have no ids. +- Any message may be the fork point. A mid-tool-batch tip remains promptable because pi-ai inserts empty results for orphaned calls at request build. +- Forking leaves the source untouched and copies only its coherent committed prefix, including committed open-operation entries but not its records or promised output. - Linkage is `parentSessionId`, set by `fork()` and settable on `create()` — the basis for subagent parent/child tracking and export bundles. -- **Non-normative application example.** `AgentHarness` has no built-in subagent tool. An application implementing one may derive a child session id from its parent session id plus the provider `toolCallId`: a safe replay can then reopen the same child instead of spawning a twin. No core record, reducer rule, tool API, or invariant depends on this convention. +- **Non-normative example.** Harness has no subagent tool. An application may derive a child session id from parent id plus provider `toolCallId`, so safe replay reopens rather than duplicates it. Core does not depend on this. - Policy, restated from Part I: a platform thread that shares history with its channel is a lane; a fork is for isolation — subagents, exports, clones. A subagent can also run on a lane of its parent's session when isolation is not wanted. ## 18. Telemetry -Telemetry uses explicit context propagation. Core code does not use `AsyncLocalStorage`, global current-span state, or runtime-specific context APIs: pi runs in Node, Bun, browsers, and workers, so no runtime's ambient-context mechanism can be the core abstraction. An adapter may use ambient context internally — for example, an OpenTelemetry adapter may activate its native child context so HTTP auto-instrumentation attaches correctly — but pi always passes the parent explicitly. +Telemetry passes context explicitly; core uses no `AsyncLocalStorage`, global current span, or runtime-specific context. Adapters may activate ambient context internally, for example for OTel HTTP instrumentation, but pi always supplies the parent. -Pi ships no exporter and requires no backend-specific telemetry implementation. It does ship `InMemoryTelemetryContext` as the deterministic backend-neutral reference implementation; applications may use it for process-local capture or supply a `TelemetryContext` adapter that bridges spans into OTel, Sentry, logs, or another backend. The adapter is trusted to obey the callback contract below. It owns backend ids and native context objects; core never carries trace-id plumbing. +Pi ships no exporter. `InMemoryTelemetryContext` is the deterministic reference; applications may use it or bridge `TelemetryContext` to another backend. Adapters own backend ids/native contexts and must obey the callback contract; core carries no trace ids. ### Package ownership -The generic contract, schema-definition machinery, shared no-op, and in-memory reference implementation live under `packages/telemetry/src/` and are exported from `@earendil-works/pi-telemetry`. The runner-independent conformance cases live under `packages/telemetry/src/testing/` and are exported from `@earendil-works/pi-telemetry/testing`. Pi-ai imports only `TelemetryContext` for request options; it owns no span schema or helper and emits no telemetry itself. `packages/agent/src/harness/telemetry.ts` owns both `AI_TELEMETRY_SCHEMA` / `startAiSpan()` and `HARNESS_TELEMETRY_SCHEMA` / `startHarnessSpan()`, plus the readonly `AGENT_TELEMETRY_SCHEMAS` tuple that composes their typed vocabularies without merging their schema data or versions. The agent package root re-exports those domain schemas, helpers, tuple, and the generic telemetry surface. There is one generic contract and one domain-schema owner. +`@earendil-works/pi-telemetry` owns/exports the generic contract, schema machinery, no-op, and memory reference; `/testing` exports runner-independent conformance. Pi-ai imports only `TelemetryContext` for options and emits no spans. Agent `harness/telemetry.ts` owns AI/harness schemas and starters plus their readonly composition tuple. Agent root re-exports them and the generic surface: one generic contract, one domain-schema owner. `AgentHarnessOptions.telemetryContext` defaults to the no-op context, and the agent-side request wrapper emits `pi.ai.request` through the agent-owned AI schema. -Both schemas are pi-owned. Span names use the `pi.ai.*`, `pi.harness.*`, and `pi.session.*` families; attributes use the same pi-owned `pi.*` vocabulary and do not adopt an external semantic-convention namespace. Adapters translate them when useful; the emitted pi vocabulary remains stable regardless of backend convention churn. +Schemas use pi-owned `pi.ai.*`, `pi.harness.*`, `pi.session.*`, and `pi.*` attributes, not external semantic conventions. Adapters may translate; emitted vocabulary stays stable. ### Context contract @@ -3949,7 +3947,7 @@ interface TelemetrySpan extends TelemetryContext { } ``` -The telemetry package exports the shared no-op context and the deterministic in-memory reference context. The harness and compatibility wrapper select the no-op when no application context is supplied. Under the context contract, `startSpan()` creates the child and invokes its callback synchronously, exactly once, before returning a promise. It keeps the span open until the callback's value or promise settles: +Telemetry exports shared no-op and memory contexts; harness and compatibility wrapper default to no-op. `startSpan()` creates a child and invokes its callback synchronously exactly once before returning a promise, keeping the span open until settlement: - return or resolve: default status `ok`, then automatic end; - synchronous throw: return a promise rejected with the same thrown value, after automatic error status and end; @@ -3959,9 +3957,9 @@ The telemetry package exports the shared no-op context and the deterministic in- - `setAttributes()` merges keys; a later defined value overwrites an earlier one and `undefined` is ignored; - calls on a settled span are inert and never throw. -Adapters preserve the callback's result and error. Their recording methods are synchronous, passive, and must not throw; asynchronous exporters buffer internally and flush on their own schedule. If native span creation or recording fails, the adapter suppresses that failure, ignores the failed recording call atomically, substitutes no-op behavior, and still invokes the business callback exactly once. A nonconforming adapter is an application defect. The no-op implementation invokes the callback with one shared inert span, allocates no per-span object, inspects and retains no attributes, and otherwise preserves the callback's behavior. Flushing a real adapter at shutdown is the application's responsibility. +Adapters preserve callback results/errors. Recording is synchronous, passive, and nonthrowing; exporters buffer asynchronously. Native telemetry failure is suppressed atomically with no-op behavior while business callback still runs once. Nonconformance is an application defect. No-op uses one shared inert span, allocates nothing per span, and neither inspects nor retains attributes. Applications flush real adapters. -The harness runtime passes context to every effectful implementation boundary as a normal argument. No core function looks up a current context: +Harness passes context explicitly to every effectful boundary; core never looks it up: ```ts streamAssistant(messages, configWithTelemetryContext, emit); @@ -3972,7 +3970,7 @@ fx.appendEntry(entry, telemetryContext); fx.runHook(name, event, telemetryContext); ``` -A `TelemetrySpan` is also the explicit child `TelemetryContext`. Passing the callback span to lower-level work creates nesting through the ordinary call graph. The schema-typed API below automates that handoff by giving each callback a child starter bound to its live span; it does not use ambient mutable context. Every `Effects` method receives its parent as a parameter, and parallel tools use separate child spans and therefore separate parent contexts. +A `TelemetrySpan` is also a child `TelemetryContext`. Passing it down creates nesting through normal calls. Typed starters automate this handoff without ambient state. Every `Effects` method receives its parent; parallel tools get separate child contexts. ### Typed schema @@ -4036,11 +4034,11 @@ interface TelemetrySchemaDefinition { declare function defineTelemetrySchema(schema: T): T; ``` -`defineTelemetrySchema()` is a typed identity helper; the returned value is ordinary serializable data, not a validation runtime. Span names, attribute types, required keys, and literal `values` are inferred from that value. The tables below are the normative domain vocabulary; `packages/agent/docs/telemetry-schema.md` is its generated reference. +`defineTelemetrySchema()` is a typed identity over serializable data, not runtime validation. Types infer names, attributes, requirements, and literals. The tables are normative; `telemetry-schema.md` is generated. -`createTypedSpanStarter(context, schemas)` binds one explicit parent context to the combined span vocabulary of a non-empty readonly schema tuple. The schemas retain independent objects, ownership, documentation, and versions; the tuple is not a third merged schema. Span names must be unique across the tuple and duplicate literal names fail compilation. The schema values are otherwise type-inference inputs only and are not inspected or retained at runtime. +`createTypedSpanStarter(context, schemas)` binds a parent to a non-empty readonly tuple's combined vocabulary. Schemas retain separate ownership/versioning; the tuple is not a merged schema. Duplicate span names fail compilation. Values drive types only and are not retained at runtime. -The returned `TypedSpanStarter` is a per-name overload set that accepts only a declared literal name and that span's exact start attributes. A union-valued name must be narrowed before the call so its runtime name cannot be paired with another span's attributes. Its callback receives the schema-scoped span plus another starter over the same schema tuple bound to the callback span. The child starter therefore creates correctly nested spans without ambient context or manual rebinding, and concurrent callbacks receive independent starters: +`TypedSpanStarter` accepts a declared literal name and its exact start attributes; union names require narrowing. Its callback receives a schema-scoped span and same-tuple child starter bound to that span, creating explicit nesting and independent concurrent starters: ```ts const AGENT_TELEMETRY_SCHEMAS = [ @@ -4061,7 +4059,7 @@ await startSpan("pi.harness.step", stepAttributes, async (stepSpan, startChildSp }); ``` -The callback span still retains the open generic `TelemetryContext.startSpan()` method, so it can be passed to a starter for a different schema tuple when an integration intentionally crosses vocabularies. `createTypedSpanStarter()` itself adds no runtime span, schema validation, parent-rule enforcement, or durable state. +The span retains generic `startSpan()` for intentionally crossing schema tuples. Starter creation adds no span, runtime validation, parent enforcement, or durability. The following tables are normative input to the schema objects. `!` means a required start attribute; `?` means an optional start attribute. Every end attribute is optional enrichment. Array element sets use `elementValues`; all other closed sets use `values`. The automatic throw/reject rule from the context contract applies to every span in addition to the explicit status rule shown. @@ -4119,19 +4117,19 @@ The three operation spans share `pi.session.id` (string, required, high cardinal | `pi.harness.event_handler` | root or the scope emitting the event | `pi.event.type`! low-cardinality string with the section 10 event discriminants, `pi.lane.name`? string high-cardinality | none | listener throw; the event system catches it after the span rejects | | `pi.session.write` | root or the current harness scope | `pi.lane.name`!, `pi.operation.id`? string high-cardinality, `pi.session.mutation`!: `entry`, `record`, `lane`, `fact`, `multi`; `pi.session.item_type`? string | `pi.session.seq` number for a single mutation or the first sequence in a multi-write append | storage rejection | -The parent column maps directly to `TelemetryParentDefinition`: “root or application span” is `root_or_external`; “root or the current scope” and “root or any caller span” are `any`; every finite pi span list uses `spans` with exactly those names. `pi.harness.tool` wraps one phase-two `fx.executeTool` only and settles before `after_tool` finalization: `pi.tool.is_error` describes the raw execution result and there is no final `terminate` attribute. A batch plan is only a `pi.session.write` for its record. Blocked, invalid, genuine-length, aborted-before-start, and interrupted-without-replay results execute no tool and emit no tool span; every live execution or safe replay emits its own span. Live execution supplies the active turn id and parents the span to `pi.harness.turn`; reconciliation has no durable turn id, omits it, and parents the span directly to the resumed `pi.harness.run` invocation. The `pi.hook.name` values array is exactly `before_run`, `before_resume`, `before_run_end`, `transform_context`, `before_request`, `before_payload`, `after_response`, `before_tool`, `after_tool`, `before_compaction`, and `before_navigation`. The `pi.event.type` values array contains every `type` discriminant in the section 10 catalog and no others. `pi.harness.hook` describes one registered handler invocation, so isolated handler failures have their own status without failing the enclosing run. `pi.harness.event_handler` does the same for passive listener failures. The harness schema declares no span events initially. +Parent text maps directly to `TelemetryParentDefinition`: root/application is `root_or_external`, root/current or any caller is `any`, and finite lists are exact `spans`. Tool spans wrap only phase-two `fx.executeTool` and report raw `is_error`, not final `terminate`; plans are session-write spans. Blocked, invalid, genuine-length, aborted-before-start, and interrupted-without-replay results emit no tool span; every live execution or safe replay emits one. Live tools parent to turn with turn id; reconciliation omits turn id and parents to resumed run. `pi.hook.name` contains exactly `before_run`, `before_resume`, `before_run_end`, `transform_context`, `before_request`, `before_payload`, `after_response`, `before_tool`, `after_tool`, `before_compaction`, and `before_navigation`; `pi.event.type` contains exactly section 10 discriminants. Each handler invocation has its own span/status without failing its parent. Harness schema initially declares no span events. -One `pi.session.write` span covers one `Session.append()` call. Single-mutation appends use their logical kind; a non-empty array uses `pi.session.mutation: "multi"` and omits `pi.session.item_type` when its items differ. Dynamic identifiers and names are attributes, never span names. One `pi.harness.step` span covers one in-process provider attempt, while required `pi.step.id` correlates every attempt of the same durable `step_started`; `deferred_fetch` attempts parent directly to the resumed run. A hook-sourced structural `step_started` has no provider attempt and therefore emits no step or AI-request span: its hook invocation and durable writes keep their ordinary spans. Generated structural streams emit step and AI-request spans but no public assistant-message events. Writing `branch_summary_prepared` is a `pi.session.write`; appending it after a committed move requires no provider span. The schema definitions are the exhaustive vocabulary pi instrumentation may emit. +One session-write span covers one append. Singles use their kind; arrays use `multi` and omit item type when mixed. Dynamic ids/names are attributes. One step span covers one in-process provider attempt; durable step id correlates attempts, and deferred fetch parents to resumed run. Hook structural sources emit hook/write but no step/AI span. Generated structural requests emit step/AI but no public message lifecycle. Prepared/post-move writes need no provider span. The schemas exhaust pi instrumentation vocabulary. -When an earlier abort marker interrupts an active assistant/fetch attempt, that attempt span may end with outcome `aborted`; its conditional response append and usage remain ordinary session-write spans. Without the marker, a provider response whose stop reason is `aborted` gives the assistant step span outcome `retry` below the captured cap or `failed` at the cap, and never gives the operation span outcome `aborted`. Deferred-fetch interruption attempts use the same step outcomes but emit no public retry lifecycle events. Recovery that synthesizes a missing aborted response emits write spans but no AI-request span because it performs no provider effect. Abort between steps, during backoff, during tool-only reconciliation, or while suspended creates no assistant step span merely for termination. The operation span ends with outcome `aborted`, and best-effort deferred cancellation emits its ordinary `pi.ai.request` span only when attempted. +Marker-interrupted active attempt spans may end `aborted`; response/usage appends are write spans. Unmarked `aborted` yields step `retry` below cap or `failed` at cap, never operation `aborted`; deferred interruption uses those outcomes without retry events. Synthetic recovery emits writes but no AI span. Abort outside an attempt creates no assistant step span. The operation ends `aborted`; deferred cancellation emits AI span only when attempted. -The agent package exports both schemas, `AGENT_TELEMETRY_SCHEMAS`, each span-name union, per-name start/end/combined attribute types, event types, discriminated span unions, and typed `startAiSpan()` / `startHarnessSpan()` helpers. The telemetry package exports `createTypedSpanStarter()` and `TypedSpanStarter`; callers can bind the agent tuple when one scope needs both AI-request and harness spans. Every typed starter or domain helper accepts only that span's start attributes; its callback receives a schema-scoped view of the live span whose `setAttributes()` accepts only that span's optional end attributes and whose `addEvent()` accepts only declared event names and attributes. Individual calls reject missing required attributes, duplicate composed span names, unknown attributes, type mismatches, and invalid closed-set values at compile time. TypeScript does not try to prove that any end setter ran; `startSpan()` always owns automatic settlement. The scoped view erases to the generic `TelemetrySpan`; production performs no schema validation. +Agent exports both schemas, `AGENT_TELEMETRY_SCHEMAS`, span-name unions, per-name start/end/combined attribute types, event types, discriminated span unions, and typed `startAiSpan()` / `startHarnessSpan()`. Telemetry exports `createTypedSpanStarter()` and `TypedSpanStarter`. Start helpers accept exact start attributes; scoped spans accept only declared optional end attributes/events. Compile time rejects missing/unknown/mistyped/invalid values and duplicate composed names. End setters are optional; `startSpan()` owns settlement. Scoped views erase to generic spans with no production validation. -The schema objects are also the documentation source. `packages/agent/scripts/generate-telemetry-docs.ts`, exposed through package scripts `generate-telemetry-docs` and `check:telemetry-docs`, generates the combined AI-request and harness reference at `packages/agent/docs/telemetry-schema.md`. The Markdown file is repository documentation, not an npm package file; published consumers import both serializable schema objects from the agent package root. Schema `version` starts at 1; package changelogs record compatible additions and breaking renames, removals, type changes, or meaning changes. Explicit migration metadata is added only if a real consumer needs automatic translation. +Schemas generate `packages/agent/docs/telemetry-schema.md` via `generate-telemetry-docs`/`check:telemetry-docs`; this repository doc is not packaged, while schemas export from agent root. Versions start at 1. Changelogs record compatible additions and breaking renames, removals, type changes, or meaning changes; add migration metadata only for a real translator. ### Effects and nesting -Telemetry wrappers follow ownership of ordinary work. The procedure layer wraps orchestration scopes — operation invocation, checkpoint, turn, and each in-process attempt of a durable step — and passes each callback's `TelemetrySpan` as the parent parameter to work below it. `Effects` wraps the atomic effect it owns. Telemetry is not part of the gated action vocabulary and creates no durable crash boundary. +Telemetry wrappers follow ownership of ordinary work. Procedures wrap operation, checkpoint, turn, and in-process attempt scopes, passing callback spans downward. Effects wrap their atomic work. Telemetry is ungated and creates no durable crash boundary. ```ts async function assistantAttempt( @@ -4165,7 +4163,7 @@ async function assistantAttempt( } ``` -Section 14's `streamAssistant()` is the logical model-request wrapper. It starts `pi.ai.request` with `startAiSpan()`, passes that callback span as `ProviderRequestOptions.telemetryContext` through `Models`, records only schema-declared aggregate response fields, and returns the same assistant message. `Effects.executeTool()` similarly wraps only phase 2 in `pi.harness.tool`; hook and event runners follow the same explicit-parent pattern. +Section 14 `streamAssistant()` starts `pi.ai.request`, passes its span through Models request options, records only declared aggregates, and returns the same message. `Effects.executeTool()` wraps only phase 2; hook/event runners use the same explicit-parent pattern. | owner / method | target telemetry | |---|---| @@ -4178,18 +4176,18 @@ Section 14's `streamAssistant()` is the logical model-request wrapper. It starts | `sleep` | `pi.harness.sleep` | | passive event delivery | one `pi.harness.event_handler` per listener | -A context object and adapter-native span are process-local capabilities. Neither is persisted in a record, entry, snapshot, event, or deferred handle. +Contexts/native spans are process-local and never persisted in records, entries, snapshots, events, or deferred handles. ### Span lifetime -One operation span wraps one admitted in-process invocation of operation work. An initial `prompt()` / `compact()` / `navigateTree()` starts its span only after its `operation_started` acceptance commit; an admission `Err` such as `LaneBusy`, `InvalidMessage`, `InvalidNavigation`, `NothingToCompact`, or `UnknownTarget` emits no operation span. A `resume()` starts its wrapper after lane reservation and expected checks that do not require procedure progress. Missing runtime identities are checked only when the resumed procedure reaches the effect that needs them, after any identity-free durable repairs; a resulting `MissingIdentities` resolves the wrapper without outcome enrichment or error status. Each resume invocation otherwise uses the same durable operation id and recovery `true`. Repeated deferred polling therefore produces repeated ordinary wrapper spans correlated by operation id — no extra public lifecycle concept or durable telemetry state. +One operation span wraps each admitted in-process invocation. Initial operations start it after acceptance; `LaneBusy`, `InvalidMessage`, `InvalidNavigation`, `NothingToCompact`, and `UnknownTarget` emit none. Resume starts after lane reservation and progress-free checks. `MissingIdentities` may arise only when a needed effect is reached after identity-free repairs; it resolves without outcome/error enrichment. Resumes reuse operation id with recovery true, so deferred polls create correlated ordinary spans without new lifecycle/durable state. - a returned `completed`, `declined`, `aborted`, or `suspended` result resolves normally; instrumentation may enrich the span with the matching allowed outcome; - a returned `failed` result explicitly sets error status and still resolves normally as the public API requires; it may also enrich the span with outcome `failed`; - `close()`, a harness fault, or an invariant defect rejects the callback and therefore ends the local span as an error automatically; - actual process death runs no cleanup, so the backend may lose or retain an incomplete span; the next process simply creates a new span on `resume()`. -If an outcome attribute is set, run spans never use `declined`; that value exists only in the compaction and navigation schemas. Trace context is not durable. Persisting a backend-specific trace token would couple recovery data to one telemetry system. A serving layer may link a resumed span to an earlier trace when it has that information. +Run outcome never uses `declined`; only structural schemas do. Trace context is not durable or backend-coupled, though serving may link resumed spans externally. The span tree follows execution scopes: @@ -4211,11 +4209,11 @@ pi.harness.compaction manual operation pi.harness.navigation ``` -The procedure layer owns operation, checkpoint, turn, and durable-step attempt scopes. `Effects` owns session writes, phase-2 tool execution, hooks, and sleep. The request-dispatch wrapper around `Models` owns `pi.ai.request`; passive event delivery owns handler spans. Each owner receives its parent context explicitly. +Procedures own orchestration scopes; Effects own writes, phase-two tools, hooks, and sleep; Models dispatch owns AI request; event delivery owns handler spans. Parents are explicit. ### Safety and testing -Default attributes carry only schema-declared identifiers, names, counts, durations, stop reasons, status codes, and usage. They must never carry prompts, completions, tool arguments, tool output, file content, provider payloads, headers, or credentials. Schema fields flag any future sensitive or high-cardinality attribute explicitly. +Default attributes include only declared ids, names, counts, durations, stop reasons, status codes, and usage—never prompts, completions, tool args/output, files, provider payloads, headers, or credentials. Future sensitive/high-cardinality fields must be flagged. Telemetry remains separate from events and hooks: @@ -4272,7 +4270,7 @@ The in-memory backend is the reference. The parity suite runs the same setups ag Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), lane move (`L`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, queue cancellation, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, durable overflow response and guard, hook-supplied compaction, manual compaction, navigation (move-first), deferred suspension, repeated equal-handle pending polls, and every fetch outcome. Navigation admission cases assert `InvalidNavigation` with no hook or durable write for the current leaf and for a labeled root target. Every provider-settled assistant/fetch case asserts `step_started → step_attempt → provider effect with message_start/message_update* → after_response → message_end → response entry/entry_added → preplanned usage → classification`; `message_end` carries only the provisioned intended id and never proves the later append. A synthetic settlement performs no provider effect, emits no update, and runs no response hook; its order is `message_start → message_end → response entry/entry_added → preplanned usage → classification`. Deferred cases additionally assert one fetch with `wait: 0` per resume, exact source advancement or retention, no original-step lookup after F starts, ready tool selection from F's copied active names, and no retry lifecycle events. Every structural case asserts one stable typed result id, no public assistant-message lifecycle, and `step_failed` only for terminal generated failure. Hook cases assert complete output on `step_started`, exact preplanned usage before entry, `fromHook: true`, and no hook replay. Generated navigation asserts usage and `branch_summary_prepared` before move, exact post-move append with `fromHook: false`, and no provider request after move; generated compaction asserts no prepared record. Every tool case asserts response usage → complete batch plan → source-ordered clearance → `tool_started` immediately before each real `fx.executeTool` → source-ordered finalization → result `message_start/message_end` → tool usage when present → planned result/`entry_added`. Parallel cases prove that effects overlap while starts and dispatches retain source order, result commits form a source-order prefix, and blocked/invalid calls have planned results but no start or tool effect. This tier catches the critical regression classes: an effect starting before its intent record, a response omitted for one stop reason, classification starting before usage is durable, or a result id allocated after clearance began. -Abort writer-conformance traces additionally assert that marker-before-response normalizes the existing attempt response, response-before-marker preserves its stop reason, a missing response is synthesized under that same id only on recovery, repeated abort emits/writes once, planned unstarted tools get results while started real/error results survive, pending writes precede `operation_finished`, and between-turn, backoff, deferred, and structural abort append no assistant closure. Separate no-marker traces prove that `aborted` assistant/fetch responses are accounted interruptions, emit no `run_abort`, retry under captured policy when allowed, and finish failed rather than aborted at the cap. `operation_finished` is the only universal terminal write. +Abort writer-conformance traces additionally assert that marker-before-response normalizes the existing attempt response, response-before-marker preserves its stop reason, a missing response is synthesized under that attempt's planned response id only on recovery, repeated abort emits/writes once, planned unstarted tools get results while started real/error results survive, pending writes precede `operation_finished`, and between-turn, backoff, deferred, and structural abort append no assistant closure. Separate no-marker traces prove that `aborted` assistant/fetch responses are accounted interruptions, emit no `run_abort`, retry under captured policy when allowed, and finish failed rather than aborted at the cap. `operation_finished` is the only universal terminal write. Tier B also asserts provider-context projection and the append-only invariant (section 4) executably. Durable `error`, `aborted`, and `deferred` assistant responses never reach the provider; genuine output-limit `length` does, followed by its explanatory tool errors; and the exact response linked by overflow is absent from compaction preparation and retained tail. Within a run, every faux-provider request's message list otherwise extends the previous request's as an exact prefix, except across a compaction entry, the one sanctioned invalidation. This turns projection and KV-cache discipline into failing tests whenever a path includes a non-projecting response or inserts before the tail. @@ -4535,7 +4533,7 @@ H0 converges restore and primitives into `agent-harness.ts`. H0–H8 then merge - Add deferred lane-view tree writes, direct idle tree writes, immediate total `lane_config` setters/getters for model/thinking/active-tool names, `recordUsage`, pending-write snapshots/events, and finish conditionals. Direct and applied message writes emit message start/end before append and `entry_added` after commit; all other committed entries also emit `entry_added`. Keep `setTools()` limited to the environmental implementation registry and keep `getModel(): Promise`. - Acceptance: both orders of race rows 3 and 9; accepted tree writes and immediate configuration replacements survive crashes and abort markers; retries retain their generation-step snapshot; adjustments affect ledger totals but never entries. - [ ] **H5 — abort, wait, run-when-idle, and close.** Dependencies: H4. - - Add one authoritative, idempotent abort marker; stable queue draining; one signal/event; pending-write application; marker-backed active assistant settlement under its existing attempt id; synthetic missing-attempt settlement under that same id; preserve H2's unmarked `aborted` interruption classification; between-turn/backoff and suspended abort without an assistant closure; missing-identity abort-only recovery; optional aborted-run final message fields; idle waiters/callbacks; and process-local close settlement. Close stops admission, signals effects, rejects parked/local operation promises, drains admitted storage writes through `Session.close()`, and leaves durable operations open. Never start a provider request or allocate an assistant response id for termination. + - Add one authoritative, idempotent abort marker; stable queue draining; one signal/event; pending-write application; marker-backed active assistant settlement under its planned response id; synthetic missing-attempt settlement under that same response id; preserve H2's unmarked `aborted` interruption classification; between-turn/backoff and suspended abort without an assistant closure; missing-identity abort-only recovery; optional aborted-run final message fields; idle waiters/callbacks; and process-local close settlement. Close stops admission, signals effects, rejects parked/local operation promises, drains admitted storage writes through `Session.close()`, and leaves durable operations open. Never start a provider request or allocate an assistant response id for termination. - Acceptance: both orders of race rows 4, 6, 8, and 10; repeated abort returns the same payload without a write/event/signal; crash/reopen after every abort action; every marker-backed aborted run ends in `operation_finished` after required writes and may have no assistant response; close releases only the matching writer claim after queue drain; an unmarked `aborted` response never enters this path. - [ ] **H6 — live durable tool batches.** Dependencies: H5. - After response accounting and classification, commit one `tool_batch_started` with every source-index/result-id pair before clearance. Wire section 14 callbacks through `Effects`; write effect-only `tool_started` immediately before each individual `fx.executeTool`, emit each finalized tool-result message start/end before persistence, write reported usage before the planned result, emit `entry_added` after that result commits, persist finalized `terminate`, and emit existing tool events without exposing source indices. On live abort, signal started effects and preserve their finalized real/error results while appending planned synthetic aborted results for calls that never started; start no assistant request for closure. A genuine output-limit `length` response appends its planned explanatory errors in source order, starts no clearance or tool effect, and forces another assistant turn; an overflow-classified response gets no plan. @@ -4545,7 +4543,7 @@ H0 converges restore and primitives into `agent-harness.ts`. H0–H8 then merge - Acceptance: complete tool crash matrix, changed replay declarations, blocked/invalid decisions rerun under the same id, parallel starts beyond the result prefix, usage-before-result crashes, and idempotent second recovery. - [ ] **H8 — deferred provider redemption.** Dependencies: H7. - Primary files: `packages/agent/src/harness/agent-harness.ts` and focused deferred harness tests. The deferred provider, fetch, cancellation, and authenticated `Models` APIs in `packages/ai` are already landed and receive no new work here. - - Integrate one stable `deferred_fetch` step per original deferred response and its later pending responses. Copy its total configuration and normalized retry policy from the original assistant generation step exactly once; use the exact source handle's provider/model for fetches and the copied active names for ready tool calls. Number poll attempts across the step, record exact source plus response/usage ids before each fetch, persist pending/ready/terminal/interrupted responses before usage and classification, advance equal-handle pending source entries, retain interrupted/unknown sources, reject unmarked complete-handle mismatch, and best-effort cancel the newest persisted handle. An unmarked `aborted` response re-parks on its source below the copied per-source cap and fails at that cap without retry lifecycle events. Suspended abort retains deferred entries and adds no assistant closure; an active marker-backed fetch settles or synthesizes only under its existing attempt id. + - Integrate one stable `deferred_fetch` step per original deferred response and its later pending responses. Copy its total configuration and normalized retry policy from the original assistant generation step exactly once; use the exact source handle's provider/model for fetches and the copied active names for ready tool calls. Number poll attempts across the step, record exact source plus response/usage ids before each fetch, persist pending/ready/terminal/interrupted responses before usage and classification, advance equal-handle pending source entries, retain interrupted/unknown sources, reject unmarked complete-handle mismatch, and best-effort cancel the newest persisted handle. An unmarked `aborted` response re-parks on its source below the copied per-source cap and fails at that cap without retry lifecycle events. Suspended abort retains deferred entries and adds no assistant closure; an active marker-backed fetch settles or synthesizes only under its planned response id. - `resume()` always calls `fetchDeferred` with `wait: 0`: one check, then re-park immediately when pending. Poll cadence belongs to the application and can use `pollAfterMs`. - Acceptance: at most one fetch per resume; repeated pending polls with a completely equal handle create distinct durable messages and advance exact source lineage; every pending/ready/terminal/interrupted poll has its preplanned usage record; ready tool calls use copied active names despite later lane configuration changes; returned and rejected terminal errors never start replacement requests; unmarked interruptions retry only on later resumes under copied policy and never abort the operation; no deferred poll emits retry lifecycle events; cancellation targets the newest persisted handle and remains best effort. @@ -4594,7 +4592,7 @@ The runtime merge lane is strictly **H0 → H1 → H2 → H3 → H4 → H5 → H For a fresh implementation session, in this order. This document wins over older harness designs. -1. `packages/agent/docs/harness-v2-merged.md` — this document. +1. `packages/agent/docs/harness-v2.md` — this document. 2. `packages/agent/src/harness/session/types.ts` — v4 entries, records, storage, and repository contracts. 3. `packages/agent/src/harness/session/session.ts` — session validation and lane-bound views. 4. `packages/agent/src/harness/session/memory.ts` — reference backend. From 45fac5bd517c371993b1781291d3a7971633a651 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 9 Aug 2026 01:36:21 +0200 Subject: [PATCH 066/284] docs(agent): add explicit-state harness redesign --- .../agent/docs/harness-v2-state-machine.md | 798 ++++++++++++++++++ 1 file changed, 798 insertions(+) create mode 100644 packages/agent/docs/harness-v2-state-machine.md diff --git a/packages/agent/docs/harness-v2-state-machine.md b/packages/agent/docs/harness-v2-state-machine.md new file mode 100644 index 00000000000..4fec8de7bea --- /dev/null +++ b/packages/agent/docs/harness-v2-state-machine.md @@ -0,0 +1,798 @@ +# AgentHarness v2 explicit-state redesign + +> **Status:** Working handoff document. This is not yet the canonical implementation specification. `harness-v2.md` remains the complete requirements inventory until this design is validated and adopted. +> +> **Purpose:** Preserve the redesign and decisions from the current design session in a form a new session can read quickly. The design deliberately replaces implicit recovery reduction with explicit, total durable operation state. + +## 1. Concise model + +An `AgentHarness` owns one durable session. The session contains: + +1. **Conversation tree** — append-only message, compaction, branch-summary, and custom entries. +2. **Lanes** — permanent names pointing at tree leaves. Each lane has at most one open operation. +3. **Lane configuration** — one total replacement containing model reference, thinking level, and active tool names. +4. **Operational state** — an immutable operation header plus append-only total snapshots of the current operation. +5. **Usage ledger** — immutable usage records, independent of whether later orchestration succeeds. +6. **Global facts** — latest-wins name, labels, and custom facts. + +Lanes run concurrently. One writer owns the session. Each lane serializes state-dependent decisions on a mutation line. Storage serializes all appends and assigns one session-wide `seq`. + +### Operations + +A lane accepts one of three operation kinds: + +- **run** — prompt, assistant generations, tools, steering/follow-up, deferred writes, and automatic compaction; +- **compaction** — standalone manual compaction; +- **navigation** — move to another tree entry, optionally with a summary. + +An accepted operation has one durable header and a sequence of total state snapshots. The latest snapshot directly states what the operation is doing and what may happen next. + +### Effects + +An effect is work outside pure state calculation: + +- durable storage mutation; +- provider generation or deferred fetch; +- tool invocation; +- hook invocation; +- timer or retry sleep. + +Before a repeat-sensitive external effect starts, durable state records that it is pending and provisions every settlement ID. After it settles, one atomic transaction writes its durable output, usage, and next total operation state. + +### External-effect non-goal + +External effects cannot generally be both durable and exactly once across process failure. Provider requests, tools, hooks, and provider billing can happen without their settlement becoming durable. Implementations must use idempotency, declared safe replay, reconciliation, or accept uncertainty. The harness makes this uncertainty explicit but cannot eliminate it. + +### Context projection + +Conversation persistence and provider context remain separate. Durable `error`, `aborted`, and `deferred` assistant responses do not project. Genuine output-limit `length` projects. Overflow compaction omits its exact superseded response from summary input and retained tail. Compaction entries remain self-contained context boundaries. + +## 2. Replace implicit reduction with total operation snapshots + +The current design reconstructs orchestration from combinations of records, entry presence, lane pointers, and later transitions. The redesign persists the continuation directly. + +### Operation header + +The header contains immutable acceptance data and is written once: + +```ts +interface OperationHeader { + operationId: string; + lane: string; + sourceLeafId: string | null; + startedAt: number; + intent: + | { + kind: "run"; + originalPrompt: AgentMessage[]; + systemPromptOverride?: string; + resumeData?: Record; + } + | { + kind: "compaction"; + customInstructions?: string; + } + | { + kind: "navigation"; + targetId: string | null; + summarize: boolean; + label?: string; + customInstructions?: string; + }; +} +``` + +### Total operation snapshot + +Every state transition appends a complete snapshot of all current mutable orchestration state for that operation: + +```ts +interface OperationSnapshotRecord { + type: "operation_snapshot"; + id: string; + lane: string; + operationId: string; + revision: number; + state: OperationState; +} +``` + +`revision` starts at 1 and increases by exactly one. Snapshots are append-only; the latest revision is authoritative. + +**Total means total.** A snapshot does not contain a patch and does not require older snapshots to interpret it. It contains the complete current workflow state, retry state, tool plan and per-call states, pending operation-owned queues and writes, deferred source, and cancellation control. It may reference immutable conversation entries, usage records, and the immutable operation header by ID, but no older operational snapshot supplies missing state. + +The first implementation accepts the storage cost of total snapshots. Do not introduce delta chains, child-state logs, or patch replay to optimize them. If measurement later shows a problem, optimize physical encoding or compression while preserving the logical total-snapshot contract. + +### Loading current state + +The public storage concept is: + +```ts +interface CurrentOperation { + header: OperationHeader; + snapshot: OperationSnapshotRecord; +} + +getCurrentOperation(lane: string): Promise; +``` + +Backends answer through their latest-operation index. They read the immutable header and exactly one latest total snapshot. They do not scan or fold operation history, inspect entry absence to infer a phase, or collect partial task state from several operational logs. + +Memory keeps the latest snapshot in a map. JSONL updates its latest-snapshot projection while replaying the file. SQLite keeps a current-operation projection and reads the selected snapshot/header by indexed ID. + +### Records retained and replaced + +Retain: + +- total `lane_config` replacements; +- immutable usage and adjustment records; +- operation header; +- total operation snapshots; +- facts, lane history, and conversation entries. + +The total snapshot replaces the recovery authority currently spread across: + +```text +abort_requested +step_started +step_attempt +step_failed +branch_summary_prepared +tool_batch_started +tool_started +queue_enqueued +queue_cancelled +write_deferred +operation_finished +``` + +Some implementation may retain compact audit records, but recovery and transition validity must never depend on them. + +## 3. Operation state + +```ts +type OperationState = + | RunOperationState + | ManualCompactionState + | NavigationOperationState + | FinishedOperationState; +``` + +### Orthogonal cancellation control + +Abort is not a workflow phase. It is control over the current workflow: + +```ts +type OperationControl = + | { status: "running" } + | { + status: "cancel_requested"; + requestedAt: number; + drainedSteer: ProvisionedEntry[]; + drainedFollowUp: ProvisionedEntry[]; + }; +``` + +Every active operation state contains `control`. Normal transition planning checks it. If cancellation won first, no new provider, tool, hook decision, or retry effect starts. Effect settlement, usage, accepted deferred writes, configuration changes, and cancellation completion remain allowed. + +### Run state + +```ts +interface ActiveRunState { + kind: "run"; + control: OperationControl; + phase: RunPhase; + pendingSteer: ProvisionedEntry[]; + pendingFollowUp: ProvisionedEntry[]; + pendingWrites: ProvisionedEntry[]; +} + +type RunPhase = + | { + kind: "checkpoint"; + continuation: CheckpointContinuation; + } + | { + kind: "assistant"; + generation: GenerationState; + } + | { + kind: "tools"; + batch: ToolBatchState; + } + | { + kind: "compaction"; + compaction: SummaryGenerationState; + resumeAfter: CheckpointContinuation; + } + | { + kind: "deferred"; + deferred: DeferredState; + } + | { + kind: "failure_drain"; + error: OperationError; + terminalResponseEntryId: string; + }; + +type CheckpointContinuation = + | { + kind: "need_assistant"; + triggerMessageId: string; + } + | { + kind: "may_finish"; + }; +``` + +`continuation` replaces inference such as `needsAssistant()`. A compaction stores the continuation it must resume. Overflow compaction resumes `need_assistant` with the same trigger. Applying a new user-context message changes the continuation to `need_assistant` with that message ID in the same atomic transaction. + +### Generation state + +```ts +interface GenerationContext { + stepId: string; + triggerMessageId: string; + configuration: LaneConfiguration; + retryPolicy: RetryPolicy; +} + +type GenerationState = + | { + status: "ready"; + context: GenerationContext; + nextAttempt: number; + } + | { + status: "effect_pending"; + context: GenerationContext; + attempt: number; + responseEntryId: string; + usageRecordId: string; + intendedOutputLimit: number; + contextWindow: number; + } + | { + status: "retry_wait"; + context: GenerationContext; + nextAttempt: number; + notBefore: number; + errorMessage: string; + }; +``` + +`RetryPolicy` applies to generation requests, including generated summaries. It does not impose a retry or polling cap on deferred fetch. + +### Tool batch state + +```ts +interface ToolBatchState { + assistantEntryId: string; + triggerMessageId: string; + genuineLength: boolean; + calls: ToolCallState[]; + nextToFinalize: number; +} + +type ToolCallState = + | { + status: "planned"; + sourceIndex: number; + toolCall: AgentToolCall; + resultEntryId: string; + } + | { + status: "effect_pending"; + sourceIndex: number; + toolCall: AgentToolCall; + resultEntryId: string; + effectiveArgs: JsonValue; + replay: "never" | "safe"; + } + | { + status: "completed"; + sourceIndex: number; + toolCall: AgentToolCall; + resultEntryId: string; + terminate: boolean; + }; +``` + +The total operation snapshot contains the complete batch and every call state. This can duplicate data across snapshots; correctness and direct recovery take priority. Parallel tool execution remains possible: several calls may be `effect_pending`, while result commits remain source ordered. + +### Deferred state + +```ts +type DeferredState = + | { + status: "suspended"; + stepId: string; + sourceEntryId: string; + configuration: LaneConfiguration; + } + | { + status: "effect_pending"; + stepId: string; + sourceEntryId: string; + responseEntryId: string; + usageRecordId: string; + configuration: LaneConfiguration; + }; +``` + +Each `resume()` performs at most one `fetchDeferred(..., { wait: 0 })`. The application decides whether and when to call `resume()` again. Deferred polling has no harness retry count, retry cap, or retry sleep. A pending response becomes the next source. Provider terminal errors fail the run. Provider behavior such as expiration or cancellation support is outside harness control. + +### Terminal state + +```ts +interface FinishedOperationState { + kind: "finished"; + control: OperationControl; + outcome: "completed" | "declined" | "failed" | "aborted"; + leafId: string | null; + error?: OperationError; + finalAssistantEntryId?: string; +} +``` + +Only transition functions may construct terminal state. This makes terminal validity local and exhaustive instead of a separate historical-log audit. + +## 4. Atomic transition rule + +Every durable boundary follows one rule: + +> Compute one next total operation state, then atomically append all conversation, usage, fact, lane, and snapshot mutations that make that state true. + +A transaction either commits all logical mutations or none. + +### Assistant attempt + +Plan before the effect: + +```text +TX operation snapshot: + phase assistant + generation effect_pending + attempt 1 + response R1 + usage U1 +``` + +After the provider settles, classify in memory and commit settlement plus meaning: + +```text +TX assistant entry R1 + usage U1 + operation snapshot: + phase tools + complete result-ID plan +``` + +or: + +```text +TX assistant entry R1 + usage U1 + operation snapshot: + phase assistant + retry_wait for attempt 2 +``` + +or: + +```text +TX assistant entry R1 + usage U1 + operation snapshot: + phase compaction + exact overflow response link + resumeAfter need_assistant +``` + +There is no durable response-without-usage or accounted-response-without-classification state. + +### Tool call + +After clearance and immediately before execution: + +```text +TX operation snapshot: + call i = effect_pending + effective args and replay declaration stored +``` + +After execution/finalization: + +```text +TX tool usage, when present + planned tool-result entry + operation snapshot: + call i = completed + next continuation recorded when batch completes +``` + +If a crash leaves `effect_pending`, replay only when the declaration and implementation are safe; otherwise append the planned interrupted result. + +### Queue or deferred-write application + +Acceptance updates the total snapshot with the complete provisioned payload. Application is atomic: + +```text +TX message/custom entry + operation snapshot with item removed + continuation updated when the entry requires an assistant +``` + +A crash cannot consume an item without updating operation state, or update operation state without appending the entry. + +### Navigation + +Reject before acceptance when: + +- target equals current leaf; +- target is root and a label was requested; +- summary was requested while the source leaf is root; +- non-null target does not exist. + +For summarized navigation, all provider/hook work happens before the structural transaction. Successful completion is one atomic append: + +```text +TX generated usage, when present + lane move to target + exact branch-summary entry + label fact, when present + finished operation snapshot +``` + +The summary entry chains from the moved target because mutations apply in order. A crash sees either an uncommitted navigation at its source or a fully completed navigation. No prepared-summary or post-move recovery state is needed. + +### Manual compaction + +Determine whether useful context exists before operation acceptance. If not, return `NothingToCompact` and write nothing. Successful settlement atomically appends usage, the compaction entry, and finished state. + +## 5. Interpreter, abort, and recovery + +### Interpreter + +```ts +async function drive(operation: CurrentOperation): Promise { + while (true) { + const action = nextAction(operation.snapshot.state); + + switch (action.kind) { + case "transition": + operation = await commitTransition(operation, action); + break; + + case "effect": + operation = await commitEffectIntent(operation, action); + const result = await runEffect(action.effect); + operation = await commitEffectSettlement(operation, result); + break; + + case "wait": + return action.result; + + case "done": + return action.result; + } + } +} +``` + +The exact implementation may avoid an explicit loop, but every path uses the same `nextAction`, intent, and settlement transitions. Manual drive gates these actions. Recovery loads current state and calls the same interpreter. + +### Abort + +Pure synchronous code cannot be interrupted. Abort and normal commits race only on the lane mutation line. The first abort transaction changes `control` to `cancel_requested`, stores the exact drained steer/follow-up payloads, and leaves the workflow state intact. After commit it signals a live cooperative effect and cancels unreleased gated effects. + +Normal work-creating transitions recheck control and write nothing when cancellation already won. Settlement and accounting for already-intended effects remain allowed. Planned tools become aborted; restored started tools become interrupted; live started tools preserve their finalized result. Assistant/fetch settlement after cancellation is stored under its planned response ID with stop reason `aborted`. + +Repeated `abort()` while the operation remains open appends nothing, signals nothing, and returns the same durable drained payloads. Abort after terminal state returns `NoActiveOperation`. + +Effects are required to cooperate with `AbortSignal`. Provider and tool adapters must settle after cancellation rather than run indefinitely. + +### Recovery + +Restore performs indexed reads only: + +```text +latest lane configuration +current operation header + latest total snapshot +current lane leaf +independent pending nextRun state +``` + +`getCurrentOperation()` returns the materialized header and one latest total snapshot. No historical operation records or older snapshots are reduced. The snapshot directly selects the next interpreter action. + +The remaining unavoidable crash state is: + +```text +effect intent is durable +effect settlement is absent +``` + +For generation, a later attempt is allowed only under the captured generation retry policy; when no attempt remains, recovery persists a synthetic error under the already-planned response ID. If durable cancellation won, recovery instead persists synthetic `aborted`. For tools, safe replay or planned interruption applies. Hook and external-effect side effects remain subject to the external-effect non-goal. + +### Missing runtime identities + +Before `prompt()`, `compact()`, or `navigateTree()` accepts work, the lane verifies that its configured model/provider and every active tool name can resolve. Missing identities return `MissingIdentities` and write nothing. The lane remains idle. + +For an already-open operation, `resume()` verifies the identities required by its next effect. Missing identities return `MissingIdentities`, perform no effect, and leave the operation open at the same snapshot. + +Registering the missing tools/providers/models unblocks execution. An explicit escape hatch is also needed to replace a missing model/provider referenced by existing lane or operation state. Its exact API and whether it rewrites a pending generation snapshot remain unresolved. + +## 6. Storage and event boundaries + +The redesign assumes the existing storage contract: + +- one writer per session; +- per-lane mutation serialization; +- one session-wide monotonic `seq`; +- atomic non-empty mutation arrays; +- Memory one queue job, JSONL one physical object/array line, SQLite one transaction; +- all-or-none replay and publication; +- fenced SQLite writer ownership. + +The state-machine design does not replace or weaken these requirements. + +Lifecycle events such as streaming updates remain process ordered. Events that claim a durable commit fire only after the atomic transaction commits. `message_end` still means streaming ended; `entry_added` still means the entry committed. + +Whether durable commit events, especially usage totals, must be published in strict global `seq` order remains unresolved. Strict ordering is more faithful to durable state but may briefly buffer a later lane's commit event until an earlier lane has installed and queued its event. Storage already resolves append promises in commit order, and state installation must contain no `await`, so expected buffering is small; this needs implementation-level validation before becoming a requirement. + +## 7. Decisions from the design session + +This section records decisions made while developing the redesign. A later session must not silently reopen them without a concrete contradiction or failing trace. + +### 7.1 Total snapshots, not shallow or delta state + +The latest operation snapshot is complete mutable operational state. Loading an operation reads its immutable header and exactly one latest snapshot. Do not split active generation, tool, queue, or deferred state into separate latest-value logs that must be collected and reconciled. Do not use patches or replay delta chains. + +Total snapshots may consume more storage. Correctness and direct recovery take priority. Measure before optimizing. Permitted future optimizations are physical compression, backend-internal structural sharing, or compact field encoding that still decodes one snapshot into the complete state. Tool batches are the likely worst case because every call state repeats after transitions. + +### 7.2 One external-effect non-goal + +Provider calls, tool effects, hook-owned side effects, and provider billing are examples of one problem: an external effect may occur before its settlement becomes durable. Exactly-once execution cannot be guaranteed without cooperation from the external system. Enumerate these examples once and require idempotency, safe replay, reconciliation, or accepted uncertainty. Do not create separate orchestration theories for each example. + +### 7.3 Abort + +Abort is orthogonal operation control, not a workflow phase. Pure synchronous code cannot be interrupted. Abort and normal state changes race only at mutation/effect boundaries. The first durable cancellation request wins; later requests return the same drained steer/follow-up payloads without another write, signal, or event. + +Effects must cooperate with `AbortSignal` and settle promptly. New ordinary effects do not start after cancellation. Settlement/accounting for an already-intended effect and accepted writes that survive abort remain allowed. + +### 7.4 Deferred polling + +Deferred polling is application-controlled. Each `resume()` performs at most one `fetchDeferred(..., { wait: 0 })`. The harness persists the response and either continues or remains suspended. The application uses `pollAfterMs` or its own schedule to decide whether to call `resume()` again. + +`RetryPolicy` applies to generation, including generated summaries. It does not impose a deferred-fetch retry count, cap, backoff, or automatic polling loop. Provider expiration, terminal errors, and cancellation support are provider behavior the harness must report but cannot repair. + +### 7.5 Context projection + +Existing projection rules remain. This redesign changes orchestration state, not conversation projection. Durable error, aborted, and deferred assistant responses remain omitted; genuine output-limit `length` remains; exact overflow omission remains linked to compaction; compaction tails remain self-contained. + +### 7.6 Missing runtime identities + +`prompt()`, `compact()`, and `navigateTree()` check the lane's configured model/provider and active tool names before acceptance. If any are missing, return `MissingIdentities`, perform no effect, and write no operation. The lane remains idle until the application registers the missing identities or changes configuration. + +`resume()` checks only identities required by the next effect. Missing identities return `MissingIdentities`, perform no effect, and leave the existing operation open at the same total snapshot. + +Tools are restored by registering every active tool name mentioned by the relevant configuration. An idle lane can replace a missing model with `setModel(validModel)`. An open operation may contain a captured missing model reference, so an explicit model/provider replacement escape hatch is needed; its API and durable semantics are unresolved. + +### 7.7 Navigation from root + +Reject summarized navigation when `sourceLeafId === null`. There is no source branch to summarize and `BranchSummaryEntry.fromId` is non-null. Reject before hook invocation or durable acceptance. + +### 7.8 Empty manual compaction + +Manual compaction with no useful preparation returns `NothingToCompact` before durable operation acceptance. It does not invoke the decision hook or provider and writes no operation state. + +### 7.9 Navigation decision hook + +The current intended decision is that `before_navigation` applies only to summarized navigation. Unsummarized navigation validates and moves without that decision hook and cannot finish `declined`. Before making this normative, compare against current coding-agent behavior; do not perform that investigation as part of this handoff edit. + +### 7.10 Sensitive events versus telemetry + +Events and hooks may contain prompts, model output, tool arguments/results, deferred handles, and other sensitive application content. The previous goal that events are secret-free is rejected. Serving layers own authorization and optional redaction. Handler errors still need a JSON-safe normalized shape. + +Telemetry remains content- and secret-free by default. It may contain declared identifiers, names, counts, durations, statuses, and usage, but not prompts, completions, tool data, provider payloads, headers, or credentials. + +### 7.11 Storage assumptions + +Single writer, lane mutation serialization, atomic non-empty append arrays, monotonic `seq`, all-or-none replay, and fenced SQLite ownership are existing storage contracts. They are assumed, already enforced, and not problems for the new state model to solve. + +### 7.12 Lane-level next-run state + +`nextRun` exists independently of an operation. It therefore needs one total latest-value lane runtime record or equivalent current-state projection containing the complete pending next-run items. Run acceptance atomically removes the captured items from that total lane state, appends their entries, opens the operation, and writes its first total snapshot. It must not be reconstructed from queue history plus entry absence. + +The exact type and whether this lane runtime record also points at the current operation remain to be specified. Its semantic requirement is total latest-value state, not a patch/event stream. + +### 7.13 Commit-event ordering + +Strict global `seq` ordering for durable commit events is not yet accepted. Storage resolves append promises in commit order, so a central publication queue could order durable events with little buffering if state installation performs no `await`. However, it may introduce cross-lane head-of-line coupling or require delayed/reordered delivery. Keep process-local lifecycle events in process order. Validate the implementation consequences before strengthening durable-event ordering. + +## 8. Unresolved questions and retained audit findings + +This section preserves the full audit result and its current disposition so a later session can continue without rerunning the entire conversation. Findings marked **addressed by redesign** still require tests and must not be assumed correct merely because the state shape permits a solution. + +### 8.1 Runtime and recovery findings + +#### Tool outcome racing abort — addressed by redesign + +Old failure: `before_tool` produced a blocked result, abort committed before the result append, and normal execution and abort reconciliation could append different content under one planned result ID. + +Required transition rule: settlement enters the lane mutation line and reads current cancellation control. A planned/unstarted call becomes aborted when cancellation won. A live started effect may preserve its finalized result. A restored started effect becomes interrupted. Add an explicit regression for both commit orders. + +#### Terminal state not tied to operation state — addressed by redesign + +Old invalid examples included completed runs with pending steer, completed compaction after structural failure, and declined runs. Only exhaustive transition functions may create `FinishedOperationState`. They must reject terminal state while required work, unresolved effects, operation-owned queues/writes, or incompatible failure provenance remains. + +#### Failure drain plus deferred user write — addressed by redesign + +Old failure: failure drain applied a deferred user message but used only a process-local consumed-queue count to decide whether to restart generation. The atomic write-application transition must remove the write, append its entry, and set `checkpoint.need_assistant` in one transaction. + +#### Crash after failure-drain queue consumption — addressed by redesign + +Old failure: a steer entry committed, the process crashed before a new step started, and recovery no longer knew that terminal failure had been cleared. Queue application now atomically appends the entry and writes `checkpoint.need_assistant`, preserving the restart. + +#### Assistant need lost after compaction — addressed by redesign + +Old failure: the newest own entry became a `CompactionEntry`, so `needsAssistant()` could return false even though overflow or a tool-result tail required another assistant. `resumeAfter: CheckpointContinuation` explicitly preserves `need_assistant`; no tree-entry-role inference determines continuation. + +#### Missing identities — policy partly decided + +Pre-acceptance operation calls return `MissingIdentities` without writing. Resume leaves an existing operation open. The unresolved part is replacing a missing model/provider captured inside an open operation. See section 8.4. + +#### Final-response hook cannot be mounted — unresolved implementation contract + +`StreamAssistantConfig` currently lacks a callback that receives and may replace the settled assistant message before `message_end`. Transport `onResponse` sees HTTP metadata, not the final message. Add an explicit final-response callback and define metadata when transport failure is converted to an in-band error. This remains required regardless of state persistence. + +#### Hook-produced assistant validity — accepted contract risk + +A hook can return duplicate tool IDs, invalid deferred-handle combinations, pending stop reason, or non-JSON values. The decision is not to attempt exhaustive semantic validation. Hooks must obey their typed/runtime contract. Minimal boundary checks needed to prevent storage corruption or impossible core types may still be required; distinguish those from trying to validate model semantics. + +#### Summarized navigation from root — decided + +Reject before acceptance. See section 7.7. + +#### Empty manual compaction — decided + +Return `NothingToCompact` before acceptance. See section 7.8. The race between the pre-acceptance preparation read and another idle-lane mutation still needs a concrete admission algorithm; acceptance must revalidate the source leaf or reserve the lane while preparation is checked. + +#### Unsummarized navigation decline — provisional decision + +Treat `before_navigation` as summary-only and disallow decline for unsummarized navigation, subject to comparison with coding-agent. + +#### Missing generation settlement after crash + +Plain-language definition: generation intent is durable, the provider may have run, but no response transaction exists. Below the captured generation attempt limit, start a new numbered attempt. When no generation attempt remains, persist a synthetic assistant error under the already-planned response ID. If durable abort control won first, persist synthetic `aborted` instead. Avoid unexplained terms such as “unknown effect at cap” and “marker-backed cancellation” in the final specification. + +#### Captured next-run cancellation — addressed by total state + +Old ambiguity: a next-run item was captured by accepted run state but its entry was not yet appended, so cancellation could not distinguish pending from consumed. Acceptance must atomically remove the item from total lane next-run state and append it with the operation. After acceptance, cancellation reports `already_consumed`; there is no intermediate captured-without-entry state. + +### 8.2 Event, hook, and telemetry findings + +#### Usage totals can regress under out-of-order delivery — unresolved + +Example: usage commit seq 11 with totals 20 is delivered before seq 10 with totals 10; a stateless consumer ends at 10. Options are strict session-sequenced durable event publication or requiring consumers to track maximum record `seq`. Strict ordering may buffer or couple lanes; investigate before deciding. See section 7.13. + +#### Secret-free event claim — decided + +Reject the claim for events/hooks; retain it for telemetry only. See section 7.10. + +#### Safe tool replay event lifecycle — unresolved + +A safely replayed tool needs a defined `turnId` and tool event lifecycle. One candidate is deriving `turnId` from the durable assistant generation `stepId` and emitting recovery turn/tool brackets. Another is making recovery tool event fields differ. Decide when the event model is implemented; do not let telemetry invent a separate answer. + +#### Telemetry sleep parents — fix required + +Retry sleeps can occur under turn or checkpoint scopes, while the current schema permits only operation parents. Either add turn/checkpoint as allowed parents or explicitly pass operation-level context to sleeps. Prefer schema parents matching actual call structure. + +#### `compaction_end.fromHook` without a result — fix required + +Declined, pre-source aborted, and early failed compactions have no result provenance. Make structural end events discriminated: completed carries `entry` and `fromHook`; declined/aborted omit both; failed carries error and omits result provenance. + +#### Async callbacks outside Effects — unresolved contract detail + +`systemPrompt`, `toProviderMessages`, and entry projectors may be async and can perform external I/O outside the complete effect boundary. Prefer a contract that these callbacks are deterministic/idempotent computation with no externally visible side effects and may repeat. Effectful interception should use hooks. Also define the system-prompt preview supplied to `before_run` versus per-request evaluation. + +#### “Operations never throw” wording — fix required + +Expected caller errors resolve through `Result`; storage faults, close, and invariant defects may reject. Correct the public API comment accordingly. + +#### Session-ordered durable events — unresolved + +See usage ordering above and section 7.13. Non-durable stream/tool lifecycle must not be reordered merely to match storage `seq`. + +### 8.3 Storage and fork findings + +#### Read-only normalized-v3 fork has no configuration — unresolved + +A normalized-v3 `main` is unconfigured until harness attachment, while current fork rules require copying current configuration. Options: permit an unconfigured destination `main`, require a seed in fork options, or require harness attachment before fork. The earlier recommendation was to allow an unconfigured destination and seed it on first attachment, but this is not yet accepted. + +#### JSONL fork versus active source queue — unresolved + +A coherent fork must order its snapshot with concurrent source appends. The repository currently claims not to retain open storage instances. Possible resolution: accept the open `Session` as fork source and enqueue a private snapshot operation on its mutation queue. Validate against existing repository API before deciding. + +#### JSONL conversion wording — documentation fix + +Clarify that conversion writes a temporary file and atomically renames it over the original path; the final directory and filename do not change. + +#### SQLite lane-move action — schema fix + +The proposed/current `LogItem` distinguishes lane `create` and `move`, but the shown `lane_moves` table lacks an action column. Add it or define an unambiguous derivation. Explicit column is simpler. + +#### Storage efficiency of total snapshots — measure + +Total snapshots can repeat large queue payloads and tool batches. Do not weaken semantics preemptively. Add size benchmarks for long runs, large tool batches, and repeated queued writes. If needed, optimize physical backend representation while keeping `getCurrentOperation()` equivalent to header plus one total snapshot. + +### 8.4 Public API and identity follow-ups + +#### Missing model/provider replacement escape hatch — unresolved and important + +Idle lane configuration can be repaired with `setModel(validModel)`. An open operation may have captured the missing reference in its total snapshot; changing only lane configuration must not silently alter already-started generation under the prior contract. Possible APIs include an explicit operation-state repair method or runtime model-reference override registry. The API must be explicit, durable where necessary, and limited to missing identities rather than general in-flight mutation. + +#### Undeclared tool context generic — note for implementation + +`AgentHarnessOptions.toolContext` uses `TContext` while the shown interface is not generic. Make the options/harness/tool types consistently generic or use `unknown`. Do not block state-machine design on this. + +#### Per-entry effective usage query — note + +The design defines ledger-adjusted effective entry cost but exposes only immutable entry snapshots and session totals. Add a query such as `getEntryUsage(entryId)` if consumers need it. Defer until ledger API implementation. + +#### Adjustment `runId` — note + +Public `recordUsage()` cannot supply `runId` although adjustment records permit one. Prefer deriving the current operation ID on the lane mutation line when present rather than allowing arbitrary caller-provided operation IDs. + +#### Wide deferred-fetch return type — note + +The landed `Models.fetchDeferred()` type may return wide `AssistantMessage`. The harness adapter must reject/narrow a final `pending` value before hooks, events, persistence, or state settlement. Do not assign unrelated pi-ai work without checking the landed API. + +#### Temporary `HarnessNotImplemented` — note + +Define it as a scaffold-only promise rejection outside final public `Result` unions. Remove it operation by operation as owning packages land. + +#### Application message schema registry — note + +J6 requires runtime schemas for application-defined `AgentMessage` variants but no registration API is specified. A likely surface is immutable Session/repository open options keyed by the custom discriminator. Resolve with storage schema work. + +### 8.5 Work-package and document follow-ups + +#### R3 versus H0 main initialization — fix plan + +R3 owns restore but final reduction requires an initialized total lane configuration; H0 currently owns fresh/v3 main initialization later. Move one-time main seed initialization into R3 or restructure dependencies so restore never receives an unconfigured lane. “Restore writes nothing” should mean after optional first attachment initialization. + +#### D0 reservation marker — note + +The track prose reserves D0 indirectly but the package lacks the standard immediate reservation marker. Add the marker or document a track-level reservation exception. + +#### SQLite search follow-ups — assign owner + +Search completion, cursor/limit support, and indexed `findEntries` work need an explicit unchecked package owner, likely O4, or must be marked non-normative. + +#### Required reading — add reducer/current-state implementation + +The old required-reading list omits the reducer despite several packages depending on it. If the redesign lands, replace that reading with the new operation-state transition module and keep the old reducer only as pre-convergence history. + +#### Preserve implementation boundaries + +Telemetry fixes, public type cleanup, fork behavior, and work-package ownership are not reasons to reintroduce implicit operation reduction. Track them separately from the state-machine core. + +### 8.6 Validation required before adoption + +Prototype the total-snapshot model against these traces before replacing the canonical design: + +1. successful assistant generation; +2. retryable generation and crash with missing settlement; +3. overflow compaction requiring another assistant; +4. blocked tool versus abort in both commit orders; +5. started safe/unsafe tool crash and recovery; +6. terminal failure plus deferred user write; +7. terminal failure plus consumed steer followed by crash; +8. repeated application-driven deferred resumes with pending/ready/error results; +9. manual compaction empty/prepared/generated/hook paths; +10. summarized navigation, abort before final transaction, and atomic completion; +11. repeated abort before effect, during effect, and after finish; +12. missing identities for idle operation calls and resume. + +For every external effect, test crash before intent, after intent, and after atomic settlement. For every public race, test both lane-mutation orders. Compare automatic and manual drive durable snapshots and outcomes. From 157aa19c8cfa3acdddfccc4dcfaff672ddf165c3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 9 Aug 2026 01:45:19 +0200 Subject: [PATCH 067/284] docs(agent): clarify explicit operation records --- .../agent/docs/harness-v2-state-machine.md | 93 ++++++++++--------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/packages/agent/docs/harness-v2-state-machine.md b/packages/agent/docs/harness-v2-state-machine.md index 4fec8de7bea..372c8428140 100644 --- a/packages/agent/docs/harness-v2-state-machine.md +++ b/packages/agent/docs/harness-v2-state-machine.md @@ -11,7 +11,7 @@ An `AgentHarness` owns one durable session. The session contains: 1. **Conversation tree** — append-only message, compaction, branch-summary, and custom entries. 2. **Lanes** — permanent names pointing at tree leaves. Each lane has at most one open operation. 3. **Lane configuration** — one total replacement containing model reference, thinking level, and active tool names. -4. **Operational state** — an immutable operation header plus append-only total snapshots of the current operation. +4. **Operational state** — one immutable `OperationRecord` plus append-only total `OperationStateRecord`s. 5. **Usage ledger** — immutable usage records, independent of whether later orchestration succeeds. 6. **Global facts** — latest-wins name, labels, and custom facts. @@ -25,7 +25,7 @@ A lane accepts one of three operation kinds: - **compaction** — standalone manual compaction; - **navigation** — move to another tree entry, optionally with a summary. -An accepted operation has one durable header and a sequence of total state snapshots. The latest snapshot directly states what the operation is doing and what may happen next. +An accepted operation has one durable `OperationRecord` and a sequence of total state records. The latest `OperationStateRecord` directly states what the operation is doing and what may happen next. ### Effects @@ -47,16 +47,17 @@ External effects cannot generally be both durable and exactly once across proces Conversation persistence and provider context remain separate. Durable `error`, `aborted`, and `deferred` assistant responses do not project. Genuine output-limit `length` projects. Overflow compaction omits its exact superseded response from summary input and retained tail. Compaction entries remain self-contained context boundaries. -## 2. Replace implicit reduction with total operation snapshots +## 2. Replace implicit reduction with total operation state The current design reconstructs orchestration from combinations of records, entry presence, lane pointers, and later transitions. The redesign persists the continuation directly. -### Operation header +### Operation record -The header contains immutable acceptance data and is written once: +`OperationRecord` contains immutable acceptance data and is written once: ```ts -interface OperationHeader { +interface OperationRecord { + type: "operation"; operationId: string; lane: string; sourceLeafId: string | null; @@ -82,13 +83,13 @@ interface OperationHeader { } ``` -### Total operation snapshot +### Total operation state record -Every state transition appends a complete snapshot of all current mutable orchestration state for that operation: +Every state transition appends one record containing all current mutable orchestration state for that operation: ```ts -interface OperationSnapshotRecord { - type: "operation_snapshot"; +interface OperationStateRecord { + type: "operation_state"; id: string; lane: string; operationId: string; @@ -97,11 +98,11 @@ interface OperationSnapshotRecord { } ``` -`revision` starts at 1 and increases by exactly one. Snapshots are append-only; the latest revision is authoritative. +`revision` starts at 1 and increases by exactly one. State records are append-only; the latest revision is authoritative. -**Total means total.** A snapshot does not contain a patch and does not require older snapshots to interpret it. It contains the complete current workflow state, retry state, tool plan and per-call states, pending operation-owned queues and writes, deferred source, and cancellation control. It may reference immutable conversation entries, usage records, and the immutable operation header by ID, but no older operational snapshot supplies missing state. +**Total means total.** An `OperationStateRecord` is not a patch and needs no older state record for interpretation. It contains the complete current workflow state, retry state, tool plan and per-call states, pending operation-owned queues and writes, deferred source, and cancellation control. It may reference immutable conversation entries, usage records, and its immutable `OperationRecord` by ID, but no older operational state record supplies missing state. -The first implementation accepts the storage cost of total snapshots. Do not introduce delta chains, child-state logs, or patch replay to optimize them. If measurement later shows a problem, optimize physical encoding or compression while preserving the logical total-snapshot contract. +The first implementation accepts the storage cost of total state records. Do not introduce delta chains, child-state logs, or patch replay to optimize them. If measurement later shows a problem, optimize physical encoding or compression while preserving the logical total-state contract. ### Loading current state @@ -109,16 +110,16 @@ The public storage concept is: ```ts interface CurrentOperation { - header: OperationHeader; - snapshot: OperationSnapshotRecord; + operation: OperationRecord; + stateRecord: OperationStateRecord; } getCurrentOperation(lane: string): Promise; ``` -Backends answer through their latest-operation index. They read the immutable header and exactly one latest total snapshot. They do not scan or fold operation history, inspect entry absence to infer a phase, or collect partial task state from several operational logs. +Backends answer through their latest-operation index. They read one immutable `OperationRecord` and exactly one latest total `OperationStateRecord`. They do not scan or fold operation history, inspect entry absence to infer a phase, or collect partial task state from several operational logs. -Memory keeps the latest snapshot in a map. JSONL updates its latest-snapshot projection while replaying the file. SQLite keeps a current-operation projection and reads the selected snapshot/header by indexed ID. +Memory keeps the latest state record in a map. JSONL updates its latest-state projection while replaying the file. SQLite keeps a current-operation projection and reads the selected operation/state records by indexed ID. ### Records retained and replaced @@ -126,11 +127,11 @@ Retain: - total `lane_config` replacements; - immutable usage and adjustment records; -- operation header; -- total operation snapshots; +- immutable `OperationRecord`; +- total `OperationStateRecord`s; - facts, lane history, and conversation entries. -The total snapshot replaces the recovery authority currently spread across: +The total state record replaces the recovery authority currently spread across: ```text abort_requested @@ -298,7 +299,7 @@ type ToolCallState = }; ``` -The total operation snapshot contains the complete batch and every call state. This can duplicate data across snapshots; correctness and direct recovery take priority. Parallel tool execution remains possible: several calls may be `effect_pending`, while result commits remain source ordered. +The total operation state record contains the complete batch and every call state. This can duplicate data across state records; correctness and direct recovery take priority. Parallel tool execution remains possible: several calls may be `effect_pending`, while result commits remain source ordered. ### Deferred state @@ -341,7 +342,7 @@ Only transition functions may construct terminal state. This makes terminal vali Every durable boundary follows one rule: -> Compute one next total operation state, then atomically append all conversation, usage, fact, lane, and snapshot mutations that make that state true. +> Compute one next total operation state, then atomically append all conversation, usage, fact, lane, and operation-state mutations that make that state true. A transaction either commits all logical mutations or none. @@ -350,7 +351,7 @@ A transaction either commits all logical mutations or none. Plan before the effect: ```text -TX operation snapshot: +TX operation state: phase assistant generation effect_pending attempt 1 @@ -363,7 +364,7 @@ After the provider settles, classify in memory and commit settlement plus meanin ```text TX assistant entry R1 usage U1 - operation snapshot: + operation state: phase tools complete result-ID plan ``` @@ -373,7 +374,7 @@ or: ```text TX assistant entry R1 usage U1 - operation snapshot: + operation state: phase assistant retry_wait for attempt 2 ``` @@ -383,7 +384,7 @@ or: ```text TX assistant entry R1 usage U1 - operation snapshot: + operation state: phase compaction exact overflow response link resumeAfter need_assistant @@ -396,7 +397,7 @@ There is no durable response-without-usage or accounted-response-without-classif After clearance and immediately before execution: ```text -TX operation snapshot: +TX operation state: call i = effect_pending effective args and replay declaration stored ``` @@ -406,7 +407,7 @@ After execution/finalization: ```text TX tool usage, when present planned tool-result entry - operation snapshot: + operation state: call i = completed next continuation recorded when batch completes ``` @@ -415,11 +416,11 @@ If a crash leaves `effect_pending`, replay only when the declaration and impleme ### Queue or deferred-write application -Acceptance updates the total snapshot with the complete provisioned payload. Application is atomic: +Acceptance updates the total operation state with the complete provisioned payload. Application is atomic: ```text TX message/custom entry - operation snapshot with item removed + operation state with item removed continuation updated when the entry requires an assistant ``` @@ -441,7 +442,7 @@ TX generated usage, when present lane move to target exact branch-summary entry label fact, when present - finished operation snapshot + finished operation state ``` The summary entry chains from the moved target because mutations apply in order. A crash sees either an uncommitted navigation at its source or a fully completed navigation. No prepared-summary or post-move recovery state is needed. @@ -457,7 +458,7 @@ Determine whether useful context exists before operation acceptance. If not, ret ```ts async function drive(operation: CurrentOperation): Promise { while (true) { - const action = nextAction(operation.snapshot.state); + const action = nextAction(operation.stateRecord.state); switch (action.kind) { case "transition": @@ -498,12 +499,12 @@ Restore performs indexed reads only: ```text latest lane configuration -current operation header + latest total snapshot +current `OperationRecord` + latest total `OperationStateRecord` current lane leaf independent pending nextRun state ``` -`getCurrentOperation()` returns the materialized header and one latest total snapshot. No historical operation records or older snapshots are reduced. The snapshot directly selects the next interpreter action. +`getCurrentOperation()` returns one immutable `OperationRecord` and one latest total `OperationStateRecord`. No historical operation or state records are reduced. The state record directly selects the next interpreter action. The remaining unavoidable crash state is: @@ -518,9 +519,9 @@ For generation, a later attempt is allowed only under the captured generation re Before `prompt()`, `compact()`, or `navigateTree()` accepts work, the lane verifies that its configured model/provider and every active tool name can resolve. Missing identities return `MissingIdentities` and write nothing. The lane remains idle. -For an already-open operation, `resume()` verifies the identities required by its next effect. Missing identities return `MissingIdentities`, perform no effect, and leave the operation open at the same snapshot. +For an already-open operation, `resume()` verifies the identities required by its next effect. Missing identities return `MissingIdentities`, perform no effect, and leave the operation open at the same state record. -Registering the missing tools/providers/models unblocks execution. An explicit escape hatch is also needed to replace a missing model/provider referenced by existing lane or operation state. Its exact API and whether it rewrites a pending generation snapshot remain unresolved. +Registering the missing tools/providers/models unblocks execution. An explicit escape hatch is also needed to replace a missing model/provider referenced by existing lane or operation state. Its exact API and whether it rewrites pending generation state remain unresolved. ## 6. Storage and event boundaries @@ -544,11 +545,11 @@ Whether durable commit events, especially usage totals, must be published in str This section records decisions made while developing the redesign. A later session must not silently reopen them without a concrete contradiction or failing trace. -### 7.1 Total snapshots, not shallow or delta state +### 7.1 Total state records, not shallow or delta state -The latest operation snapshot is complete mutable operational state. Loading an operation reads its immutable header and exactly one latest snapshot. Do not split active generation, tool, queue, or deferred state into separate latest-value logs that must be collected and reconciled. Do not use patches or replay delta chains. +The latest `OperationStateRecord` is complete mutable operational state. Loading an operation reads its immutable `OperationRecord` and exactly one latest state record. Do not split active generation, tool, queue, or deferred state into separate latest-value logs that must be collected and reconciled. Do not use patches or replay delta chains. -Total snapshots may consume more storage. Correctness and direct recovery take priority. Measure before optimizing. Permitted future optimizations are physical compression, backend-internal structural sharing, or compact field encoding that still decodes one snapshot into the complete state. Tool batches are the likely worst case because every call state repeats after transitions. +Total state records may consume more storage. Correctness and direct recovery take priority. Measure before optimizing. Permitted future optimizations are physical compression, backend-internal structural sharing, or compact field encoding that still decodes one state record into the complete state. Tool batches are the likely worst case because every call state repeats after transitions. ### 7.2 One external-effect non-goal @@ -574,7 +575,7 @@ Existing projection rules remain. This redesign changes orchestration state, not `prompt()`, `compact()`, and `navigateTree()` check the lane's configured model/provider and active tool names before acceptance. If any are missing, return `MissingIdentities`, perform no effect, and write no operation. The lane remains idle until the application registers the missing identities or changes configuration. -`resume()` checks only identities required by the next effect. Missing identities return `MissingIdentities`, perform no effect, and leave the existing operation open at the same total snapshot. +`resume()` checks only identities required by the next effect. Missing identities return `MissingIdentities`, perform no effect, and leave the existing operation open at the same total state record. Tools are restored by registering every active tool name mentioned by the relevant configuration. An idle lane can replace a missing model with `setModel(validModel)`. An open operation may contain a captured missing model reference, so an explicit model/provider replacement escape hatch is needed; its API and durable semantics are unresolved. @@ -602,7 +603,7 @@ Single writer, lane mutation serialization, atomic non-empty append arrays, mono ### 7.12 Lane-level next-run state -`nextRun` exists independently of an operation. It therefore needs one total latest-value lane runtime record or equivalent current-state projection containing the complete pending next-run items. Run acceptance atomically removes the captured items from that total lane state, appends their entries, opens the operation, and writes its first total snapshot. It must not be reconstructed from queue history plus entry absence. +`nextRun` exists independently of an operation. It therefore needs one total latest-value lane runtime record or equivalent current-state projection containing the complete pending next-run items. Run acceptance atomically removes the captured items from that total lane state, appends their entries, opens the operation, and writes its first total operation state record. It must not be reconstructed from queue history plus entry absence. The exact type and whether this lane runtime record also points at the current operation remain to be specified. Its semantic requirement is total latest-value state, not a patch/event stream. @@ -722,15 +723,15 @@ Clarify that conversion writes a temporary file and atomically renames it over t The proposed/current `LogItem` distinguishes lane `create` and `move`, but the shown `lane_moves` table lacks an action column. Add it or define an unambiguous derivation. Explicit column is simpler. -#### Storage efficiency of total snapshots — measure +#### Storage efficiency of total state records — measure -Total snapshots can repeat large queue payloads and tool batches. Do not weaken semantics preemptively. Add size benchmarks for long runs, large tool batches, and repeated queued writes. If needed, optimize physical backend representation while keeping `getCurrentOperation()` equivalent to header plus one total snapshot. +Total state records can repeat large queue payloads and tool batches. Do not weaken semantics preemptively. Add size benchmarks for long runs, large tool batches, and repeated queued writes. If needed, optimize physical backend representation while keeping `getCurrentOperation()` equivalent to one `OperationRecord` plus one total `OperationStateRecord`. ### 8.4 Public API and identity follow-ups #### Missing model/provider replacement escape hatch — unresolved and important -Idle lane configuration can be repaired with `setModel(validModel)`. An open operation may have captured the missing reference in its total snapshot; changing only lane configuration must not silently alter already-started generation under the prior contract. Possible APIs include an explicit operation-state repair method or runtime model-reference override registry. The API must be explicit, durable where necessary, and limited to missing identities rather than general in-flight mutation. +Idle lane configuration can be repaired with `setModel(validModel)`. An open operation may have captured the missing reference in its total state record; changing only lane configuration must not silently alter already-started generation under the prior contract. Possible APIs include an explicit operation-state repair method or runtime model-reference override registry. The API must be explicit, durable where necessary, and limited to missing identities rather than general in-flight mutation. #### Undeclared tool context generic — note for implementation @@ -780,7 +781,7 @@ Telemetry fixes, public type cleanup, fork behavior, and work-package ownership ### 8.6 Validation required before adoption -Prototype the total-snapshot model against these traces before replacing the canonical design: +Prototype the total-state-record model against these traces before replacing the canonical design: 1. successful assistant generation; 2. retryable generation and crash with missing settlement; @@ -795,4 +796,4 @@ Prototype the total-snapshot model against these traces before replacing the can 11. repeated abort before effect, during effect, and after finish; 12. missing identities for idle operation calls and resume. -For every external effect, test crash before intent, after intent, and after atomic settlement. For every public race, test both lane-mutation orders. Compare automatic and manual drive durable snapshots and outcomes. +For every external effect, test crash before intent, after intent, and after atomic settlement. For every public race, test both lane-mutation orders. Compare automatic and manual drive durable state records and outcomes. From 936aff00918de1187f085f123c2812d8f2d67745 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 9 Aug 2026 02:11:00 +0200 Subject: [PATCH 068/284] docs(agent): complete explicit-state harness design --- .../agent/docs/harness-v2-state-machine.md | 424 +++++++++++++++++- 1 file changed, 415 insertions(+), 9 deletions(-) diff --git a/packages/agent/docs/harness-v2-state-machine.md b/packages/agent/docs/harness-v2-state-machine.md index 372c8428140..89234e0e508 100644 --- a/packages/agent/docs/harness-v2-state-machine.md +++ b/packages/agent/docs/harness-v2-state-machine.md @@ -47,6 +47,20 @@ External effects cannot generally be both durable and exactly once across proces Conversation persistence and provider context remain separate. Durable `error`, `aborted`, and `deferred` assistant responses do not project. Genuine output-limit `length` projects. Overflow compaction omits its exact superseded response from summary input and retained tail. Compaction entries remain self-contained context boundaries. +### Why this is better + +The old design persisted many small orchestration events and reconstructed a hidden program counter from their combinations and from entry absence. One assistant settlement could be durable as response-only, response-plus-usage, response-plus-usage-plus-tool-plan, or several other prefixes. Queue status, failure clearance, and navigation progress were likewise inferred. + +The redesign persists the program counter directly as one total `OperationStateRecord` and commits output, accounting, and the next state atomically. This gives five concrete benefits: + +1. **Direct recovery.** Load one immutable operation record and one latest total state record; do not fold operation history. +2. **Fewer crash states.** A repeat-sensitive effect has only intent absent, intent present with settlement absent, or settlement and next state committed. +3. **Exhaustive transitions.** A pure transition function switches on explicit state and input. Missing cases are visible in types and tables. +4. **Local terminal validity.** Only transitions from eligible states can create a finished state; finish validity is not a historical audit. +5. **Mechanical testing.** Crash before intent, after intent, and after atomic settlement. Public races have two mutation-line orders. + +The trade-off is repeated state data. The first implementation accepts that cost. It must not recover space by reintroducing patches, partial child logs, or historical state collection. + ## 2. Replace implicit reduction with total operation state The current design reconstructs orchestration from combinations of records, entry presence, lane pointers, and later transitions. The redesign persists the continuation directly. @@ -149,11 +163,86 @@ operation_finished Some implementation may retain compact audit records, but recovery and transition validity must never depend on them. +### Lane state and inbox records + +`nextRun` belongs to a lane even when no operation is open. Its current state is therefore a separate total lane record: + +```ts +interface LaneStateRecord { + type: "lane_state"; + id: string; + lane: string; + revision: number; + currentOperationId: string | null; + pendingNextRun: QueuedInput[]; +} + +interface QueuedInput { + entryId: string; + message: ProvisionedEntry; +} +``` + +The latest `LaneStateRecord` is total. It is not reconstructed from queue history or entry absence. `getCurrentLaneState(lane)` returns that one record. Run acceptance atomically removes captured next-run items, appends their entries, sets `currentOperationId`, writes the immutable operation record, and writes the first total operation state. + +Run-owned input is contained completely in `ActiveRunState`: + +```ts +interface RunInbox { + steer: QueuedInput[]; + followUp: QueuedInput[]; + writes: PendingWrite[]; +} + +interface PendingWrite { + entryId: string; + entry: ProvisionedEntry; +} +``` + +Queue acceptance writes a new total lane or operation state before resolving. Consumption atomically appends the entry and removes the item from total state. Deferred writes survive abort; steer and follow-up are moved into durable abort control and removed from the active inbox by the first abort transaction. + +Cancellation needs history only for an exact item lookup after it leaves current state. A compact disposition record supplies that without participating in recovery: + +```ts +interface QueueDispositionRecord { + type: "queue_disposition"; + id: string; + lane: string; + entryId: string; + disposition: "cancelled" | "cleared_by_abort"; +} +``` + +`cancelQueued(entryId)` runs on the lane mutation line: + +- pending in lane `nextRun` or run steer/follow-up: atomically remove it and append `cancelled`; +- target entry exists or was captured and appended by run acceptance: `already_consumed`; +- disposition exists: `already_cleared`; +- otherwise: `UnknownQueueItem`. + +Capture and entry append occur in the same run-acceptance transaction, so no captured-but-unappended state exists. Queue modes affect which pending steer/follow-up items a checkpoint consumes, but every consumed set is removed and appended atomically. + +| Public input | Admission | Total-state transition | +|---|---|---| +| `nextRun` | any open harness state | append to `LaneStateRecord.pendingNextRun`; never starts a run | +| `steer` | active running run | append complete item to `ActiveRunState.inbox.steer` | +| `followUp` | active running run | append complete item to `ActiveRunState.inbox.followUp` | +| lane-view tree write during run | active run, including suspension/cancellation | append complete item to `inbox.writes`; survives abort | +| lane-view tree write while idle | idle lane | append entry directly and advance leaf | +| lane-view tree write during compaction/navigation | structural operation open | wait until structural operation ends, then re-evaluate | +| `cancelQueued` | item currently pending | remove from total state and append disposition atomically | +| checkpoint consumes input | eligible pending item | append entry, remove item, and update continuation atomically | +| first abort | running run | move steer/follow-up into durable abort control; writes remain pending | +| finish | inbox empty and no required continuation | final operation state and lane current-operation clear atomically | + +Acceptance, cancellation, application, abort, and finish all run on the same lane mutation line. Thus each race has only caller-A-first or caller-B-first history; no item can be both pending and applied in durable current state. + ## 3. Operation state ```ts type OperationState = - | RunOperationState + | ActiveRunState | ManualCompactionState | NavigationOperationState | FinishedOperationState; @@ -183,9 +272,7 @@ interface ActiveRunState { kind: "run"; control: OperationControl; phase: RunPhase; - pendingSteer: ProvisionedEntry[]; - pendingFollowUp: ProvisionedEntry[]; - pendingWrites: ProvisionedEntry[]; + inbox: RunInbox; } type RunPhase = @@ -220,13 +307,14 @@ type CheckpointContinuation = | { kind: "need_assistant"; triggerMessageId: string; + overflowRecoveryUsed: boolean; } | { kind: "may_finish"; }; ``` -`continuation` replaces inference such as `needsAssistant()`. A compaction stores the continuation it must resume. Overflow compaction resumes `need_assistant` with the same trigger. Applying a new user-context message changes the continuation to `need_assistant` with that message ID in the same atomic transaction. +`continuation` replaces inference such as `needsAssistant()`. A compaction stores the continuation it must resume. Overflow compaction resumes `need_assistant` with the same trigger and `overflowRecoveryUsed: true`; another recoverable overflow for that trigger enters failure drain. Applying a new user-context message atomically changes the continuation to `need_assistant` with that message ID and resets the flag to false. ### Generation state @@ -264,6 +352,58 @@ type GenerationState = `RetryPolicy` applies to generation requests, including generated summaries. It does not impose a retry or polling cap on deferred fetch. +### Structural decision and summary state + +Manual compaction, auto-compaction, and summarized navigation first enter a decision state. The decision hook may decline, supply a complete result, or select generated work. A crash while the hook is running reruns it; hook-owned side effects follow the external-effect non-goal. Once generated work is selected, the state change is durable and the decision hook does not run again. + +```ts +type StructuralDecisionState = + | { + status: "deciding"; + } + | { + status: "generating"; + generation: SummaryGenerationState; + }; + +interface SummaryGenerationContext { + taskId: string; + resultEntryId: string; + kind: "compaction" | "branch_summary"; + configuration: LaneConfiguration; + retryPolicy: RetryPolicy; + reason?: "manual" | "threshold" | "overflow"; + overflow?: { + supersededResponseEntryId: string; + triggerMessageId: string; + }; +} + +type SummaryGenerationState = + | { + status: "ready"; + context: SummaryGenerationContext; + nextAttempt: number; + } + | { + status: "effect_pending"; + context: SummaryGenerationContext; + attempt: number; + usageRecordIds: string[]; + } + | { + status: "retry_wait"; + context: SummaryGenerationContext; + nextAttempt: number; + notBefore: number; + errorMessage: string; + }; +``` + +One structural attempt may make one or two provider requests. Before its first request, total state becomes `effect_pending`. After each request, reported usage and a new total state containing its usage record ID commit atomically. Intermediate response content need not persist; a crash before the final structural transaction makes the whole attempt uncertain and starts a later numbered attempt only under the captured generation policy. Failed-attempt usage remains in the ledger. + +Hook-supplied compaction and branch-summary entries set `fromHook: true`; generated entries set it false. Hook usage, when present, commits atomically with the structural result. Generated result usage is the sum of successful-attempt request usage records. + ### Tool batch state ```ts @@ -309,19 +449,75 @@ type DeferredState = status: "suspended"; stepId: string; sourceEntryId: string; + poll: number; configuration: LaneConfiguration; } | { status: "effect_pending"; stepId: string; sourceEntryId: string; + poll: number; responseEntryId: string; usageRecordId: string; configuration: LaneConfiguration; }; ``` -Each `resume()` performs at most one `fetchDeferred(..., { wait: 0 })`. The application decides whether and when to call `resume()` again. Deferred polling has no harness retry count, retry cap, or retry sleep. A pending response becomes the next source. Provider terminal errors fail the run. Provider behavior such as expiration or cancellation support is outside harness control. +The original assistant generation that returns `deferred` atomically writes its response/usage and enters `suspended`, copying that generation's total configuration once. The exact source entry supplies provider, model, and complete handle; copied active tool names govern a ready response's tool calls. + +Each `resume()` performs at most one `fetchDeferred(..., { wait: 0 })`. It atomically changes `suspended` to `effect_pending` before polling. The application decides whether and when to call `resume()` again. Deferred polling has no harness retry count, retry cap, or retry sleep. + +Settlement is atomic: + +- another `deferred` response: append response/usage, require complete handle equality, increment `poll`, and suspend on the new response entry; +- ready response: append response/usage and move to tools or the appropriate checkpoint continuation; +- provider error or rejected fetch converted to error: append response/usage and enter failure drain; +- unmarked returned `aborted`: append response/usage and suspend on the unchanged source; the application may resume again; +- durable cancellation: best-effort cancel the newest source handle and settle any already-planned response under its ID as `aborted`. + +If a process dies with a poll `effect_pending`, the remote check may have happened but no settlement is durable. A later application `resume()` provisions a fresh poll and response/usage IDs; no retry cap is applied. Provider behavior such as expiration or cancellation support is outside harness control. + +### Manual compaction state + +```ts +interface ManualCompactionState { + kind: "compaction"; + control: OperationControl; + customInstructions?: string; + structural: StructuralDecisionState; +} +``` + +Admission computes preparation against the source leaf. Empty preparation returns `NothingToCompact` before acceptance. Acceptance atomically writes `OperationRecord`, `ManualCompactionState` in `deciding`, and `LaneStateRecord.currentOperationId`. In `deciding`, `before_compaction` may decline, supply a complete compaction, or select generated work. Decline or hook-supplied completion commits finished state directly. Generated work follows `SummaryGenerationState`; success atomically commits usage, the complete `CompactionEntry`, and finished state. + +### Navigation state + +```ts +type NavigationOperationState = + | { + kind: "navigation"; + control: OperationControl; + targetId: string | null; + label?: string; + customInstructions?: string; + summarize: false; + phase: { kind: "ready_to_commit" }; + } + | { + kind: "navigation"; + control: OperationControl; + targetId: string; + label?: string; + customInstructions?: string; + summarize: true; + phase: { + kind: "summary"; + structural: StructuralDecisionState; + }; + }; +``` + +After target/source validation, acceptance atomically writes `OperationRecord`, the appropriate navigation state, and `LaneStateRecord.currentOperationId`. Unsummarized navigation has no decision hook. Summarized navigation runs `before_navigation`, which may decline, supply a complete summary, or select generated work. All source-tree reads and provider/hook work happen before the final structural transaction. Completion atomically moves the lane, appends the exact summary when required, writes the label when present, writes finished operation state, and clears `LaneStateRecord.currentOperationId`. ### Terminal state @@ -346,6 +542,57 @@ Every durable boundary follows one rule: A transaction either commits all logical mutations or none. +### Lane configuration + +`lane_config` remains a separate total latest-value record containing model reference, thinking level, and active tool names. `AgentHarnessOptions` supplies an immutable seed used for first attachment of `main` and every later `createLane`; anchors and other lanes never supply configuration. Configured lane creation atomically creates the pointer and first total config. + +`setModel`, `setThinkingLevel`, and `setActiveTools` immediately commit one total replacement on the lane mutation line, including during an operation or cancellation. Starting a generation snapshots the current configuration into operation state in the same lane ordering. Later setters affect only later generations; retries keep the generation's captured value. Tool implementations and contexts remain environmental and are resolved by captured active names immediately before a real invocation. + +### Run acceptance + +`skill()` / `promptFromTemplate()` resource expansion and prompt normalization happen before acceptance. `before_run` is an effect before the lane acceptance transaction; it receives only the normalized caller prompt, not pending next-run items. Its returned messages, optional system-prompt override, and resume data are held until acceptance. A concurrent winner may make the lane busy, in which case the hook output is discarded and no operation is written. + +The acceptance transaction validates idle state and identities, captures all pending next-run input, and atomically writes: + +```text +TX updated LaneStateRecord: + captured nextRun removed + currentOperationId = O + OperationRecord O + captured nextRun entries + caller prompt entries + before_run injected entries + first OperationStateRecord: + run checkpoint + continuation need_assistant(newest user-context entry) + inbox empty +``` + +The operation call resolves only after this transaction. There is no accepted operation with missing initial entries. + +### Checkpoint and finish boundary + +At a run checkpoint, transitions occur in this order: + +1. atomically apply accepted deferred writes; +2. atomically consume eligible steering according to steering mode; +3. run threshold compaction when required, preserving the current continuation; +4. if continuation is `need_assistant`, start generation; +5. after assistant/tool continuation is exhausted, atomically consume eligible follow-up input; +6. when continuation is `may_finish` and inbox is empty, invoke `before_run_end`; +7. conditionally finish. + +A `before_run_end` follow-up is committed only if control is still running and the operation is still at the same finish boundary. Its message entry and `need_assistant` state commit atomically. Abort or another input that wins first drops the stale hook result. + +Finish is one lane mutation transaction: + +```text +TX final OperationStateRecord + LaneStateRecord.currentOperationId = null +``` + +It commits only when total state proves no required work remains. Steer/follow-up acceptance, deferred writes, abort, and finish are serialized, so only input-first or finish-first histories exist. + ### Assistant attempt Plan before the effect: @@ -392,6 +639,30 @@ TX assistant entry R1 There is no durable response-without-usage or accounted-response-without-classification state. +### Assistant settlement classifier + +Classification is pure and runs before the atomic settlement transaction. Cancellation control takes priority. Without cancellation, evaluate in this order: + +| Durable response | Next state | +|---|---| +| explicit provider context-limit error | overflow handling | +| `stop` with reported input plus cache-read greater than captured context window | overflow handling | +| Xiaomi-compatible zero-output/full-window pressure | overflow handling | +| `length` with output below captured intended output limit | overflow handling | +| `deferred` with valid handle | deferred suspended | +| unmarked `aborted`, attempts remain | generation retry wait | +| unmarked `aborted`, no attempts remain | failure drain | +| retryable `error`, attempts remain | generation retry wait | +| other `error` | failure drain | +| `toolUse` or accepted response with calls | tools with complete result plan | +| `stop` or genuine output-limit `length` | checkpoint `may_finish` | + +`intendedOutputLimit` is the caller's explicit limit or model limit before context clamping. The percentage heuristic is only the existing Xiaomi-compatible signal. Overflow handling never creates a tool plan. + +For overflow, `need_assistant` carries `overflowRecoveryUsed`. If false, enter compaction with the exact superseded response/trigger and resume with the flag true. If already true, enter failure drain. Consuming newer user-context input creates a new trigger and resets the flag. + +A genuine output-limit `length` remains in provider context. If it contains tool calls, create the complete batch plan but execute no call; append one planned `isError: true` result per call explaining that truncation may have left arguments incomplete. Those results require another assistant generation. + ### Tool call After clearance and immediately before execution: @@ -414,6 +685,23 @@ TX tool usage, when present If a crash leaves `effect_pending`, replay only when the declaration and implementation are safe; otherwise append the planned interrupted result. +The complete batch plan exists before tool lookup, argument validation, or `before_tool`. Calls are identified privately by source index; public hooks/events/tool context use provider `toolCallId` and tool name. Tool IDs are required to be response-local unique. + +| Call state/input | Atomic result and next state | +|---|---| +| planned, unknown tool or invalid arguments | planned error result; mark completed | +| planned, `before_tool` blocks or throws | planned blocked error; mark completed | +| planned, control cancelled | planned aborted error; mark completed | +| planned, clearance succeeds | total state becomes `effect_pending`; then dispatch effect | +| live effect settles | run `after_tool`; usage + finalized result + completed state | +| restored effect pending, replay safe now and when started | re-execute persisted args, finalize, usage + result + completed state | +| restored effect pending, replay unsafe | interrupted error + completed state | +| genuine `length` planned call | explanatory error + completed state; no clearance/effect | + +`after_tool` may patch content, details, error status, usage, and `terminate`; the finalized decision is stored in the result entry/state. Hook output must satisfy its contract. A hook crash before the atomic result transaction may rerun after a safe replay. + +Sequential mode clears, starts, executes, finalizes, and commits one call before the next. Parallel mode performs clearance and effect-intent commits in source order, dispatches effects in source order without awaiting earlier ones, allows concurrent settlement, and commits finalized results in source order. Several calls may therefore be durable `effect_pending` while results still form a source-order prefix. + ### Queue or deferred-write application Acceptance updates the total operation state with the complete provisioned payload. Application is atomic: @@ -426,6 +714,28 @@ TX message/custom entry A crash cannot consume an item without updating operation state, or update operation state without appending the entry. +### Structural decision and generation transitions + +| State/input | Atomic result and next state | +|---|---| +| deciding, hook declines | finished `declined` for standalone operation; threshold returns to prior continuation; overflow enters failure drain | +| deciding, hook supplies result | usage + exact typed entry + continuation/finished state | +| deciding, hook selects generation | total state with generated summary `ready`; hook will not rerun | +| generation ready or retry elapsed | total state `effect_pending`; then provider request(s) | +| generation retryable failure | reported usage + retry-wait state | +| generation terminal/exhausted | standalone finished failed or run failure drain | +| generated compaction succeeds | usage + compaction entry + prior continuation/finished state | +| generated branch summary succeeds | usage + move + summary + label + finished state | +| cancellation before structural commit | reported usage still commits; generated result is discarded; finish aborted | + +Structural provider streams are internal and emit no public assistant-message lifecycle. Every provider request still crosses the effect boundary and writes reported usage before another request or structural completion. Hook-provided details remain opaque; generated details may use harness-owned structure. + +### Automatic compaction + +Threshold and overflow compaction are run phases, not nested operations. Threshold compaction preserves the continuation that caused the context check. Hook decline or empty useful preparation returns to that continuation because threshold compaction is proactive. + +Overflow compaction carries the exact superseded response entry and trigger in `SummaryGenerationContext`, omits that response from preparation and `retainedTail`, and resumes `need_assistant` with `overflowRecoveryUsed: true`. Hook decline or empty preparation enters failure drain because the rejected request cannot fit without compaction. + ### Navigation Reject before acceptance when: @@ -451,6 +761,50 @@ The summary entry chains from the moved target because mutations apply in order. Determine whether useful context exists before operation acceptance. If not, return `NothingToCompact` and write nothing. Successful settlement atomically appends usage, the compaction entry, and finished state. +### Transition summary + +This is the normative high-level machine. Detailed transition functions must refine, not contradict, it. + +| Current state | Trigger | Durable transaction | Next state | +|---|---|---|---| +| idle lane | accepted prompt | lane state + operation record + initial entries + total operation state | run checkpoint `need_assistant` | +| checkpoint `need_assistant` | drive | generation intent state | assistant effect pending | +| assistant effect pending | settled response | response + usage + classified total state | retry/tools/deferred/compaction/checkpoint/failure | +| assistant retry wait | delay elapsed | next generation intent state | assistant effect pending | +| tools planned | clearance succeeds | call effect-pending total state | tools with started call | +| tool effect pending | finalized result | usage + result + total state | tools or checkpoint | +| checkpoint | accepted steer/follow-up/write | total state containing item | same checkpoint | +| checkpoint | apply/consume item | entry + total state without item | `need_assistant` or prior continuation | +| checkpoint | context threshold | compaction decision state | compaction | +| compaction deciding | hook result | decline/result/generated-source transaction | continuation/generating/failure | +| summary effect pending | provider outcome | usage + retry or final structural transaction | retry/continuation/finished/failure | +| assistant response deferred | settlement | response + usage + copied deferred state | suspended | +| deferred suspended | one `resume()` | poll intent state | deferred effect pending | +| deferred effect pending | poll settles | response + usage + classified state | suspended/tools/checkpoint/failure | +| failure drain | new user-context item applied | entry + total state | checkpoint `need_assistant` | +| finish boundary | no hook follow-up or pending work | final state + lane state clears operation | finished | +| any active state | first abort | cancellation control + drained inbox | same workflow under cancellation | +| cancellation control | reconciliation complete | required results/writes + final state + clear lane operation | finished aborted | + +### Crash table + +Atomic transactions have no internal crash prefix. For each repeat-sensitive effect, only these states exist: + +| Crash point | Durable state | Recovery | +|---|---|---| +| before effect-intent transaction | previous total state | plan the effect normally | +| after effect intent, before dispatch | effect pending; effect did not run or dispatch status was lost | apply the effect's uncertainty policy; cancellation prevents dispatch | +| during/after external effect, before settlement transaction | effect pending; external outcome unknown | generation retries under captured policy, tools replay only when safe, deferred waits for another application resume, cancellation settles as specified | +| after settlement transaction | output, usage, and next total state all durable | continue from next state; never repeat settlement | +| before queue/write application transaction | item remains fully pending in total state | apply later | +| after queue/write application transaction | entry exists and item is absent; continuation updated | continue; never apply twice | +| before final structural transaction | source leaf and generated/hook work remain uncommitted | retry/recompute only according to current state and external-effect policy | +| after final structural transaction | move/entry/label/usage/finished state all durable | operation is complete | +| after first abort transaction | cancellation and drained payloads durable | never start new ordinary effects; reconcile pending workflow | +| after terminal transaction | finished state and lane clear are durable | lane is idle | + +The unavoidable uncertain interval is effect intent durable with settlement absent. Provider, tool, hook, and billing examples all belong to the single external-effect non-goal. + ## 5. Interpreter, abort, and recovery ### Interpreter @@ -523,6 +877,30 @@ For an already-open operation, `resume()` verifies the identities required by it Registering the missing tools/providers/models unblocks execution. An explicit escape hatch is also needed to replace a missing model/provider referenced by existing lane or operation state. Its exact API and whether it rewrites pending generation state remain unresolved. +### Effects boundary and manual drive + +Every durable transition, provider request/fetch, individual tool invocation, hook invocation, and timer crosses one `Effects` method. Procedures receive no direct Session, Models, tool registry, or hook runner. Pure state calculation, immutable tree/context reads, and ID allocation are not gated effects; after any awaited read, the next effect/commit revalidates current total state and cancellation control. + +Automatic drive executes the interpreter. Manual drive parks before each effect and exposes one JSON-safe action. `peekAction()` is stable and side-effect free; `executeAction()` releases exactly one action; `runToCompletion()` releases nested actions before awaiting parents. Lane-surface operations such as steer, cancellation, configuration setters, and writes remain ungated so tests can exercise both race orders. + +Closing while an action is parked rejects it without execution. The durable state is exactly the prefix of committed total state records and effect intents. + +### Close + +Close is process lifecycle, not operation abort. It writes no cancellation or terminal operation state. It stops public admission, signals cooperative in-flight effects, rejects parked/local operation promises, lets already-admitted lane mutations and Session appends settle, drains storage, and releases only its writer claim. A prior effect intent may remain without settlement; reopening loads that total state and ordinary recovery applies. Durable open operations remain resumable. + +### Hook replay summary + +- `before_run`: before acceptance; output commits in the acceptance transaction; reruns only when no operation was accepted. +- `transform_context`, `before_request`, and `before_payload`: per provider request; ephemeral and may rerun with a repeated request. +- `after_response`: transforms the settled message before `message_end` and atomic settlement; `streamAssistant` still needs an explicit final-message callback to mount it. +- `before_tool`: runs while call is planned; effective args become durable only in effect-pending state; reruns if that state did not commit. +- `after_tool`: runs after a real effect or safe replay; output becomes durable with usage/result/completed state. +- compaction/navigation decision hooks: run in `deciding`; generated-source state prevents rerun; supplied output commits directly with the result. +- `before_run_end`: may rerun at the same finish boundary; its returned follow-up commits conditionally. + +Hook-owned external side effects must be idempotent under the external-effect non-goal. + ## 6. Storage and event boundaries The redesign assumes the existing storage contract: @@ -541,6 +919,24 @@ Lifecycle events such as streaming updates remain process ordered. Events that c Whether durable commit events, especially usage totals, must be published in strict global `seq` order remains unresolved. Strict ordering is more faithful to durable state but may briefly buffer a later lane's commit event until an earlier lane has installed and queued its event. Storage already resolves append promises in commit order, and state installation must contain no `await`, so expected buffering is small; this needs implementation-level validation before becoming a requirement. +### Storage representation of new state + +All backends expose latest total lane/operation state through indexes or replayed projections: + +- Memory: latest lane state, open operation ID, immutable operation record, and latest operation state maps; +- JSONL: ordinary object for one mutation and one array line for an atomic transition; replay updates latest-state/open-operation projections; a torn final array is discarded wholly; +- SQLite: append-only operation-state rows plus a current lane/open-operation projection, all updated in the same transition transaction. + +A finished transition appends final operation state and clears the lane's current-operation projection atomically. Forks copy conversation, current facts, pointers, and fresh lane configuration, but no operation or usage state. Coding-agent v3 normalization still opens one idle, initially unconfigured `main`; first harness attachment seeds its total lane configuration. + +### Public surface consequences + +Existing lane methods remain: `prompt`, `skill`, `promptFromTemplate`, `compact`, `navigateTree`, `resume`, `abort`, `steer`, `followUp`, `nextRun`, `cancelQueued`, `recordUsage`, configuration setters/getters, lane tree view, `watch`, `waitForIdle`, `runWhenIdle`, manual drive, and `close`. Harness lane management (`lane`, `createLane`, `lanes`) and `watchSession` remain. Expected caller failures use `Result`; storage faults, close, and invariant defects may reject. + +`LaneSnapshot.operation` is derived directly from current total state. It reports running, suspended, or cancelling; streaming drafts and running tools remain process-local additions. `SuspendedOperation.missing` reports identities required by the next effect. Reconnect obtains a new snapshot and non-replayed event stream. + +`message_end` remains stream completion before durable settlement. An atomic settlement publishes `entry_added` for its entry after commit and then usage/operation events in logical mutation order. Internal structural provider streams emit no public assistant message lifecycle. Events and hooks may contain sensitive content; telemetry may not. + ## 7. Decisions from the design session This section records decisions made while developing the redesign. A later session must not silently reopen them without a concrete contradiction or failing trace. @@ -603,9 +999,7 @@ Single writer, lane mutation serialization, atomic non-empty append arrays, mono ### 7.12 Lane-level next-run state -`nextRun` exists independently of an operation. It therefore needs one total latest-value lane runtime record or equivalent current-state projection containing the complete pending next-run items. Run acceptance atomically removes the captured items from that total lane state, appends their entries, opens the operation, and writes its first total operation state record. It must not be reconstructed from queue history plus entry absence. - -The exact type and whether this lane runtime record also points at the current operation remain to be specified. Its semantic requirement is total latest-value state, not a patch/event stream. +`nextRun` exists independently of an operation. `LaneStateRecord` is its total latest-value durable state and also names the current operation ID. Run acceptance atomically removes captured items, appends their entries, opens the operation, and writes its first total operation state record. Neither next-run state nor operation capture is reconstructed from queue history plus entry absence. ### 7.13 Commit-event ordering @@ -617,6 +1011,18 @@ This section preserves the full audit result and its current disposition so a la ### 8.1 Runtime and recovery findings +#### Inbox omitted from the first redesign draft — addressed + +The first draft mentioned pending input but did not define lane-level next-run state, operation-owned inbox state, acceptance, cancellation, capture, application, abort clearing, or race behavior. Sections 2 and 4 now define total lane state, complete run inbox payloads, disposition records used only for exact historical lookup, and atomic transition rules. + +#### Structural states referenced but undefined — addressed + +The first draft named manual compaction, navigation, and summary-generation states without defining them. Section 3 now defines their state and section 4 defines decision, generation, completion, failure, decline, and cancellation transitions. + +#### Acceptance and finish boundaries omitted — addressed + +The first draft did not model initial prompt/next-run/hook entry commit or `before_run_end` and finish races. Section 4 now defines both atomic boundaries and their lane-mutation ordering. + #### Tool outcome racing abort — addressed by redesign Old failure: `before_tool` produced a blocked result, abort committed before the result append, and normal execution and abort reconciliation could append different content under one planned result ID. From 47610217098d9ba8f22d223fa7c1413f9f5fd759 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 10 Aug 2026 08:03:46 +0200 Subject: [PATCH 069/284] chore(docs): change 'reloads' phrasing in compaction docs --- packages/coding-agent/docs/compaction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 788348eeac7..2593863cf65 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -42,7 +42,7 @@ You can also trigger manually with `/compact [instructions]`, where optional ins 2. **Extract messages**: Collect messages from the previous kept boundary (or session start) up to the cut point 3. **Generate summary**: Call LLM to summarize with structured format, passing the previous summary as iterative context when present 4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId` -5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards +5. **Reload**: Session rebuilds context, using summary + messages from `firstKeptEntryId` onwards ``` Before compaction: From 31b513e316ab2b5ec736268350635511297fa3c1 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 10 Aug 2026 08:27:35 +0200 Subject: [PATCH 070/284] chore(docs): change a bullet point in compaction docs --- packages/coding-agent/docs/compaction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 2593863cf65..4bf01c9d21a 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -42,7 +42,7 @@ You can also trigger manually with `/compact [instructions]`, where optional ins 2. **Extract messages**: Collect messages from the previous kept boundary (or session start) up to the cut point 3. **Generate summary**: Call LLM to summarize with structured format, passing the previous summary as iterative context when present 4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId` -5. **Reload**: Session rebuilds context, using summary + messages from `firstKeptEntryId` onwards +5. **Rebuilds context**: Session rebuilds the context for the next request, using summary + messages from `firstKeptEntryId` onwards ``` Before compaction: From 3059b813161b9408144ce394283d95b198e6df44 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 10 Aug 2026 13:44:36 +0200 Subject: [PATCH 071/284] docs(agent): add durable harness implementation spec --- packages/agent/docs/agent-harness-spec.md | 2541 +++++++++++++++++++++ 1 file changed, 2541 insertions(+) create mode 100644 packages/agent/docs/agent-harness-spec.md diff --git a/packages/agent/docs/agent-harness-spec.md b/packages/agent/docs/agent-harness-spec.md new file mode 100644 index 00000000000..a8bf08f6098 --- /dev/null +++ b/packages/agent/docs/agent-harness-spec.md @@ -0,0 +1,2541 @@ +# AgentHarness — implementation specification + +**Status:** supersedes `harness-v2.md` in full. Where the two disagree, this document wins. Appendix B lists the substantive changes and why. + +**Audience:** an engineer who has never seen this system and has to build it from this document and the existing source tree. Part 0 explains what it is. Parts 1–5 specify it. Part 6 is the build order. Part 7 is what must be true when you are done. Types owned by this design are defined here; existing agent, provider, compaction, and telemetry types are named with their source paths in §0.7. + +--- + +# Part 0 — Orientation + +## 0.1 What this is + +A durable runtime for agent conversations. You hand it a prompt; it talks to a language model, runs tools, and produces a response. The difference from an ordinary agent loop is that **the process can die at any instant** — mid-stream, between a tool call and its result, halfway through a summary — and a new process picks up exactly where the old one stopped, without repeating durable work and without losing anything that had committed. + +It is a library, not a server. One process owns one session at a time. + +## 0.2 Three concepts + +### Session — the conversation + +A session is one conversation, stored as a **tree** rather than a list. + +``` +a ── b ── c ── d + └── e ── f +``` + +A tree, because three features need history that does not move: branching (explore an approach, back out, keep the record), compaction (replace a long prefix with a summary while the original stays queryable), and forking (copy a prefix into a new session). Entries are appended and never modified or deleted. + +A session also holds **facts** (session name, entry labels, application key-value state — latest write wins, not part of the tree) and a **usage ledger** (every token and cost event, append-only). + +### Lane — a cursor into the conversation + +A lane is a **name plus a leaf**: the entry that new work extends. Every session has `main`. Applications create more. + +A lane owns its leaf, its configuration (model, thinking level, active tools), its queues, and at most one operation in flight. Lanes run in parallel and share nothing except the tree beneath them. + +Why lanes exist: a Slack channel is one session, and each thread is a lane. Threads share the channel's history but take turns independently. Two lanes can sit on the same entry and diverge on their next append — the tree handles that, and no coordination is needed. + +**Lane vs fork:** a lane *shares* history; a fork *copies* it for isolation. Use a lane for a thread in a shared conversation, a fork for a subagent, an export, or a what-if. + +### Harness — what runs a lane + +The harness is the API surface. Per lane: `prompt`, `steer`, `followUp`, `nextRun`, `abort`, `resume`, `compact`, `navigateTree`, plus configuration getters and setters and a tree view. Harness-wide: lane management, tool and resource registries, hooks, events. + +An **operation** is one accepted unit of work on a lane — a `run` (prompt to final answer, including all tool calls), a `compaction`, or a `navigation`. One per lane at a time. + +## 0.3 Worked example — a Slack thread + +A user posts in a channel that already has 400 entries of history. The application creates a lane for the thread, anchored at the channel's current leaf. + +``` +harness.createLane("slack:1719432.0021", at: "e_400") +lane.prompt("what changed in auth last week?") +``` + +What happens, in order: + +1. **Acceptance.** The harness validates, runs the `before_run` hook, and commits one transaction: the user message, the operation, and the operation's first state — *"I am at a checkpoint, and I need an assistant response."* +2. **Intent.** It commits a second transaction: *"I am about to make a provider request. The response will be entry `e_401` and the usage record will be `u_1`."* Nothing has been sent yet. +3. **The request.** Streaming happens. This is the only part that is not durable. +4. **Settlement.** One transaction commits the response, its usage, and the next state: *"the response has tool calls; here is the batch plan, with result ids already assigned."* +5. Tool calls follow the same intent → effect → settlement shape, one pair of commits each. +6. When the model stops without tool calls, a final transaction records the terminal state and clears the lane's current operation. + +Kill the process between any two of those transactions and restart. The harness reads the lane's state, sees exactly which of those sentences was the last one committed, and continues. If it died in step 3, it knows a request may have been billed and may or may not have produced output — that is the one genuinely uncertain window in the whole system, and there is a stated policy for it. + +Meanwhile a second thread in the same channel is running its own lane, over the same 400 entries of shared history, with no coordination between them. + +## 0.4 Worked example — a crash mid-tool + +``` +lane.prompt("delete the stale migrations and run the test suite") +``` + +The model returns two tool calls. The harness commits the batch plan, then commits `call 0 is about to execute, with these exact arguments, and it declares itself unsafe to replay`. The tool starts deleting files. The process is killed. + +On restart the harness reads one object and finds `calls[0].status = "effect_pending", replay = "never"`. It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1. The conversation stays coherent — every tool call has a result — and nothing ran twice. + +Had the tool declared `replay: "safe"` (a read, a query), the harness would have re-executed it with the persisted arguments instead. + +## 0.5 The four ideas + +Everything in Parts 1–5 follows from these. + +**1. Write-once objects, moving names.** Every durable thing is created once and never modified: messages, tool arguments, tree nodes, state frames. The only mutable things in the system are **refs** — names that point at objects. `lane.leaf/main` is a name. Moving a lane is moving a name. Recovery is reading three names. + +**2. Atomic transactions.** A transaction is a set of object creations plus ref moves, committed all-or-none with consecutive sequence numbers. There is no crash state inside a transaction. This is the only write primitive. + +**3. The durable program counter.** After every step, the harness writes one object holding the *complete* current state of the operation, and points `op.state/{id}` at it. Recovery does not replay a journal or infer position from what is missing; it reads the state and switches on it. The state is *total* — it never depends on a previous state — but its fields are mostly **ids**, not copied payloads. + +**4. The effect sandwich.** Provider requests and real tool calls are wrapped in two commits: + +``` +commit: "about to do X; its output will use ids R and U" ← intent + do X ← the uncertain part +commit: output + usage + next state ← settlement +``` + +Hooks follow their replay contract instead: a result becomes durable in the transaction that consumes it, and a crash before that transaction may rerun the hook. Thus every external effect can still happen without durable settlement. Provider/tool intents make that uncertainty explicit where replay policy depends on it; idempotent hooks accept it as a non-goal. + +## 0.6 Non-goals + +- **Exactly-once external effects.** See above. Hooks with their own side effects must be idempotent, keyed by operation id. +- **Provider stream resumption.** Partial streams are process-local, never persisted. A settled response is persisted *completely* before anything classifies it. +- **Multiple writers.** One process per session. The serving layer routes accordingly, and the SQLite backend enforces it with a fenced lease (§1.6). Lanes cover the workload that looks like multi-writer. +- **Replication.** A session lives in one place. + +## 0.7 Notation + +- `TX[ a, b, c ]` — one atomic commit containing writes `a`, `b`, `c` in that order. +- `obj_*` object ids (internal), `e_*` tree node ids (public entry ids), `u_*` usage ids. +- `S(next)` — write the new operation-state object and rebind `op.state`. `L(next)` — the same for lane state. +- **must / must not** are normative. Everything else is explanation. + +Source type provenance: + +- `AgentMessage`, `AgentTool`, `AgentToolResult`, `AgentEventSink`, `QueueMode`, and `ThinkingLevel`: `packages/agent/src/types.ts`. +- `Skill`, `PromptTemplate`, `AgentHarnessResources` (`Resources` below), `AgentHarnessTool`, `AgentHarnessStreamOptions`, and `AgentHarnessStreamOptionsPatch`: `packages/agent/src/harness/types.ts`. +- `Model`, `Models`, `Usage`, `RetryPolicy`, `StopReason`, `AssistantMessage`, `ImageContent`, provider messages, stream options, and deferred handles: `packages/ai`. +- `CompactionSettings`, `CompactionPreparation`, `CompactResult`, `BranchPreparation`, and `BranchSummaryResult`: `packages/agent/src/harness/compaction/`. Existing preparation and split-turn algorithms remain the implementation starting point unless this document explicitly changes them. +- `TelemetryContext` and typed schema helpers: `packages/telemetry`; the agent-owned schemas remain in `packages/agent/src/harness/telemetry.ts`. +- `TSchema` for durable custom-message registration: `typebox`. + +The public `QueueMode` remains `"all" | "one-at-a-time"`. Public `RetryPolicy` remains the pi-ai shape `{ enabled, maxRetries, baseDelayMs }`; operation state stores its normalized `{ maxAttempts, baseDelayMs }` equivalent. `maxRetries` and `baseDelayMs` must be finite non-negative safe integers and `maxRetries + 1` must remain safe; disabled retry normalizes to one attempt. Exponential delay and `notBefore` arithmetic saturate at `Number.MAX_SAFE_INTEGER`. Public `CompactionSettings` remains `{ enabled, reserveTokens, keepRecentTokens }`; both token counts must be finite non-negative safe integers. Constructors and setters reject invalid settings before publication. This design adds `deferred?: boolean | { window?: "15m" | "1h" | "24h" }` to `AgentHarnessStreamOptions` and its patch type; structural requests always force it to false. + +```ts +type SettledAssistantMessage = AssistantMessage & { + stopReason: Exclude; +}; + +/** Added to packages/ai: a synchronous registry lease that captures the exact + provider/model and Models auth resolver without resolving auth yet. */ +interface ModelRequestLease { + readonly model: Model; + stream(context: Context, options?: ModelsApiStreamOptions): + AssistantMessageEventStream; + streamSimple(context: Context, options?: ModelsSimpleStreamOptions): + AssistantMessageEventStream; + fetchDeferred(handle: DeferredHandle, options?: ModelsDeferredFetchOptions): + Promise; + cancelDeferred(handle: DeferredHandle, options?: ModelsDeferredCancelOptions): + Promise; +} +// Models.lease(provider: string, modelId: string): ModelRequestLease | undefined +``` + +There are no orchestration "records" in this system. Every durable thing is an **object**, a **node**, or a **ref**. + +--- + +# Part 1 — Storage substrate + +Storage knows nothing about agents, lanes, or conversations. It stores objects, moves names, and answers a small fixed set of queries. Parts 2–4 are built entirely on this. + +## 1.1 The model + +```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; + +/** Write-once. Created in exactly one transaction, never modified or deleted. */ +type StoredObject = { + [P in K]: { + id: string; // globally unique within the session + kind: P; + seq: number; // storage-assigned at commit + lane?: string; // denormalized for scanObjects + operationId?: string; // denormalized for scanObjects + payload: ObjectPayloads[P]; + } +}[K]; + +/** A node in the conversation tree. Write-once, like every object. */ +interface TreeNode { + id: string; // the public "entry id" + parentId: string | null; + seq: number; // storage-assigned at commit + type: EntryType; + customType?: string; // when type === "custom" + objectId: string | null; // the content; null for a custom entry with no data + timestamp: number; // Unix ms, storage-assigned +} + +type EntryType = "message" | "compaction" | "branch_summary" | "custom"; + +/** The only mutable thing. A name bound to an object or node id. */ +interface Ref { + namespace: N; + key: string; + targetId: string | null; // null = explicitly unbound (a tombstone) + seq: number; +} +``` + +**Why nodes and objects are separate.** Content and placement have different birth times. A queued message has content at enqueue and placement much later. An assistant response needs its id fixed *before* the content exists. Splitting them lets both be write-once: an object is created when its content exists; a node is created when placement happens; neither is ever updated. Reserving an id costs nothing, because a reserved id is just a string in a state object — there is no placeholder row. + +## 1.2 Object kinds + +```ts +interface ObjectPayloads { + message: { message: AgentMessage; terminate?: true }; + compaction: CompactionPayload; + branch_summary: BranchSummaryPayload; + custom_data: JsonValue; + lane_config: LaneConfiguration; + lane_state: LaneState; + operation: Operation; + operation_state: OperationState; + usage: UsageEntry; + tool_args: Record; + structural_preparation: DurableStructuralPreparation; + queue_disposition: { nodeId: string; disposition: "cancelled" | "cleared_by_abort" }; + fact_value: { value: JsonValue }; +} +type ObjectKind = keyof ObjectPayloads; + +interface CompactionPayload { + summary: string; retainedTail: AgentMessage[]; tokensBefore: number; + details?: JsonValue; usage?: Usage; fromHook: boolean; +} +interface BranchSummaryPayload { + fromId: string; summary: string; + details?: JsonValue; usage?: Usage; fromHook: boolean; +} +interface DurableFileOperations { + read: string[]; written: string[]; edited: string[]; +} +type DurableStructuralPreparation = + | { kind: "compaction"; messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; retainedTail: AgentMessage[]; + isSplitTurn: boolean; tokensBefore: number; previousSummary?: string; + fileOps: DurableFileOperations; settings: CompactionSettings } + | { kind: "branch_summary"; messages: AgentMessage[]; + fileOps: DurableFileOperations; totalTokens: number }; + +interface UsageEntry { + usage: Usage; + nodeId?: string; // the entry this cost belongs to, when there is one + adjustment: boolean; // true = caller-supplied reconciliation, not a provider report + details?: JsonValue; +} +``` + +`lane` is set on `lane_config`, `lane_state`, `operation`, `operation_state`, `usage`, `tool_args`, structural preparations, and queue dispositions. `operationId` is set on `operation`, `operation_state`, structural preparations, and operation-owned usage/tool-args objects; adjustments and lane-level objects omit it. The remaining kinds carry neither. + +## 1.3 Transactions + +```ts +type Write = + | { kind: "object"; id: string; objectKind: ObjectKind; payload: JsonValue; + lane?: string; operationId?: string } + | { kind: "node"; id: string; parentId: string | null; type: EntryType; + customType?: string; objectId: string | null } + | { kind: "ref"; namespace: RefNamespace; key: string; targetId: string | null }; + +interface Transaction { writes: Write[] } + +interface CommitResult { firstSeq: number; seqs: number[]; timestamp: number } +``` + +Rules: + +1. A transaction commits **all-or-none**. There is no observable state in which some of its writes exist and others do not. +2. Writes receive **consecutive** `seq` values in the order given. `seq` is monotonic session-wide across all lanes and all write kinds. +3. Within a transaction, writes apply in order: a node may reference an object created earlier in the same transaction; a ref may target an object or node created earlier in the same transaction. +4. Object and node ids share one session-wide namespace. Writing either kind under any existing object/node id is **corruption**, not an update. +5. A ref write with the same `(namespace, key)` as an earlier binding replaces it. History is retained for `getLog`, but only the latest binding is live. +6. Transactions on one session are **serialized**. There is one writer and one queue. + +Session validates the complete transaction, including JSON serialization and runtime schemas, before storage admission. A failed admitted commit **faults the harness**: all effects stop, all calls reject, and the process must be restarted. A partially applied transaction is not tolerated. + +## 1.4 Queries + +One `Storage` instance serves one session. Repository discovery and lifecycle are outside this interface (§2.8). + +```ts +interface Storage { + commit(tx: Transaction): Promise; + + getObjects(ids: string[]): Promise>; + /** Node joined to its object. */ + getEntries(ids: string[]): Promise>; + + getRef(namespace: N, key: string): Promise | undefined>; + listRefs(namespace: N): Promise[]>; + + scanBranch(q: BranchScan): Promise; + scanBranchStructure(q: BranchScan): Promise; + scanEntries(q: EntryScan): Promise; // session-wide tree inventory + scanObjects(q: ObjectScan): Promise; + getStats(): Promise; // maintained projection + + /** Debug/audit history. Hot-path recovery never calls this. */ + getLog(fromSeq?: number, limit?: number): Promise; + close(): Promise; +} + +interface NodeRef { + id: string; parentId: string | null; seq: number; + type: EntryType; customType?: string; +} + +interface EntryScan { + type?: EntryType; customType?: string; + fromSeq?: number; toSeq?: number; + order?: "asc" | "desc"; limit?: number; +} + +interface ObjectScan { + kind?: ObjectKind; lane?: string; operationId?: string; + fromSeq?: number; toSeq?: number; + order?: "asc" | "desc"; limit?: number; +} + +type LogItem = + | { kind: "object"; seq: number; object: StoredObject } + | { kind: "node"; seq: number; node: TreeNode } + | { kind: "ref"; seq: number; ref: Ref }; +``` + +Recovery and execution reads must be index-driven and bounded. They may not fold history or infer state from an absent object. Exact dereference is allowed: one current state may name a bounded set of immutable payload objects, fetched in one batch without order-dependent reduction. Public inventory and debugging APIs may intentionally read more than a hot path; their `limit`/pagination behavior is explicit at the `SessionTree` layer. + +`close()` is idempotent. It seals admission, rejects later reads/commits on that instance, drains commits admitted before the seal, then releases resources and the writer claim. Durable data is reopened through the repository. + +## 1.5 Ref namespaces + +```ts +type RefNamespace = + | "lane.leaf" | "lane.config" | "lane.state" + | "op.state" | "queue.disposition" + | "fact.name" | "fact.label" | "fact.custom"; +``` + +| Namespace | Key | Target | Meaning | +|---|---|---|---| +| `lane.leaf` | lane name | node id or null | where this lane appends next | +| `lane.config` | lane name | object id | total `LaneConfiguration` | +| `lane.state` | lane name | object id | total `LaneState` (§3.3) | +| `op.state` | operation id | object id | total `OperationState` (§3.2) — **the program counter** | +| `queue.disposition` | queued node id | object id | terminal cancellation/abort disposition; never used by restore | +| `fact.name` | `""` | object id or null | session name | +| `fact.label` | node id | object id or null | entry label | +| `fact.custom` | application key | object id or null | application state | + +That is the complete set. A ref bound to `null` is a **tombstone**: explicitly absent, which differs from never bound. Deleting a label sets a tombstone. `queue.disposition` is written only when a pending item is cancelled or cleared by abort; it is an exact public-API lookup, not current orchestration state. + +## 1.6 Backends + +Three encodings of one model. All three pass the same conformance suite (§7.3). + +### Memory + +```ts +objects: Map +nodes: Map +refs: Map // key: `${namespace}\u0000${key}` +children: Map // parentId → node ids, for tree walks +log: LogItem[] +``` + +One queue serializes commits. A commit validates and applies writes to temporary transactional state, then publishes the maps and log together. Reads are map lookups; `scanBranch` walks `parentId` and joins in RAM. `getLog` returns a slice of `log`. + +### JSONL + +One physical line per `commit()`. Storage assigns sequence/timestamp fields first, then encodes one committed log item as an object line or several as one **array line**. + +```jsonl +{"v":4,"kind":"header","id":"s_1","createdAt":1700000000000,"cwd":"..."} +[{"kind":"object","seq":1,"id":"obj_7","objectKind":"message","payload":{"message":{"role":"user","content":[...]}}}, + {"kind":"object","seq":2,"id":"op_1","objectKind":"operation","payload":{...},"lane":"main","operationId":"op_1"}, + {"kind":"node","seq":3,"timestamp":1700000000000,"id":"e_1","parentId":null,"type":"message","objectId":"obj_7"}, + {"kind":"object","seq":4,"id":"obj_9","objectKind":"operation_state","payload":{...},"lane":"main","operationId":"op_1"}, + {"kind":"ref","seq":5,"namespace":"lane.leaf","key":"main","targetId":"e_1"}, + {"kind":"ref","seq":6,"namespace":"op.state","key":"op_1","targetId":"obj_9"}] +``` + +- This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix C). +- Open verifies persisted sequence continuity and timestamps while replaying into the Memory projections above. It never regenerates committed timestamps. All queries then run in RAM. +- **A torn final line is discarded whole**, including every element of an array, and is truncated before new writes are admitted. This is what makes "no crash prefix inside a transaction" true here. +- A malformed *interior* line, or a complete-but-invalid transaction, is corruption. +- Durability is process-crash level: a resolved `commit()` survives process death. No fsync promise. +- `getLog` reproduces the file's logical order, expanding arrays. +- Optional: retain `(offset, length)` per object and load payloads lazily, keeping only node/ref structure resident. Do this only if profiling demands it. + +### SQLite + +```sql +objects(session_id, id TEXT, kind TEXT, seq INTEGER, lane TEXT, operation_id TEXT, + payload TEXT, PRIMARY KEY (session_id, id)) WITHOUT ROWID; +CREATE INDEX ix_obj_scan ON objects(session_id, kind, seq); +CREATE INDEX ix_obj_seq ON objects(session_id, seq); +CREATE INDEX ix_obj_lane ON objects(session_id, lane, kind, seq); +CREATE INDEX ix_obj_op ON objects(session_id, operation_id, seq); + +nodes(session_id, id TEXT, parent_id TEXT, seq INTEGER, type TEXT, custom_type TEXT, + object_id TEXT, timestamp INTEGER, PRIMARY KEY (session_id, id)) WITHOUT ROWID; +CREATE INDEX ix_node_parent ON nodes(session_id, parent_id); +CREATE INDEX ix_node_seq ON nodes(session_id, seq, type); + +refs(session_id, namespace TEXT, key TEXT, target_id TEXT, seq INTEGER, + PRIMARY KEY (session_id, namespace, key)); +ref_history(session_id, seq INTEGER, namespace, key, target_id, + PRIMARY KEY (session_id, seq)); + +-- Private branch index (§2.6). Not refs; no equivalent in the other backends. +branch_entries(session_id, branch_id TEXT, node_id TEXT, node_seq INTEGER, node_type TEXT, + PRIMARY KEY (session_id, branch_id, node_id)) WITHOUT ROWID; +-- Ordered scans. node_seq must follow branch_id directly or ORDER BY needs a +-- temp b-tree; node_id and node_type trail so the index covers id-only reads. +CREATE INDEX ix_be_seq ON branch_entries(session_id, branch_id, node_seq, node_id, node_type); +-- Type-filtered scans. +CREATE INDEX ix_be_type ON branch_entries(session_id, branch_id, node_type, node_seq, node_id); +CREATE INDEX ix_be_node ON branch_entries(session_id, node_id); +branch_meta(session_id, branch_id TEXT, tip_node_id TEXT, tip_seq INTEGER, + base_branch_id TEXT, base_seq INTEGER, + PRIMARY KEY (session_id, branch_id)); +CREATE UNIQUE INDEX ix_bm_tip ON branch_meta(session_id, tip_node_id); + +sessions(session_id, created_at, parent_session_id, metadata); +session_stats(session_id, message_count, usage_payload); +session_sequences(session_id, next_seq); +writer_leases(session_id, owner_id TEXT, fence INTEGER, expires_at_ms INTEGER); +``` + +One `commit()` is one SQL transaction: insert objects, insert nodes, upsert refs plus append `ref_history`, maintain the branch index, bump `session_stats`. Never an UPDATE to an object or node row. + +**Every transaction must open with `BEGIN IMMEDIATE`.** A deferred `BEGIN` that +reads before it writes takes a read snapshot and must later upgrade to the write +lock; if another writer committed in between, SQLite fails that upgrade — and +`busy_timeout` does **not** rescue it, because no amount of waiting can refresh a +stale snapshot. The only recovery is rollback and full retry. + +Every commit has this shape, not just a few. Allocating the sequence range reads +`session_sequences.next_seq` and then writes it, so a read precedes a write in every +transaction the system performs. Branch creation (§2.6) adds a second instance, +reading the newest compaction before inserting. `BEGIN IMMEDIATE` takes the write +lock up front and avoids an unrecoverable stale-snapshot upgrade, so there is no case +where a deferred `BEGIN` is the right choice here. + +**`writer_leases` enforces the single-writer rule.** Expiring fenced ownership: +`open()` acquires the claim, storage renews it on appends and while idle, and close +stops renewal after the queue drains and deletes only its matching `(owner_id, +fence)` pair — so a stale owner cannot release the replacement that succeeded it. +This is what makes "one process owns one session" an enforced property rather than +a convention the serving layer is trusted to uphold. Memory and JSONL have no +equivalent and rely on process ownership; a JSONL session opened twice is corrupt +and undetected. + +**Writer scope is per database file, not per session.** WAL mode permits exactly one +writer per file. Because these tables are keyed by `session_id`, several sessions may +share a file, and the design's one-writer-per-session rule does not by itself make +writes uncontended. Choose deliberately: + +- *One file per session* — the single-writer claim becomes literally true, and there + is no cross-session contention. Preferred unless something forces otherwise. +- *One file for many sessions* — correct, but all sessions share SQLite's one-writer queue. Use only when that contention is acceptable. + +Atomicity itself needs no special handling. A multi-write transaction is all-or-none +by the file format: WAL frames become visible only when the commit record lands, so a +concurrent reader observes either none of a transaction's writes or all of them. + +Each physical segment of `scanBranch` uses one JOIN; §2.6 combines segment ranges: + +```sql +SELECT n.id, n.parent_id, n.seq, n.type, n.custom_type, n.timestamp, o.payload +FROM branch_entries b +CROSS JOIN nodes n ON n.session_id = b.session_id AND n.id = b.node_id +LEFT JOIN objects o ON o.session_id = n.session_id AND o.id = n.object_id +WHERE b.session_id = ? AND b.branch_id = ? AND b.node_seq > ? AND b.node_seq <= ? +ORDER BY b.node_seq; +``` + +`CROSS JOIN` is load-bearing: it forces `branch_entries` to be the outer loop. Left +to itself the planner may drive from `nodes`, scan the table, and sort through a +temporary b-tree. Assert the plan in a test: + +``` +SEARCH b USING COVERING INDEX ix_be_seq (session_id=? AND branch_id=? AND node_seq>?) +SEARCH n USING PRIMARY KEY (session_id=? AND id=?) +SEARCH o USING PRIMARY KEY (session_id=? AND id=?) LEFT-JOIN +``` + +Any plan containing `USE TEMP B-TREE FOR ORDER BY` or a scan of `nodes` is a +regression. + +`scanBranchStructure` is the same without the `objects` join. `getEntries` is the same JOIN keyed by `n.id IN (...)`. `getLog` is a three-way `UNION ALL` over `objects`, `nodes`, and `ref_history`, ordered by `seq` — which is the whole reason `ref_history` exists. + +The repository's existing `SessionSearch` surface remains. SQLite replaces its rowid-dependent entry index with an FTS projection keyed by stored `session_id` and `node_id`; searchable text is the JSON serialization of the materialized entry, matching the scanning fallback. The transaction that places a node also inserts its projection after validating the node/object pair. Queued objects are not searchable before placement. Fork import populates the same projection, and session deletion removes its rows. Search never depends on `nodes.rowid`. + +## 1.7 Why write-once is worth the discipline + +- **Recovery is a read.** Three refs, then point lookups. No reducer exists to have a bug. +- **Crash states are enumerable.** Between transactions, never inside one. +- **No repair-by-rewrite.** Recovery only ever *appends*, so recovery is itself crash-safe: interrupt it and rerun it and you get the same result. +- **Concurrency is trivial.** Readers never see partial state; there is nothing to lock. +- **Content is stored once.** A queued message is serialized at enqueue and referenced thereafter. + +--- + +# Part 2 — The conversation tree + +## 2.1 Entries + +An **entry** is what the application sees: a node joined to its object. + +```ts +interface EntryBase { id: string; seq: number; parentId: string | null; timestamp: number } + +interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; + terminate?: true } +interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; + retainedTail: AgentMessage[]; tokensBefore: number; + details?: JsonValue; usage?: Usage; fromHook: boolean } +interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; + summary: string; details?: JsonValue; + usage?: Usage; fromHook: boolean } +interface CustomEntry extends EntryBase { type: "custom"; customType: string; data?: JsonValue } + +type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; +``` + +Materialization is mechanical, and happens inside storage: + +```ts +function toEntry(node: TreeNode, object: StoredObject | undefined): Entry { + const base = { id: node.id, seq: node.seq, parentId: node.parentId, + timestamp: node.timestamp }; + switch (node.type) { + case "message": return { ...base, type: "message", ...object!.payload }; + case "compaction": return { ...base, type: "compaction", ...object!.payload }; + case "branch_summary": return { ...base, type: "branch_summary", ...object!.payload }; + case "custom": return { ...base, type: "custom", customType: node.customType!, + data: object?.payload }; + } +} +``` + +Rules: + +- `type` and `customType` live on the **node**, not the object, because they are structural filters used by branch queries and denormalized into the branch index. +- Node/object compatibility is exact: `message → message`, `compaction → compaction`, `branch_summary → branch_summary`, and `custom → custom_data | null`. Every other pairing is corruption. +- Assistant entries always contain a `SettledAssistantMessage`. Reject `pending` before writing. +- Tool-result entries carry `terminate?: true` on the message object. It is orchestration state that `ToolResultMessage` has no field for. +- Every compaction and branch summary carries `fromHook`: `true` for hook output, `false` for generated. +- Every compaction stores a complete `retainedTail` (`[]` when empty). **Context never reads past a compaction.** This is what makes a compaction a self-contained checkpoint rather than a pointer into history. +- Objects are never shared between nodes by the harness. Content-hash dedup is possible under this model and explicitly not built (Appendix D). + +## 2.2 Placement + +The tree's central rule: + +> An **object** is created when its content exists. A **node** is created when placement happens. They may land in the same transaction or in two, and neither is ever modified. + +Three cases, all mechanical: + +**Born placed** — assistant responses, tool results, direct appends to an idle lane. One transaction: + +``` +TX[ put obj_8 = , + put node e_a4 = { parent: e_q1, type: "message", object: obj_8 }, + setRef lane.leaf/main → e_a4 ] +``` + +**Content first, placement later** — queued input (`steer`, `followUp`, `nextRun`) and deferred writes. Two transactions, possibly far apart: + +``` +t0 TX[ put obj_7 = <200KB message>, + S(next){ ...inbox.steer += { nodeId: "e_q1", objectId: "obj_7" } } ] + +t1 TX[ put node e_q1 = { parent: e_a3, type: "message", object: obj_7 }, + setRef lane.leaf/main → e_q1, + S(next){ ...inbox.steer -= that item } ] +``` + +The content is serialized **once**. The node references it. + +**Id reserved before content exists** — assistant responses and tool results. The id is a string in a state object; no row exists until settlement. Reserving costs nothing. + +Consequences to rely on: + +- A pending write is **invisible to tree queries** (no node) but **visible in snapshots** (the operation state names it, and its content can be dereferenced). +- "Has this been placed yet?" is answered by the operation state, which lists it as pending — never by the absence of a node. +- A node whose `objectId` names a nonexistent object is corruption. + +## 2.3 Lanes + +A configured lane is three refs and nothing else. Fresh or normalized-v3 `main` may temporarily lack `lane.config` until first harness attachment: + +``` +lane.leaf/{name} → node id or null +lane.config/{name} → object id (LaneConfiguration) // absent only for unconfigured main +lane.state/{name} → object id (LaneState) +``` + +```ts +interface LaneConfiguration { + model: { provider: string; modelId: string }; + thinkingLevel: ThinkingLevel; + activeToolNames: string[]; +} +``` + +- A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that node), or the lane navigates (leaf jumps to an existing node). +- `LaneConfiguration` is **total**. A setter writes a whole new object and rebinds the ref; it is never a patch and never a tree entry. +- Creating a lane copies no tree content, no history, and no configuration from its anchor: + +``` +TX[ put obj_cfg = , + put obj_ls = { currentOperationId: null, pendingNextRun: [] }, + setRef lane.config/{name} → obj_cfg, + setRef lane.leaf/{name} → anchorNodeId, + setRef lane.state/{name} → obj_ls ] +``` + +- Lanes are never deleted or renamed. Names are permanent application keys. +- `main` exists in every session. +- Two lanes at the same leaf simply diverge on their next append. + +## 2.4 Facts + +Session-scoped, latest-wins, not part of the tree. + +``` +fact.name/"" → object id | null +fact.label/{nodeId} → object id | null +fact.custom/{key} → object id | null +``` + +Setting a value to `undefined` binds `null` (a tombstone). JSON `null` is a legitimate custom value, stored as `{ value: null }`. The built-in and custom namespaces never overlap. Fact writes commit immediately and never move a leaf. + +## 2.5 Branch queries and context + +```ts +interface BranchScan { + start?: string; // default: the view's lane leaf + stopAtType?: EntryType; // scan ends after the first match, inclusive + stopAtId?: string; + type?: EntryType; + customType?: string; + order?: "newestFirst" | "oldestFirst"; // default newestFirst + limit?: number; + cursor?: EntryCursor; +} +type EntryCursor = { seq: number }; +``` + +Semantics: take the path from `start` toward the root, order it (default `newestFirst`), stop **inclusively** at the first `stopAt` match, filter by `type`/`customType`, apply the exclusive cursor, then apply `limit`. For `newestFirst`, a cursor retains `seq < cursor.seq`; for `oldestFirst`, it retains `seq > cursor.seq`. A `stopAt` node is returned only if it also passes the filter. + +**Context projection** — how a provider request is built: + +1. `scanBranch({ start: leaf, order: "newestFirst", stopAtType: "compaction" })`. +2. Reverse to oldest-first. If a compaction terminated the scan, the context is: its `summary`, then its `retainedTail`, then every entry after it. **Nothing earlier is read.** +3. Drop assistant responses whose stop reason is `error`, `aborted`, or `deferred`. Retain genuine output-limit `length`. +4. Run custom entries through `entryProjectors`. An unprojected custom entry never enters context. +5. Run `transform_context`, then `toProviderMessages`. + +There is no rule for omitting an overflow response, and no link anywhere pointing at one. An overflow response is committed with stop reason `error` (§3.7) and is therefore dropped by rule 3 like any other error, and by any downstream `transformMessages` that filters the same way. + +**Append-only context invariant.** Across the requests of one lane, provider context must only grow at the tail. An insertion before the previous request's tail invalidates the provider's KV cache and multiplies cost. This is *why* mid-run writes defer to checkpoints, where they append at the tail. Compaction is the one deliberate cache invalidation, and it trades that for a smaller context. + +## 2.6 The branch index — SQLite only + +Memory and JSONL walk parent pointers in RAM. SQLite maintains a private segmented branch cache so a diverging append does not copy an unbounded root prefix. + +`branch_entries` stores the nodes physically present in one segment. `branch_meta` stores its tip and optional `{ baseBranchId, baseSeq }`. A segment logically contains its own rows above `baseSeq` plus the referenced base prefix through `baseSeq`. + +Append: + +1. If a branch tip equals the lane leaf, append one row and move that tip. +2. Otherwise resolve a branch that actually covers the leaf, find the newest compaction at or below the leaf through the complete segment chain, copy only rows after that compaction through the leaf, and set the older prefix as the new segment's base. +3. Append the new node and make it the new segment tip. + +Read newest segment first. If the requested range crosses `baseSeq`, continue through the base chain with the upper bound capped at that boundary. Merge segment results into the requested order before filtering/limiting. + +Two correctness rules are mandatory: + +- The base branch must itself cover the leaf within its logical range; merely containing the leaf in an ancestor is insufficient. +- The newest compaction search must traverse the base chain; checking only the newest physical segment can miss it. + +The cache must preserve: + +- following a segment chain yields the exact root path with no gaps or duplicates; +- all chains containing a node agree below it; +- runtime reads never fall back to a table scan or parent walk; +- stale branches remain valid cache history; +- only an explicit repair operation rebuilds the cache from nodes. + +Tests assert these invariants and the required query plans. No wall-clock threshold is normative. + +## 2.7 Forks + +A fork is a repository operation over one coherent source-session snapshot. It copies selected nodes and objects, latest facts, lane pointers, and total configuration; it never copies open operation state or usage ledger objects. + +```ts +type ForkOptions = + | { scope?: "branch"; entryId?: string; position?: "before" | "at" } + | { scope: "tree" }; +``` + +- Memory and JSONL obtain the snapshot as one job on the source storage queue. SQLite uses one read transaction. +- Branch scope copies one path and creates only destination `main`. Tree scope copies the whole tree and every lane pointer/configuration. +- The destination is idle and its token/cost ledger starts at zero. Entry-local display usage remains on copied entries. +- Facts follow the selected scope: name/custom facts always copy; labels copy only when their target copies unless tree scope copies all targets. +- Any message may be the fork point. Request construction heals orphaned tool calls. +- The destination metadata records `parentSessionId`. + +A source with only fresh/unconfigured `main`—new format 4 or read-only normalized v3—may have no configuration. Either fork scope then creates one unconfigured destination `main`, which first harness attachment seeds normally. Every configured format-4 lane copied by a fork keeps its current total configuration. + +## 2.8 Session and repository boundary + +`Storage` is deliberately one-session only. `Session` supplies typed validation, lane-bound views, and object/node/ref materialization. `SessionRepo` owns discovery and storage-instance lifecycle: + +```ts +interface SessionMetadata { + id: string; + createdAt: number; + parentSessionId?: string; + /** Only when a v3 parent path cannot be resolved to an available header id. */ + legacyParentSessionPath?: string; +} + +interface SessionCodecOptions { + /** Built-in provider-message roles are registered by default. */ + customMessageSchemas?: Record; // keyed by custom `role` +} + +interface SessionSearchOptions { text: string; cwd?: string } +interface SessionSearchHit { + metadata: M; entryId: string; timestamp: string; snippet?: string; score?: number; +} +interface SessionSearch { + search(options: SessionSearchOptions): Promise[]>; +} + +interface SessionRepo { + create(options: C): Promise>; + open(metadata: M): Promise>; + list(options?: L): Promise; + delete(metadata: M): Promise; + fork(source: M, options: ForkOptions & C): Promise>; +} + +interface Session extends SessionTree { + readonly metadata: M; + readonly idGenerator: { next(): string }; + view(lane: string): SessionTree; + + /** Package-internal harness substrate; validates before delegating to Storage. */ + commit(tx: Transaction): Promise; + getObjects(ids: string[]): Promise>; + getEntries(ids: string[]): Promise>; + getRef(namespace: N, key: string): Promise | undefined>; + listRefs(namespace: N): Promise[]>; + getLog(fromSeq?: number, limit?: number): Promise; + + close(): Promise; +} +``` + +Repository constructors accept `SessionCodecOptions`. Every declaration-merged custom `AgentMessage` must have a string `role` and a registered runtime schema; unknown custom roles are rejected before persistence and on decode. A new repository session creates `main` with null leaf and an empty `LaneState`, but no configuration; first harness attachment writes its seed configuration. Repository implementations resolve `fork(source, ...)` to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read transaction. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session `Storage` contract. + +# Part 3 — The operation state machine + +## 3.1 Operations + +```ts +interface Operation { + operationId: string; + lane: string; + sourceLeafId: string | null; + startedAt: number; + intent: + | { kind: "run"; promptObjectIds: string[]; + systemPromptOverride?: string; resumeData?: Record } + | { kind: "compaction"; customInstructions?: string } + | { kind: "navigation"; targetId: string | null; summarize: boolean; + label?: string; customInstructions?: string }; +} +``` + +The operation object's stored id is exactly `operationId`. It is written once at acceptance. + +## 3.2 Operation state — the program counter + +`op.state/{operationId}` points at one total `operation_state` object: + +```ts +type OperationState = RunState | CompactionState | NavigationState | FinishedState; + +type Control = + | { status: "running" } + | { status: "cancel_requested"; requestedAt: number; + drainedSteer: QueuedInput[]; drainedFollowUp: QueuedInput[] }; + +interface RunState { + kind: "run"; + control: Control; + /** Captured atomically at acceptance; setters affect later operations. */ + settings: { + compaction: CompactionSettings; + steeringMode: QueueMode; + followUpMode: QueueMode; + toolExecution: "sequential" | "parallel"; + }; + phase: RunPhase; + inbox: Inbox; + /** Newest durable assistant generation/fetch response in this operation. */ + latestAssistantNodeId: string | null; +} + +interface CheckpointPhase { + kind: "checkpoint"; + continuation: Continuation; + /** Durable correlation source for the next generation step. */ + triggerNodeId: string; + /** Threshold compaction is attempted at most once per trigger boundary. */ + thresholdCheckedTriggerNodeId?: string; + /** Generate before draining another queued input after one-at-a-time drain. */ + skipInboxOnce?: boolean; +} + +type RunPhase = + | CheckpointPhase + | { kind: "assistant"; generation: Generation } + | { kind: "tools"; batch: ToolBatch } + | { kind: "compaction"; reason: "threshold" | "overflow"; + structural: StructuralDecision; resumeAfter: CheckpointPhase } + | { kind: "deferred"; deferred: Deferred } + | { kind: "failure_drain"; error: OperationError; provenance: + | { kind: "response"; nodeId: string } + | { kind: "structural"; taskId: string } }; + +type Continuation = + | { kind: "need_assistant"; overflowRecoveryUsed: boolean } + | { kind: "may_finish"; includeFinalAssistant: boolean }; + +interface Inbox { + steer: QueuedInput[]; + followUp: QueuedInput[]; + writes: PendingWrite[]; +} + +interface QueuedInput { nodeId: string; objectId: string } +interface PendingWrite { nodeId: string; objectId: string | null; + type: EntryType; customType?: string } +interface OperationError { code: string; message: string; details?: JsonValue } +``` + +`latestAssistantNodeId` updates in the same settlement transaction as every assistant generation or deferred-fetch response. It lets finish and resume construct results/events without a branch scan. A tool batch retains its producing turn id while tool work remains active. + +Any transition that appends conversational input or tool results and requires another assistant writes a checkpoint with `need_assistant(false)` and the appended node as `triggerNodeId`. An unprojected custom write preserves the current checkpoint, including trigger and overflow flag. Entering threshold compaction first copies the checkpoint to `resumeAfter` with `thresholdCheckedTriggerNodeId = triggerNodeId`; decline, empty preparation, success, and crash therefore cannot recheck the same boundary. + +### Generation + +```ts +interface NormalizedRetryPolicy { maxAttempts: number; baseDelayMs: number } + +interface GenerationContext { + stepId: string; + triggerNodeId: string; + configurationObjectId: string; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; +} + +type Generation = + | { status: "ready"; context: GenerationContext; nextAttempt: number } + | { status: "effect_pending"; context: GenerationContext; attempt: number; + responseNodeId: string; responseObjectId: string; usageObjectId: string; + intendedOutputLimit: number; contextWindow: number } + | { status: "retry_wait"; context: GenerationContext; nextAttempt: number; + notBefore: number; errorMessage: string }; +``` + +For each attempt, `before_request` runs from generation `ready` (an elapsed retry wait first returns to `ready`). Its curated patch is composed with the context's captured base stream options, then `intendedOutputLimit` and `contextWindow` are calculated and persisted in the `effect_pending` intent before dispatch. A pre-intent crash may rerun the hook. Harness-owned `before_payload`/`after_response` callbacks are mounted only after intent and cannot be replaced through stream options. + +### Tool batch + +```ts +interface ToolBatch { + assistantNodeId: string; + /** Producing generation/fetch snapshot; active tool names come from here. */ + configurationObjectId: string; + /** The assistant generation step id; recovered tool events use it as turnId. */ + turnId: string; + calls: ToolCall[]; +} + +type ToolCall = + | { status: "planned"; sourceIndex: number; resultNodeId: string } + | { status: "effect_pending"; sourceIndex: number; resultNodeId: string; + /** Always the effective post-prepare/post-hook arguments. */ + argsObjectId: string; replay: "never" | "safe" } + | { status: "completed"; sourceIndex: number; resultNodeId: string; + terminate: boolean }; +``` + +The source call comes from `assistantNodeId` plus `sourceIndex`; large effective arguments live once in `tool_args`. Persist them unconditionally because `prepareArguments`, not only `before_tool`, may change them. Parallel calls may be effect-pending together; result nodes commit in source order. + +### Deferred + +```ts +type Deferred = + | { status: "suspended"; stepId: string; sourceNodeId: string; poll: number; + configurationObjectId: string; streamOptions: AgentHarnessStreamOptions } + | { status: "effect_pending"; stepId: string; sourceNodeId: string; poll: number; + responseNodeId: string; responseObjectId: string; usageObjectId: string; + configurationObjectId: string; streamOptions: AgentHarnessStreamOptions }; +``` + +One `resume()` performs at most one `fetchDeferred(handle, { wait: 0 })`. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A pending response must have a completely equal handle and becomes the next source. A mismatched pending handle is normalized to a durable `error` response explaining the mismatch; response, usage, `latestAssistantNodeId`, and response-provenance `failure_drain` commit atomically. + +### Structural work + +```ts +type StructuralDecision = { taskId: string; preparationObjectId: string } & ( + | { status: "deciding" } + | { status: "generating"; generation: SummaryGeneration } +); + +interface SummaryContext { + taskId: string; + resultNodeId: string; + kind: "compaction" | "branch_summary"; + configurationObjectId: string; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; + reason?: "manual" | "threshold" | "overflow"; +} + +type SummaryGeneration = + | { status: "ready"; context: SummaryContext; nextAttempt: number } + | { status: "effect_pending"; context: SummaryContext; attempt: number; + /** Current nested request intent; absent between requests. */ + request?: { index: number; usageObjectId: string }; + usageObjectIds: string[] } + | { status: "retry_wait"; context: SummaryContext; nextAttempt: number; + notBefore: number; errorMessage: string }; + +interface CompactionState { + kind: "compaction"; + control: Control; + customInstructions?: string; + structural: StructuralDecision; +} + +type NavigationState = + | { kind: "navigation"; control: Control; targetId: string | null; label?: string; + summarize: false; phase: { kind: "ready_to_commit" } } + | { kind: "navigation"; control: Control; targetId: string; label?: string; + customInstructions?: string; summarize: true; + phase: { kind: "summary"; structural: StructuralDecision } }; + +type FinishedState = { + kind: "finished"; + control: Control; + leafId: string | null; + finalAssistantNodeId?: string; +} & ( + | { outcome: "failed"; error: OperationError; runCompletion?: never } + | { outcome: "completed"; error?: never; + runCompletion?: "assistant" | "terminated_tools" } + | { outcome: "declined" | "aborted"; error?: never; runCompletion?: never } +); +``` + +Structural preparation is built from the reserved source leaf and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once as `structural_preparation` before the decision hook. State carries only `preparationObjectId`; hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. + +A normal run finish copies `RunState.latestAssistantNodeId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes always include the newest settled assistant when non-null and omit both final fields otherwise. Structural operations omit `runCompletion` and final assistant. One structural attempt may make one or two provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageObjectId}`, then performs that provider request through a nested Effects action, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any restored `effect_pending` attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. A durable `generating` decision prevents its decision hook from rerunning. + +## 3.3 Lane state and current-state validity + +```ts +interface LaneState { + currentOperationId: string | null; + pendingNextRun: QueuedInput[]; +} +``` + +Restore validates only current materialized state and objects it directly names; it never audits historical states. Required checks: + +- lane state, operation object, operation state, and refs agree on lane, operation id, and operation kind; +- every ref targets an existing object/node of its namespace's required kind; +- every referenced queue/configuration/args/assistant/preparation object exists, has the expected kind, lane/operation identity, and valid JSON DTO; +- finished outcome/error/control combinations are valid for the operation kind, finished state is paired atomically with a cleared lane operation, and a completed run omits its final assistant only with `runCompletion:"terminated_tools"`; +- tool source indices are complete, ordered, unique, in range, and use unique result ids; completed result nodes match their source calls; +- reserved response/result/usage ids, if materialized, contain the intended kind and identity; +- cancellation, navigation source/target, and structural-source combinations satisfy the state discriminants. + +Runtime schemas validate every decoded object/state before publication. These bounded checks reject corrupted/imported state that TypeScript transition functions could not have produced. + +## 3.4 The atomic transition rule + +> Compute the next total state in memory, then atomically commit every object, node, ref move, and state binding that makes that state true. + +A transaction writing total `LaneState` rereads its latest value inside the lane mutation line and changes only the fields owned by that transition. In particular, finish clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. Every edge below is exactly one `commit()`. + +## 3.5 The graph + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> checkpoint : prompt() accepted + + checkpoint --> assistant : continuation = need_assistant + checkpoint --> compaction : context threshold + checkpoint --> checkpoint : apply write / consume steer / consume follow-up + checkpoint --> finished : may_finish + empty inbox + + assistant --> assistant : retryable error (retry_wait) + assistant --> tools : toolUse + assistant --> compaction : overflow (first time) + assistant --> deferred : stopReason deferred + assistant --> checkpoint : stop / genuine length + assistant --> failure_drain : terminal error / retries exhausted / 2nd overflow + + tools --> tools : per-call intent + settlement + tools --> checkpoint : batch complete + + compaction --> checkpoint : resumeAfter restored + compaction --> failure_drain : overflow compaction declined or failed + + deferred --> deferred : poll returns pending + deferred --> tools : ready response with calls + deferred --> checkpoint : ready response without calls + deferred --> failure_drain : provider error + + failure_drain --> checkpoint : new user-context input applied + failure_drain --> finished : inbox drained (failed) + + checkpoint --> finished : abort reconciled (aborted) + finished --> [*] +``` + +Standalone operations: + +``` +compaction: deciding ──hook declines───────────→ finished(declined) + ──hook supplies result────→ finished(completed) + ──hook selects generation─→ generating ──→ finished(completed|failed) + +navigation: ready_to_commit ───────────────────→ finished(completed) + summary.deciding ──→ generating ───→ finished(completed) +``` + +## 3.6 Acceptance + +| From | Trigger | Transaction | +|---|---|---| +| idle lane | `prompt()` after `before_run` | `TX[ put new message objects (caller prompt and hook injections), put nodes for captured nextRun objects and new messages in order, setRef lane.leaf, put Operation, S(run{captured settings, checkpoint need_assistant(false), trigger=newest node, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured nextRun removed}) ]` | +| reserved idle lane | `compact()` with non-empty preparation | `TX[ put preparation P, put Operation, S(compaction{deciding, preparationObjectId:P}), L({currentOperationId: O}) ]` | +| idle lane | unsummarized `navigateTree()` after validation | `TX[ put Operation, S(navigation{ready_to_commit}), L ]` | +| reserved idle lane | summarized `navigateTree()` with preparation | `TX[ put preparation P, put Operation, S(navigation{summary.deciding, preparationObjectId:P}), L ]` | + +Captured `nextRun` items already have objects; acceptance places their nodes and removes them from `pendingNextRun`. Their content is not re-serialized. + +Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. + +Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, or summarize from root), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve). Prompt allocates its operation id before `before_run` so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists. + +**Acceptance must observe `currentOperationId === null`.** Because acceptance is on the lane mutation line, this is validation, not compare-and-swap. + +## 3.7 Assistant generation + +| From | Trigger | Transaction | To | +|---|---|---|---| +| checkpoint `need_assistant` | drive | conditionally snapshot current lane config and normalized retry policy in `TX[ S(assistant{ready, nextAttempt:1}) ]` | ready | +| assistant `ready` | `before_request` aggregate completes | `TX[ S(assistant{effect_pending, attempt=nextAttempt, reserved R/U, intendedOutputLimit, contextWindow}) ]` | effect_pending | +| effect_pending | settles with tool calls | `TX[ put response object, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, tools{plan with reserved result ids}) ]` | tools | +| effect_pending | retryable error, attempts remain | `TX[ put response, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]` | retry_wait | +| effect_pending | first overflow, preparation non-empty | `TX[ put response **normalized to error**, node R, leaf ref, usage U, put preparation P, S(latestAssistantNodeId=R, compaction{reason:overflow, structural:{deciding, taskId, preparationObjectId:P}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]` | compaction | +| effect_pending | first overflow, preparation empty | `TX[ put normalized response, node R, leaf ref, usage U, S(latestAssistantNodeId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| effect_pending | `stopReason: "deferred"` | `TX[ put response, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, deferred{suspended, sourceNodeId R, poll 0, config/options copied}) ]` | deferred | +| effect_pending | `stop` or genuine `length` | `TX[ put response, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | +| effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ put response, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| retry_wait | `notBefore` elapsed | `TX[ S(assistant{ready, nextAttempt:k+1}) ]` | ready | + +**There is never a durable "response without usage" or "response and usage without a decision."** All three land together or none do. + +### Classification order + +Pure, computed in memory before the settlement transaction. First match wins. + +| Condition | Result | +|---|---| +| `control.status === "cancel_requested"` | normalize stop reason to `aborted`; commit `checkpoint{may_finish, includeFinalAssistant:true}` under cancelled control, then reconcile writes/finish | +| overflow: adapter-reported, or `error` whose message matches the context-limit patterns, or `length` with output below `intendedOutputLimit` | **normalize stop reason to `error`**; compact (first time) or `failure_drain` (second) | +| `deferred` with a valid handle | deferred suspended | +| retryable `error`, attempts remain / otherwise | retry_wait / failure_drain | +| `toolUse`, or an accepted response carrying calls | tools | +| `stop` or genuine output-limit `length` | checkpoint `may_finish` | + +Two normalizations happen at commit, and both are deliberate. A cancelled response commits as `aborted`. An overflow-classified response commits as `error`. In both cases the original stop reason is overwritten and the reason is preserved in human-readable form in `errorMessage`. + +The overflow normalization is what removes every link from this design. Because the committed response is `error`, §2.5 rule 3 drops it from context automatically — no superseded-response id on the compaction, none in the operation state, and no omission rule of its own. The response stays in the tree as durable history, because a provider request happened and was billed. + +**Overflow detection is a heuristic and must be labelled as one.** Three sources, in decreasing reliability: + +1. **Adapter-reported.** A provider adapter that can compute `usage.input + usage.cacheRead > contextWindow` at settlement sets `stopReason: "error"` with a message matching the context-limit patterns. This requires no new stop reason and no change to any adapter's stop-reason mapping, which matters because those mappings typically throw on unknown values. An adapter doing this should also require negligible output, so a substantive answer that merely trips a counter is not discarded. +2. **Error-message matching.** Providers usually return a context-limit failure as an HTTP error, which arrives as `error` with a message. Matching it is string matching, and it is brittle wherever it lives. +3. **`length` below `intendedOutputLimit`.** Harness-side only. An adapter must not apply this rule, because it cannot distinguish an oversized request from a response truncated mid-thinking — and those need opposite treatment, since a genuine truncation must stay in context. + +Overflow is checked before retryable error, so an oversized request compacts rather than retrying unchanged. + +**`aborted` is not a classification input.** It means the harness's own abort signal fired (§4.6), and `abort()` commits `control` before signalling — so a settled `aborted` response always has `control.status === "cancel_requested"` and is caught by the first row. An `aborted` response with `control.status === "running"` is unreachable and is corruption (invariant 17). + +An overflow classification never produces a tool plan. A *genuine* `length` that carries tool calls does produce the full plan, executes nothing, and appends one `isError: true` result per call explaining that truncation may have corrupted the arguments — those results then require another assistant turn. + +## 3.8 Tools + +| From | Trigger | Transaction | To | +|---|---|---|---| +| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ put effective tool_args object, S(call i = effect_pending, argsObjectId, replay) ]` | dispatch | +| call *i* `effect_pending` | effect settled, `after_tool` applied | `TX[ put result object, put node, setRef lane.leaf, put tool usage (if reported), S(call i = completed, terminate) ]` | tools or checkpoint | +| call *i* `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | `TX[ put synthetic error result object, put node, setRef lane.leaf, S(call i = completed, terminate from an intentional block, otherwise false) ]` | tools | +| all calls completed | — | folded into the last settlement | checkpoint | + +The batch's completion transition is: + +- **every** completed call set `terminate: true` → `checkpoint{may_finish, includeFinalAssistant: false}` +- otherwise → `checkpoint{need_assistant(overflowRecoveryUsed: false)}` + +`terminate` exists so a tool can end the run without another provider turn. The motivating case is a "submit final result" tool used in place of structured output: the model calls it, the harness commits the result, and the run finishes with those tool results as its final entries — `run_end` then carries no `finalMessage`. Without this, every such run would pay for one more model turn whose only job is to stop. + +Modes: + +- **Sequential** (option, or any called tool declares `executionMode: "sequential"`): clear → intent → execute → finalize → commit, one call at a time. +- **Parallel** (default): clearance and intent commits happen in source order; dispatch does not await earlier calls; effects settle concurrently; phase 3, result-message lifecycle, and result commits are awaited and finalized in source order. + +Blocked and invalid calls skip the intent commit and the effect, but still commit a result at their source position. + +Calls are tracked internally by `sourceIndex`. Hooks, events, and tool context see the provider `toolCallId` and tool name — never the index. + +## 3.9 Summary generation — compaction and navigation summaries + +Both operations generate a summary through the same `deciding → generating → result` machinery, which is why they are specified together. The axes: + +| | compaction | navigation | +|---|---|---| +| **standalone operation** | `lane.compact()` — reason `manual` | `lane.navigateTree(target)` | +| **phase inside a run** | reasons `threshold`, `overflow` | — | + +| reason | who asked | on hook decline | +|---|---|---| +| `manual` | the caller | operation finishes `declined` | +| `threshold` | context-size check at a checkpoint | back to the stored `resumeAfter` | +| `overflow` | a request that did not fit | `failure_drain` | + +"Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and entry into `deciding` commit together (`put preparation P` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never creates `StructuralDecision`: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. + +| From | Trigger | Transaction | +|---|---|---| +| deciding | hook declines | standalone: `TX[ S(finished{declined}), L({currentOperationId: null}) ]` · threshold: `TX[ S(restore marked resumeAfter) ]` · overflow: `TX[ S(failure_drain{error, provenance:structural taskId}) ]` | +| deciding | hook supplies compaction | standalone: `TX[ hook usage?, result object, node, leaf ref, S(finished), L(currentOperationId:null) ]`; in-run: same entry writes plus `S(resumeAfter)` | +| deciding | hook supplies navigation summary | use §3.10's final transaction with the hook usage/result | +| deciding | hook selects generation | conditionally snapshot current config/policy in `TX[ S(generating{ready}) ]` — **the decision hook will never run again** | +| generating ready / retry elapsed | drive | `TX[ S(effect_pending, attempt k) ]` | +| generating effect_pending | one nested request returns | `TX[ put usage under request.usageObjectId, S(effect_pending, request cleared, usageObjectIds += id) ]`; commit another request intent before request two | +| generating effect_pending | retryable attempt outcome | usage is already durable; `TX[ S(retry_wait) ]` | +| generating effect_pending | terminal or attempts exhausted | standalone: `TX[ S(finished{failed}), L ]` · in-run: `TX[ S(failure_drain{provenance:structural taskId}) ]` | +| generating effect_pending | compaction succeeded | standalone: `TX[ result object, node, leaf ref, S(finished), L(currentOperationId:null) ]`; in-run: entry writes plus `S(resumeAfter)` | + +Structural provider streams are internal: they emit **no** public assistant-message lifecycle. The existing summary generator is retained, but its one/two request callback uses the nested request intent/effect/usage boundaries from §3.2 and §4.2. Intermediate content is not persisted; a crash before the final transaction makes the whole attempt unknown, and a later numbered attempt starts only under the captured retry policy. Failed-attempt usage stays in the ledger regardless. + +### Worked example — overflow + +`e_40` is a tool result awaiting an assistant turn. The request does not fit. + +``` +… e_38 ── e_39 ── e_40 phase: assistant, effect_pending + continuation was need_assistant(false) +``` + +**1. Settlement.** Classification says overflow. Preparation is built against the would-be branch; because the known response is normalized to `error`, ordinary projection excludes it. Response and preparation then commit together: + +``` +TX[ put obj(response, stopReason "error", errorMessage "context window exceeded: …"), + put node e_41, setRef lane.leaf → e_41, put usage u_41, + put structural_preparation p_41, + S(compaction{ reason: overflow, + structural: { deciding, taskId, preparationObjectId: p_41 }, + resumeAfter: { checkpoint, triggerNodeId: e_40, + continuation: need_assistant(true) } }) ] + +… e_38 ── e_39 ── e_40 ── e_41 +``` + +**2. Compaction.** The durable preparation was built by the ordinary rules in §2.5. `e_41` is an `error` response, so rule 3 dropped it — from the summary input and from `retainedTail` alike, with no special case: + +``` +… e_40 ── e_41 ── e_42 (compaction) + retainedTail: [e_39, e_40] ← e_41 absent by rule 3 +``` + +The tail ends on `e_40`, a tool result, which is the correct shape for a request that is about to ask for an assistant turn. + +**3. Resume.** `resumeAfter` restores `need_assistant(overflowRecoveryUsed: true)`. Context is now summary + tail + anything after `e_42`, which is small: + +``` +… e_41 ── e_42 ── e_43 the answer to e_40 + ✗ (error, out of context) +``` + +`e_41` remains in the tree forever as durable history — a request was made and billed. If the retry overflows *again*, `overflowRecoveryUsed` is already `true` and the run goes to `failure_drain` rather than compacting in a loop. Consuming new user input appends to the tree and resets the flag to `false`. + +## 3.10 Navigation + +Unsummarized and summarized both finish in **one** transaction: + +``` +TX[ put hook-reported usage (only for a hook-supplied summary), + setRef lane.leaf → target, + put summary object + node with its display usage snapshot (when summarize; parent is target), + setRef lane.leaf → summary node (when summarize), + put label fact object + setRef fact.label (when a label is present), + S(finished{completed, leafId}), + L({currentOperationId: null}) ] +``` + +Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary node explicitly names the target as parent, and the following ref write makes that summary the completed lane leaf. A crash sees either an untouched navigation still at its source, or a fully completed one. **No prepared-summary state and no post-move recovery state exist.** Abort before this transaction finishes `aborted` with no entry; abort after it means the operation completed. + +## 3.11 Inbox, queues, deferred writes + +| Public input | Admitted when | Transaction | +|---|---|---| +| `nextRun(msg)` | any state, including idle | `TX[ put message object, L(pendingNextRun += {nodeId, objectId}) ]` — never starts a run | +| `steer(msg)` | active running run | `TX[ put message object, S(inbox.steer += item) ]` | +| `followUp(msg)` | active running run | `TX[ put message object, S(inbox.followUp += item) ]` | +| tree write, run active | including suspended and cancelling | `TX[ put object, S(inbox.writes += item) ]` — survives abort | +| tree write, lane idle | idle | `TX[ put object, put node, setRef lane.leaf ]` | +| tree write, structural op open | — | wait for the operation to end, then re-evaluate | +| `cancelQueued(id)` | item still pending | `TX[ S or L with the item removed, put queue disposition, setRef queue.disposition/{id} ]` | +| checkpoint consumes input | eligible | `TX[ put node(s), setRef lane.leaf, S(items removed, continuation → need_assistant(false), triggerNodeId = newest node, skipInboxOnce = true) ]` | +| first `abort()` | run active | `TX[ S(control = cancel_requested, requestedAt, drainedSteer, drainedFollowUp, steer/followUp emptied), dispositions for every drained item ]` | +| finish | inbox empty, no required continuation | `TX[ S(finished), L({currentOperationId: null}) ]` | + +`cancelQueued` outcomes: pending → cancel and write its disposition; node exists → `already_consumed`; disposition ref exists → `already_cleared`; none of those → `UnknownQueueItem`. Dispositions are queried only by this exact public lookup and never participate in restore. + +Because acceptance, cancellation, consumption, abort, and finish all serialize on the lane mutation line, every race has exactly two possible histories, and **no item can be both pending and applied** in durable state. + +## 3.12 The checkpoint procedure + +Order matters. At each queue drain point, `"all"` consumes every currently eligible item in acceptance order; `"one-at-a-time"` consumes only the oldest and leaves the rest pending. Any projecting drain sets durable `skipInboxOnce`; on that next pass the planner skips steps 1–2, starts generation, and clears the flag in the ready-state transition. Thus a crash cannot turn one-at-a-time into an all-item drain. + +1. Unless `skipInboxOnce`, atomically apply accepted deferred writes. +2. Unless `skipInboxOnce`, atomically consume eligible steering, per the steering mode. +3. Run threshold compaction only when `thresholdCheckedTriggerNodeId !== triggerNodeId`, preserving the marked checkpoint in `resumeAfter`. +4. If the continuation is `need_assistant`, start generation and clear `skipInboxOnce`. +5. Once assistant and tool continuation are exhausted, atomically consume eligible follow-up. +6. If the continuation is `may_finish` and the inbox is empty, invoke `before_run_end`. +7. Conditionally finish. + +Consumed steer/follow-up and projecting message writes enter `need_assistant(false)`, set `triggerNodeId` to the newest appended node, and set `skipInboxOnce`. Tool results do the same unless every result terminates. An unprojected custom write is appended and removed from the inbox but preserves the prior continuation, failure provenance, and overflow flag. Under cancelled control, every deferred write is appended and removed without changing phase/continuation or starting work; reconciliation finishes aborted after writes drain. + +`before_run_end` may return a follow-up. It commits **only** if control is still running and the operation is still at the same finish boundary; otherwise the stale hook result is dropped. Its object, node, and the `need_assistant` state commit together. + +`failure_drain` applies accepted writes, then eligible steer and follow-up input in the same order. Projecting user-context input atomically enters `checkpoint{need_assistant(false)}` and clears the failure. Unprojected custom writes do not. With no such input, it finishes failed without `before_run_end` or another provider request. + +--- + +# Part 4 — Execution, recovery, abort, close + +## 4.1 The interpreter + +The runtime plans from total durable state plus a small process-local scheduler. Immutable payloads and context named by the state are batch-loaded before planning. The driver also snapshots current settings revision and registry leases (`Models.lease` and active tool definitions) into `RuntimeSnapshot`; this performs no provider request. When a tool batch first becomes current, the driver resolves `toolContext` once, binds the batch's definitions, and retains them in `DriveState.toolBatches` for every sequential/parallel call in that batch. `nextAction` is then pure over those inputs. Pre-intent hook plans retain the exact lease used for lookup, preparation, schema validation, and eventual dispatch. + +```ts +interface CurrentOperation { + operation: Operation; + operationStateObjectId: string; + state: OperationState; + laneStateObjectId: string; + laneState: LaneState; + leafId: string | null; + configurationObjectId: string; +} + +type EffectKey = string; // deterministic from durable step/attempt or assistant/sourceIndex + +/** Process-local leases captured before intent; never persisted or exposed. */ +type RuntimeProviderLease = ModelRequestLease; +interface RuntimeToolLease { tool: AgentTool } +interface RuntimeAssistantLease { + provider: RuntimeProviderLease; + activeTools: AgentTool[]; +} + +interface LiveEffect { plan: EffectPlan; promise: Promise } + +interface DriveState { + deferredPollsRemaining: 0 | 1; + running: Map; + /** One context/tool-definition snapshot per live or restored batch. */ + toolBatches: Map>; + /** Process-local best-effort attempts; reopen may attempt again. */ + deferredCancellations: Set; +} + +type EffectPlan = { telemetryContext: TelemetryContext } & ( + | { kind: "assistant"; key: EffectKey; + generation: Extract; + streamOptions: AgentHarnessStreamOptions; identity: RuntimeAssistantLease } + | { kind: "summary"; key: EffectKey; + generation: Extract; + identity: RuntimeProviderLease } + | { kind: "tool"; key: EffectKey; assistantNodeId: string; + sourceIndex: number; argsObjectId: string; identity: RuntimeToolLease } + | { kind: "deferred"; key: EffectKey; + deferred: Extract; + streamOptions: AgentHarnessStreamOptions; identity: RuntimeProviderLease } + | { kind: "cancel_deferred"; key: EffectKey; sourceNodeId: string; + handle: DeferredHandle; identity: RuntimeProviderLease } + | { kind: "hook"; key: EffectKey; name: keyof HookMap; event: unknown; + /** Pre-intent hooks carry the exact lease used to prepare their event. */ + identity?: RuntimeProviderLease | RuntimeAssistantLease | RuntimeToolLease } +); + +type SummaryAttemptOutcome = + | { kind: "success"; result: CompactResult | BranchSummaryResult } + | { kind: "retry" | "failure"; error: OperationError }; + +type EffectOutput = + | { kind: "not_started"; key: EffectKey } + | { kind: "assistant" | "deferred"; key: EffectKey; + message: SettledAssistantMessage } + | { kind: "summary"; key: EffectKey; outcome: SummaryAttemptOutcome } + | { kind: "tool_raw"; key: EffectKey; + result: AgentToolResult; isError: boolean } + | { kind: "hook"; key: EffectKey; result: unknown } + | { kind: "cancel_deferred"; key: EffectKey }; + +type SettlementOutput = Exclude | + { kind: "tool"; key: EffectKey; result: AgentToolResult; + isError: boolean; terminate: boolean }; + +interface SettlementResult { + current: CurrentOperation; + /** Immediate live dispatch prepared by a successful pre-intent hook. */ + dispatch?: EffectPlan; + /** Identity resolution failed while durable state was still safely dispatchable. */ + suspend?: OperationResult; + /** Poll intent committed; consume this resume invocation's sole permit. */ + consumeDeferredPoll?: true; +} + +interface RuntimeSnapshot { + settingsRevision: number; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; + providerLeases: ReadonlyMap; + toolLeases: ReadonlyMap; +} + +type PlannerInputs = { + /** Exact process-local plans; never reconstruct a live plan from durable ids. */ + running: ReadonlyMap; + deferredPollsRemaining: 0 | 1; + deferredCancellations: ReadonlySet; + immutable: ReadonlyMap; + runtime: RuntimeSnapshot; + context?: AgentMessage[]; + now: number; +}; + +type OperationResult = RunOutcome | CompactionOutcome | NavigationOutcome; + +type Action = + | { kind: "transition"; next: OperationState; telemetryContext: TelemetryContext; + /** Required when this transition snapshots current mutable request state. */ + expectedConfigurationObjectId?: string; + expectedSettingsRevision?: number } + | { kind: "dispatch"; intent?: OperationState; effect: EffectPlan; + consumeDeferredPoll?: true } + | { kind: "await_effect"; key: EffectKey } + | { kind: "wait"; until: number; telemetryContext: TelemetryContext } + | { kind: "suspend"; result: OperationResult } + | { kind: "done"; result: OperationResult }; + +async function drive(current: CurrentOperation, live: DriveState): Promise { + while (true) { + const inputs = await loadPlannerInputs(current, live); // bounded immutable reads + const action = nextAction(current.state, inputs); // pure and exhaustive + + switch (action.kind) { + case "transition": { + const committed = await commitTransitionIfCurrent( + current, action.next, action.telemetryContext, + action.expectedConfigurationObjectId, action.expectedSettingsRevision); + current = committed ?? await reloadCurrent(current.operation.operationId); + break; + } + + case "dispatch": { + if (action.intent) { + const committed = await commitTransitionIfCurrent( + current, action.intent, action.effect.telemetryContext); + if (!committed) { + current = await reloadCurrent(current.operation.operationId); + break; // a lane mutation won; do not dispatch + } + current = committed; + } + if (action.consumeDeferredPoll) live.deferredPollsRemaining = 0; + if (action.effect.kind === "cancel_deferred") + live.deferredCancellations.add(action.effect.sourceNodeId); + live.running.set(action.effect.key, + { plan: action.effect, promise: fx.run(action.effect) }); + break; // permits source-ordered parallel dispatch + } + + case "await_effect": { + const liveEffect = live.running.get(action.key); + if (!liveEffect) throw new Error("planned effect is not running"); + const { plan } = liveEffect; + const output = await liveEffect.promise; + live.running.delete(action.key); + if (plan.kind === "cancel_deferred") { + current = await reloadCurrent(current.operation.operationId); // no durable write + break; + } + let settlement: SettlementOutput; + if (output.kind === "tool_raw") { + if (plan.kind !== "tool") throw new Error("tool output/plan mismatch"); + settlement = await fx.finalizeTool(plan, output); // source-ordered after_tool + } else { + settlement = output; // not_started settles synthetically without hooks + } + const settled = await commitEffectSettlement( + current, plan, settlement, plan.telemetryContext); + current = settled.current; + if (settled.suspend) return settled.suspend; + if (settled.consumeDeferredPoll) live.deferredPollsRemaining = 0; + if (settled.dispatch) + live.running.set(settled.dispatch.key, + { plan: settled.dispatch, promise: fx.run(settled.dispatch) }); + break; + } + + case "wait": + await fx.sleep( + Math.max(0, action.until - Date.now()), action.telemetryContext); + current = await reloadCurrent(current.operation.operationId); + break; + + case "suspend": + case "done": + return action.result; + } + } +} +``` + +An intent/ordinary transition requires `op.state` still to target its expected source object; otherwise it returns `undefined` and the loop replans without dispatch. A successful `before_request`/`before_tool` hook settlement uses its retained identities, atomically commits the effect intent (and effective tool args), and returns the complete process-local dispatch plan; the drive installs that promise immediately. A crash in the remaining process-only gap is conservatively the ordinary unknown-effect case. A transition that creates a generation/summary `ready` state also supplies the lane-config ref and harness-settings revision it read; the settings/lane commit requires both still match, giving setter-first or step-start-first ordering. The resulting context durably captures normalized retry and base stream options. Immediately before ordinary external execution, `fx.run` enters the lane mutation line once more: cancellation-first returns `not_started`, while start-first registers the live effect/controller so a later abort signals it. This check uses the already captured identity lease and never re-resolves the registry. Thus no effect starts in the gap after intent without belonging to one of the two serialized orders. Settlement reloads latest total state, verifies the same effect key remains pending, merges the output into that state, and applies current cancellation control. Thus steer/write acceptance, abort, and other parallel-tool intents cannot erase a live result or overwrite newer inbox/control state. + +Parallel tool calls dispatch phase two in source order into `DriveState.running`. The planner may dispatch later calls while earlier promises run, but it emits `await_effect` only for the first incomplete source position. That raw result then crosses source-ordered `fx.finalizeTool`/`after_tool` before settlement. A later settled raw promise remains process-local until its turn. After restart `running` is empty, so durable `effect_pending` follows recovery policy rather than being mistaken for a live effect. + +Recovery rules: + +- `not_started` under cancelled control settles assistant/fetch under reserved ids as `aborted`, settles a tool with its planned aborted result without `after_tool`, drops an uncommitted hook decision, discards structural work before finishing aborted, and drops a stale deferred-cancel action without settlement; +- ready generation/summary and cleared tools commit `effect_pending` before `dispatch`; +- restored generation/summary pending with no live key advances under captured retry policy or settles synthetically at the cap; +- restored tools replay only when persisted and current declarations are `safe`, otherwise settle interrupted; +- restored deferred pending normally suspends until an application `resume()` replaces it with one fresh poll intent; cancelled control instead settles the existing reserved response/usage ids synthetically as `aborted` before finishing; +- committing a deferred intent through its `before_request` settlement returns `consumeDeferredPoll:true`; the drive clears the invocation's sole permit before installing dispatch, so a pending response re-suspends rather than polling again; +- retry wait crosses `fx.sleep`, which is visible to manual drive and reloads cancellation afterward; +- structural decision hooks run from `deciding`; their consumer transaction either finishes the structure or records `generating`, so only a pre-commit crash reruns them. + +A fresh operation drive starts with zero deferred permits; `resume()` starts with one. Repairs and non-poll work do not consume it. + +## 4.2 The effects boundary + +Every operation-procedure commit, provider request, tool invocation, hook call, and timer crosses exactly one injected `Effects` (`fx`) method. Procedures receive `fx`, their telemetry context, and a read-only runtime view — never `Session`, `Models`, the tool registry, or the hook runner directly. Ungated lane-surface commits—acceptance, queue/configuration calls, facts, lane creation, and idle writes—use the same lane mutation line and typed `Session` transaction API directly. + +```ts +type SummaryRequestOutput = + | { kind: "response"; message: SettledAssistantMessage } + | { kind: "not_started" }; + +interface Effects { + commitTransition(current: CurrentOperation, next: OperationState, + telemetry: TelemetryContext, + expectedConfigurationObjectId?: string, + expectedSettingsRevision?: number): + Promise; + commitEffectSettlement(current: CurrentOperation, plan: EffectPlan, + output: SettlementOutput, telemetry: TelemetryContext): + Promise; + /** Runs after_tool for the raw phase-two result selected in source order. */ + finalizeTool(plan: Extract, + output: Extract): + Promise>; + /** Composite summary plans use this reentrantly for each provider request. */ + runSummaryRequest(plan: { taskId: string; attempt: number; requestIndex: number; + usageObjectId: string; configurationObjectId: string; + messages: AgentMessage[]; identity: RuntimeProviderLease; + telemetryContext: TelemetryContext }): + Promise; + settleSummaryRequest(current: CurrentOperation, + plan: { taskId: string; attempt: number; requestIndex: number; + usageObjectId: string }, + response: SettledAssistantMessage, + telemetry: TelemetryContext): Promise; + /** Revalidates/registers effect start on the lane mutation line before execution. */ + run(plan: EffectPlan): Promise; + sleep(delayMs: number, telemetry: TelemetryContext): Promise; +} +``` + +The commit helpers shown in §4.1 delegate to these methods. Expected provider, tool, structural, and deferred-cancel failures return in-band `EffectOutput` variants; `run` rejects only for close, harness fault, or invariant defects. `cancel_deferred` is the explicit exception to ordinary start/settlement: its start check requires the same open cancelled operation and the process-local source target registered by `abort()` (the durable phase may already have advanced), uses a close-only signal rather than the already-pulled operation signal, and its awaited output bypasses `commitEffectSettlement` with no durable write. Automatic effects execute directly; manual effects gate the same calls. Passive event-listener delivery is observation, not an interpreter effect: it is isolated and telemetry-wrapped after publication but never parked by manual drive. `sleep` resolves early when the harness signal is pulled, after which the loop reloads cancellation control. For split-turn summary work, request-intent `commitTransition`, `runSummaryRequest`, and usage/state `settleSummaryRequest` are three distinct nested gated actions. `runSummaryRequest` performs the same serialized start check as `run`; abort-first returns `not_started`, leaves no usage, and makes the outer summary plan return its own `not_started` settlement, which discards structural work under cancelled control. The outer summary orchestration action is only process-local composition; manual drive and crash tests still stop between each nested boundary. These methods are the complete procedure crash-site catalog; ungated public mutations are the race boundaries in §7.2. + +**The provider signal is harness-owned.** `fx` supplies the `AbortSignal` passed to every provider request. No caller can supply one: `signal` is absent from the options type at every public surface (§5.2), and the harness strips any signal from a `streamOptions` patch before dispatch. Only `abort()` and `close()` can pull it. This is what makes §4.6's guarantee hold. + +**Manual drive.** With `drive: "manual"` the harness parks before each effect and exposes one JSON-safe action at a time: + +```ts +peekAction(): Promise; // stable, side-effect free +executeAction(): Promise; // release exactly one +runToCompletion(): Promise; +``` + +Lane-surface calls—including operation acceptance, `steer`, `abort`, config setters, and tree writes—stay **ungated**, so a test can drive both orders of any race. In manual mode a `before_run` handler parks before acceptance; with no handler, acceptance commits immediately and the first parked action is the run's first procedure transition. The gate is reentrant: nested `fx` calls (notably request hooks inside a stream) park independently, and the driver releases them before their parent continues. Closing while an action is parked rejects it unexecuted; durable state is exactly the committed prefix. + +Enforced by construction and by a test: an operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked. + +## 4.3 The lane mutation line + +Every state-dependent mutation on a lane is linearized: validate, at most one atomic commit, and the in-memory update complete before the next mutation starts. Provider, tool, hook, and retry work never occupies the line. + +What serializes here: operation acceptance, queue enqueue and cancel, queue consumption, deferred-write acceptance and application, abort, lane-configuration setters, finish, lane creation. Harness-global stream/retry/compaction/queue settings use a second mutation line with a monotonically increasing process revision. Operation acceptance and generation/summary starts snapshot settings by taking the settings line before the lane line and conditionally committing both expected tokens; global setters take only the settings line. No code acquires them in the reverse order. + +Consequence: every race between two public calls has exactly **two** possible durable histories, and both must be tested (§7.2). + +## 4.4 Restore + +```ts +async function restore(lane: string): Promise< + { kind: "idle"; lane: string } | { kind: "suspended"; current: CurrentOperation } +> { + const configRef = await storage.getRef("lane.config", lane); + const stateRef = await storage.getRef("lane.state", lane); + const leafRef = await storage.getRef("lane.leaf", lane); + const refs = { configRef, stateRef, leafRef }; + + const laneRoots = await storage.getObjects([configRef.targetId, stateRef.targetId]); + const laneState = laneRoots.get(stateRef.targetId)!.payload; + + let opRoots = new Map(); + if (laneState.currentOperationId) { + const opStateRef = await storage.getRef("op.state", laneState.currentOperationId); + opRoots = await storage.getObjects([ + laneState.currentOperationId, opStateRef.targetId + ]); + } + + const roots = merge(laneRoots, opRoots); + const objectIds = directObjectIds(roots); // includes pendingNextRun content + const nodeIds = directMaterializedNodeIds(roots, leafRef.targetId); + const [objects, entries] = await Promise.all([ + storage.getObjects(objectIds), storage.getEntries(nodeIds) + ]); + validateCurrent(refs, roots, objects, entries); + + if (!laneState.currentOperationId) return idle(lane, laneRoots, entries, laneState); + return suspended({ operation: ..., state: ... }); // drive() resumes it +} +``` + +That is the entire current-state restore path: three lane refs, the operation-state ref, root object lookup, then bounded batched object/entry lookups for exactly the immutable objects and nodes directly named by current lane/operation state. Restore performs §3.3's bounded validation over that set. It does not fold history, build provider context, probe for missing planned entries, or audit completed operations. + +Restore already fetched directly referenced immutable objects for validation. The driver reuses/caches them and lazily builds only derived provider context or additional branch projections needed by the next action; `nextAction` itself switches on scalars and supplied immutable maps. + +Admission resolves configured identities and returns `Err(MissingIdentities)` before writing when any are absent. Each later assistant, deferred, tool, or whole-summary-attempt preparation snapshots process-local provider/tool leases before its pre-intent hook. That registry/settings-line snapshot is the step-start order: lookup, `prepareArguments`, schema validation, hook event, intent, and dispatch all retain the same lease even if the registry is replaced while the hook runs. Both split-summary requests share the attempt's lease. If resolution fails while state is still safely dispatchable (`ready`, `planned`, or between summary requests), the accepted call resolves `Ok({kind:"suspended", reason:"missing_identities", ...})`; state is unchanged and the operation stays open. A later `resume()` precheck returns `Err(MissingIdentities)` on the same condition. Registering missing pieces does not auto-drive. Restored `effect_pending` has no lease and follows unknown-effect recovery rather than claiming the effect never started. Synthetic settlement, usage repair, queue application, finish, and non-replay reconciliation need no identities. + +## 4.5 Crash positions and recovery policy + +Atomic transactions have no internal prefix, so for any repeat-sensitive effect there are exactly these durable positions: + +| Crash point | What is durable | Recovery | +|---|---|---| +| before the intent commit | the previous state | plan the effect normally, as if nothing happened | +| after intent, before dispatch | `effect_pending`; the effect did not run, or you cannot tell | apply the policy below | +| during or after the effect, before settlement | `effect_pending`; the outcome is unknown | same | +| after the settlement commit | output + usage + next state | continue; never re-settle | +| before / after a queue-application commit | the item is fully pending / the node exists and the item is gone | apply later / never apply twice | +| before the final structural commit | source leaf intact, generated work uncommitted | recompute per the current state and policy | +| after the final structural commit | move + entry + label + usage + finished state | done | +| after the first abort commit | cancellation and drained payloads durable | start no new ordinary effects; reconcile | +| after the terminal commit | finished state and cleared lane pointer | the lane is idle | + +**The one uncertain interval in the entire system is: intent durable, settlement absent.** Three policies cover it: + +| Restored state | Policy | +|---|---| +| generation `effect_pending` | start a later numbered attempt only if the **captured** retry policy allows. Otherwise persist a synthetic error under the already-reserved response id. If cancellation is durable, persist synthetic `aborted` under that id instead, and never retry. | +| tool `effect_pending` | re-execute the persisted `argsObjectId` only if the stored declaration **and** the current tool declaration both say `safe`. Otherwise append a synthetic `interrupted` error under the reserved result id. | +| deferred `effect_pending` | with running control, wait for the application's next `resume()`, which reserves fresh poll/response/usage ids; with cancelled control, synthetically settle the existing reserved response/usage ids as `aborted`. No cap. | + +## 4.6 Abort + +Abort is not a phase. It is `control`. + +- **First `abort()`**: one commit sets `control = cancel_requested`, records `requestedAt`, stores the exact drained steer and follow-up payloads, and leaves `phase` untouched. After it commits, the harness pulls the signal and cancels unreleased gated effects. The call resolves once the marker is durable; reconciliation runs in the background (automatic drive) or parks at its next action (manual drive). +- **Later `abort()`** while the operation is open: appends nothing, signals nothing, returns the same drained payloads. After the terminal state: `NoActiveOperation`. +- **Still allowed after cancellation**: settling effects that were already intended, writing their usage, applying accepted deferred writes, committing configuration changes, and completing the cancellation. +- **Forbidden**: starting any new provider request, tool, decision hook, or retry. +- **Post-effect hooks**: abort and a not-yet-started `after_response`/`after_tool` serialize on the effect-start check. Abort-first skips the hook; assistant/fetch settlement uses the raw response then normalizes it to `aborted`, while a live tool keeps its raw result with `terminate:false`. Hook-first lets it finish and uses its transformed value. A hook already running is not forcibly interrupted. +- **Per-object reconciliation**: planned tool calls get an aborted error result; restored started calls get `interrupted`; live started calls keep their finalized or raw result as above; an assistant or fetch settlement after cancellation is stored under the reserved response id with stop reason `aborted` and moves to cancelled checkpoint state. + +**Signal ownership makes `aborted` unambiguous.** Provider implementations must set `stopReason: "aborted"` if and only if the signal they were given was pulled, and the harness owns that signal exclusively (§4.2). Since `abort()` commits `control` before pulling it, a settled `aborted` response always has cancellation already durable. Timeouts, transport failures, malformed streams, and provider-side refusals all settle as `error` and take the ordinary retry path — which is correct, because those should retry and a user abort should not. An `aborted` response with `control.status === "running"` is unreachable; if one exists, the session is corrupt (invariant 17). + +On a deferred source, the `abort()` lane job registers the newest persisted handle/lease as a process-local cancellation target and immediately installs `EffectPlan{kind:"cancel_deferred"}` in `DriveState.running`, even when the drive is awaiting a live fetch. It is the one external action permitted to start under cancelled control, remains valid if fetch settlement advances the durable phase, crosses normal manual gating and `pi.ai.request`, calls the leased `cancelDeferred`, converts success/failure to an in-band output, and never writes operation state. Cancellation reconciliation awaits/removes that live plan before terminal finish. Failure is telemetry only and never blocks finish. `deferredCancellations` prevents repetition in one process; crash/reopen during reconciliation may retry. Missing provider identity skips cancellation but not durable reconciliation. + +There is no universal assistant closure. The harness never starts a request or appends an assistant message solely to manufacture one. An abort between steps, during tool work, or while suspended can therefore produce no abort-specific assistant event at all. + +For structural operations the commit point decides the race: a marker committed first discards in-memory generated work and finishes `aborted`; if the structural commit won, the procedure completes that already-committed compaction or navigation and finishes `completed`. + +## 4.7 Close — a controlled crash + +**Close is not abort.** Close writes nothing: no cancellation, no terminal state, no settlement. + +``` +close() + → stop admitting new work + → pull the signal, so in-flight provider requests and cooperative tools stop + → reject parked manual actions and unresolved local promises + → let commits already accepted by storage drain + → close storage, release the writer lease (§1.6) +``` + +A harness-wide admission barrier linearizes close against every operation and surface commit. A commit that acquires admission first is allowed to finish and close waits for it; close that seals admission first prevents the commit from entering storage. A stream cut after sealing settles locally as `aborted`, but its settlement transaction is never admitted. Durable state therefore stops at `effect_pending`, exactly as after process death. + +So close needs no recovery machinery of its own: reopening finds `effect_pending` and applies the §4.5 policy — a later numbered attempt under the captured retry policy, or a synthetic error at the cap. Open operations remain open and resumable. + +This also keeps invariant 17 true. Close pulls the same signal as abort, but the sealed admission barrier prevents that locally aborted response from committing with running control. + +## 4.8 Faults + +A failed storage commit faults the whole harness. A faulted harness stops all effects and rejects pending and future calls with `HarnessFault`; it is never an `Err` result. `faulted: true` appears in snapshots obtained before the fault closes observation. After the cause is fixed, reopening restores each lane from its refs. Close likewise rejects already-accepted local operation promises with `HarnessClosed`; calls not yet accepted return `Err(Closed)`. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from a trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or an `entryProjector`) is an application defect and faults the harness; it never escapes as an undeclared operation error. `AgentTool.prepareArguments` is the deliberate exception handled by the tool pipeline as a synthetic tool error. + +--- + +# Part 5 — Public surface + +## 5.1 The lane surface + +Expected rejection returns `Result.err`. Accepted operations return `Result.ok`, including failed, aborted, and suspended outcomes. Storage faults, close during accepted work, and invariant defects reject the promise. + +```ts +interface AgentLane { + readonly name: string; + getLeafId(): Promise; + + prompt(text: string, images?: ImageContent[]): Promise; + prompt(message: AgentMessage | AgentMessage[]): Promise; + skill(name: string, additionalInstructions?: string): Promise; + promptFromTemplate(name: string, args?: string[]): Promise; + compact(options?: { customInstructions?: string }): Promise; + navigateTree(targetId: string | null, options?: NavigateOptions): Promise; + resume(): Promise; + abort(): Promise; + + steer(message: string | AgentMessage, images?: ImageContent[]): Promise; + followUp(message: string | AgentMessage, images?: ImageContent[]): Promise; + nextRun(message: string | AgentMessage, images?: ImageContent[]): Promise; + cancelQueued(nodeId: string): Promise; + + recordUsage(usage: Usage, options?: { nodeId?: string; details?: JsonValue }): + Promise; + waitForIdle(): Promise; + runWhenIdle(callback: () => void | Promise): Promise; + + peekAction(): Promise; + executeAction(): Promise; + runToCompletion(): Promise; + + /** Undefined when the durable provider/model identity is not registered. */ + getModel(): Promise; + setModel(model: Model): Promise; + getThinkingLevel(): Promise; setThinkingLevel(l: ThinkingLevel): Promise; + getActiveTools(): Promise; setActiveTools(names: string[]): Promise; + + session: SessionTree; + watch(): Promise>; +} + +interface NavigateOptions { summarize?: boolean; label?: string; customInstructions?: string } +interface ActionInfo { kind: string; description: string; details?: JsonValue } +interface WatchHandle { snapshot: T; start(listener: EventListener): void; unsubscribe(): void } +``` + +Skill/template expansion precedes storage. Prompt intent names only normalized caller messages, excluding captured `nextRun` and hook injections. + +`waitForIdle()` registers on the lane mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local operation/admission reservation is held. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. + +`runWhenIdle(callback)` waits by the same rule, then takes a process-local lane admission reservation for the callback. The reservation is released on return or throw; callback rejection propagates. The callback must not invoke a state-mutating method on the same lane, which would deadlock behind its own reservation. Close rejects callbacks not yet started and waits for an already-running callback, which cannot be forcibly interrupted. + +### Results and errors + +```ts +type Result = { ok: true; value: T } | { ok: false; error: E }; +type Tagged> = + Error & { readonly _tag: Tag } & Readonly

; + +type OptionalFinalAssistant = + | { finalEntryId: string; finalMessage: AssistantMessage } + | { finalEntryId?: never; finalMessage?: never }; + +type MissingIdentitySuspension = { + kind: "suspended"; reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; +}; + +type RunOutcome = + | ({ kind: "completed"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) + | { kind: "suspended"; reason: "deferred"; leafId: string; + finalEntryId: string; deferred: DeferredHandle } + | (MissingIdentitySuspension & { leafId: string }); + +type CompactionOutcome = + | { kind: "completed"; leafId: string; entry: CompactionEntry } + | { kind: "declined" | "aborted"; leafId: string } + | { kind: "failed"; leafId: string; error: OperationError } + | (MissingIdentitySuspension & { leafId: string }); + +type NavigationOutcome = + | { kind: "completed"; oldLeafId: string | null; newLeafId: string | null; + summaryEntry?: BranchSummaryEntry } + | { kind: "declined" | "aborted"; leafId: string | null } + | { kind: "failed"; leafId: string | null; error: OperationError } + | (MissingIdentitySuspension & { leafId: string | null }); + +type ResumeOutcome = + | ({ operation: "run"; runId: string } & RunOutcome) + | ({ operation: "compaction"; runId: string } & CompactionOutcome) + | ({ operation: "navigation"; runId: string } & NavigationOutcome); +``` + +A completed run may omit final assistant fields when every finalized tool result terminates. The two fields are always both present or both absent. + +Expected errors use the existing `TaggedError` implementation in `harness/result.ts`: + +| tag | fields beyond `message` | +|---|---| +| `LaneBusy` | `lane`, `operationId`, `operationKind` | +| `MissingIdentities` | `lane`, `tools`, `models` | +| `NoActiveRun`, `NoActiveOperation`, `NothingToResume`, `NothingToCompact` | `lane` | +| `InvalidMessage`, `InvalidNavigation` | `lane`, `reason` | +| `UnknownSkill`, `UnknownTemplate` | `name` | +| `UnknownTarget` | `targetId` | +| `UnknownQueueItem` | `lane`, `nodeId` | +| `LaneExists`, `InvalidLane` | `lane` (`InvalidLane` also has `reason`) | +| `Closed` | none | + +```ts +type RunResult = Result<{ runId: string } & RunOutcome, + LaneBusy | MissingIdentities | InvalidMessage | UnknownSkill | UnknownTemplate | Closed>; +type CompactionResult = Result<{ runId: string } & CompactionOutcome, + LaneBusy | MissingIdentities | NothingToCompact | Closed>; +type NavigationResult = Result<{ runId: string } & NavigationOutcome, + LaneBusy | MissingIdentities | InvalidNavigation | UnknownTarget | Closed>; +type ResumeResult = Result; +type QueueResult = Result<{ nodeId: string }, NoActiveRun | InvalidMessage | Closed>; +type NextRunResult = Result<{ nodeId: string }, InvalidMessage | Closed>; +type CancelQueuedResult = Result< + { kind: "cancelled" | "already_consumed" | "already_cleared" }, UnknownQueueItem | Closed>; +type AbortResult = Result<{ runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, + NoActiveOperation | Closed>; +type RecordUsageResult = Result<{ objectId: string }, Closed>; + +class HarnessFault extends Error { + readonly cause: unknown; + constructor(message: string, cause: unknown) { super(message); this.cause = cause; } +} +class HarnessClosed extends Error {} +``` + +`runId` is the operation's durable `operationId`; the public name remains for compatibility. `HarnessFault` and `HarnessClosed` reject promises; they are not tagged expected errors and not members of these unions. + +## 5.2 The harness + +```ts +class AgentHarness + implements AgentLane { + /** Initializes an unconfigured main when needed, then restores every lane + without starting provider, tool, hook, or timer effects. One suspended + entry per lane with an open operation. */ + static create(options: AgentHarnessOptions): Promise<{ + harness: AgentHarness; + suspended: SuspendedOperation[]; + }>; + + lane(name: string): Promise; // lookup, never creates + createLane(name: string, at: string | null): Promise>; + lanes(): Promise; // always includes "main" + + // Harness-global. Tool implementations are code and cannot persist; active + // names live in each lane's configuration. setTools replaces only the registry. + getTools(): Promise[]>; + setTools(t: AgentHarnessTool[]): Promise; + getResources(): Promise; setResources(r: Resources): Promise; + getStreamOptions(): Promise; + setStreamOptions(o: AgentHarnessStreamOptions): Promise; + getRetryPolicy(): Promise; setRetryPolicy(p: RetryPolicy): Promise; + getCompactionSettings(): Promise; + setCompactionSettings(s: CompactionSettings): Promise; + getSteeringMode(): Promise; setSteeringMode(m: QueueMode): Promise; + getFollowUpMode(): Promise; setFollowUpMode(m: QueueMode): Promise; + + watchSession(): Promise<{ snapshot: SessionSnapshot; + start: (l: EventListener) => void; unsubscribe: () => void }>; + + hooks: Hooks; + events: Events; + + /** Detach cleanly (§4.7). Open operations stay resumable. */ + close(): Promise; +} + +interface LaneInfo { + name: string; + leafId: string | null; + operation: null | { id: string; kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting" }; +} + +interface SuspendedOperation { + lane: string; operationId: string; + kind: "run" | "compaction" | "navigation"; + reason: "crash" | "deferred" | "missing_identities"; + startedAt: number; + prompt?: AgentMessage[]; + deferred?: DeferredHandle; + aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; + missing: { tools: string[]; models: string[] }; +} + +// QueueMode, RetryPolicy, and CompactionSettings use the source types named in §0.7. +``` + +### Options + +```ts +/** AgentHarnessStreamOptions is the curated source type from §0.7. It excludes + signal and provider lifecycle callbacks, which the harness owns. */ +interface AgentHarnessOptions { + session: Session; + models: Models; + + // Immutable lane seed captured at create(). Initializes main when the session + // is first attached, and every lane later created by this harness. Never a + // fallback for a lane that already has a configuration. + model: Model; + thinkingLevel?: ThinkingLevel; // default "off" + activeToolNames?: string[]; // default: initial tool names + + tools?: AgentHarnessTool[]; + toolContext?: TContext | (() => TContext | Promise); + systemPrompt?: string | ((ctx: TContext) => string | Promise); // per request + resources?: Resources; // skills, prompt templates + + streamOptions?: AgentHarnessStreamOptions; + retry?: RetryPolicy; + compaction?: CompactionSettings; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + toolExecution?: "sequential" | "parallel"; // default parallel + drive?: "automatic" | "manual"; // default automatic + + toProviderMessages?: (m: AgentMessage[]) => Message[] | Promise; + entryProjectors?: Record; + /** Existing typed telemetry contract; defaults to no-op. */ + telemetryContext?: TelemetryContext; +} + +type Resources = AgentHarnessResources; +type EntryProjector = (entry: CustomEntry) => + AgentMessage[] | undefined | Promise; +``` + +`create()` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Before restore, it commits that seed as the first `lane.config` for a fresh or normalized-v3 `main`. Existing lanes use only their current config; the seed never overrides them. A configuration-less lane in a format-4 session is corrupt. + +`createLane(name, at)` atomically writes its pointer and the original captured seed, regardless of later changes. Setters replace only their lane's value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through `setStreamOptions({ deferred: ... })` or initial `streamOptions`; `before_request` may patch the same curated field per attempt. + +Initial, replacement, and hook-patched stream options are normalized to detached JSON-safe values before publication because ready states persist them. Functions, symbols, bigint values, cycles, non-finite numbers, and unsupported prototypes in metadata reject construction/the setter without changing settings; an invalid hook patch is isolated as `handler_error` and ignored without changing operation state. Patch deletion semantics are applied before this validation. + +`systemPrompt`, `toolContext`, `toProviderMessages`, and `entryProjectors` are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. `before_run` receives one preview evaluation of `systemPrompt`. A hook override is fixed in `Operation`; without one, the callback is evaluated again per provider request. + +## 5.3 SessionTree + +```ts +interface SessionTree { + getLeafId(): Promise; + getEntry(id: string): Promise; + getStats(): Promise; + + // Global facts. Latest wins; not branch-scoped. undefined deletes; JSON null + // is a legitimate custom value. Custom keys cannot collide with name or labels. + getName(): Promise; + setName(name: string | undefined): Promise; + getLabel(targetId: string): Promise; + setLabel(targetId: string, label: string | undefined): Promise; + getCustomFact(key: string): Promise; + setCustomFact(key: string, value: JsonValue | undefined): Promise; + + /** Session-wide, all branches, sequence order. */ + findEntries(query?: EntryQuery): Promise; + findEntry(query?: EntryQuery): Promise; + + /** Branch-scoped: the path from start toward root (§2.5). */ + findEntriesOnBranch(query?: BranchScan): Promise; + findEntryOnBranch(query?: BranchScan): Promise; + + // Writes resolve on durable acceptance; the returned id is the node id, + // reserved when the write defers. + appendMessage(message: AgentMessage): Promise; + appendCustomEntry(customType: string, data?: JsonValue): Promise; +} + +interface EntryQuery { type?: EntryType; customType?: string; + order?: "asc" | "desc"; limit?: number; cursor?: EntryCursor } +interface SessionStats { messageCount: number; usage: Usage } +``` + +Global queries filter first, then apply the exclusive cursor, then `limit`; default order is `"desc"`. A descending cursor retains `seq < cursor.seq`, and an ascending cursor retains `seq > cursor.seq`. + +Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. + +`SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getEntry` return only committed entries: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. + +## 5.4 Snapshots and subscription + +```ts +const { snapshot, start, unsubscribe } = await lane.watch(); +await send(client, { kind: "snapshot", snapshot }); // snapshot on the wire first +start((event) => send(client, event)); // flush buffer in order, then live +``` + +`watch()` atomically snapshots and begins buffering. `start(listener)` flushes in order, then delivers live; each event arrives once, in order, without sequence numbers or registration races. `unsubscribe()` drops the watcher and its buffer. A never-started watcher buffers without bound. + +```ts +interface QueuedItem { nodeId: string; message: AgentMessage } + +interface LaneSnapshot { + lane: string; + transcript: Entry[]; // this lane's context window plus its compaction entry + leafId: string | null; + + operation: null | { + id: string; + kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting"; + startedAt: number; + suspended?: SuspendedOperation; + streamingMessage?: AssistantMessage; // message_start until entry commit + runningTools: { toolCallId: string; toolName: string; args: unknown; + partialResult?: AgentToolResult }[]; + retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; + }; + + queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; + pendingWrites: { nodeId: string; type: EntryType; customType?: string; + message?: AgentMessage; data?: JsonValue }[]; + faulted: boolean; +} + +interface SessionSnapshot { + lanes: (LaneInfo & { suspended?: SuspendedOperation })[]; + faulted: boolean; +} +``` + +`operation.status` derives from durable state plus a process-local suspension marker: `suspended` for deferred, restored, or missing-identity suspension; `aborting` when `control.status === "cancel_requested"`; otherwise `running`. The missing-identity marker stores the exact `SuspendedOperation`, survives until a successful resume attempt or abort in this process, and is reconstructed as `reason:"crash"` after reopen. It changes snapshots but never durable recovery state. `queues` and `pendingWrites` derive from `inbox` and `pendingNextRun`, with content dereferenced by object id. `streamingMessage` and `runningTools` are process-local extras layered on top. + +Rules: + +- Configuration is **not** in snapshots. Getters return current values; `config_update` events tell a UI when to re-read. One source of truth. +- `streamingMessage` is not part of `transcript`. `message_end` replaces it with the final post-hook value but does not clear it; the matching `entry_added` confirms the append, adds the entry to `transcript`, and clears the draft. +- Direct messages and finalized tool results use the same immediate `message_start` → `message_end` lifecycle and enter `transcript` only on `entry_added`. They never populate `streamingMessage`. +- An `aborting` snapshot reports only state that actually exists. It never synthesizes a streaming assistant message. +- Reconnect means a new `watch()`. Only process death loses stream state; a restored harness shows the suspended operation instead. Every entry in the durable transcript is complete — a lost draft was never an entry. +- A lane watcher receives events whose `lane` matches, plus events with no lane. The harness-global `usage` event is the explicit exception: it carries its originating lane but reaches every watcher, because its totals are session-wide. + +## 5.5 Events + +One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot mutate execution, payloads are isolated from procedure objects, and a throw produces `handler_error` plus telemetry without affecting execution. Only hooks intercept. + +Durable-fact events fire **after** commit — `entry_added` means queryable. Multi-write events wait for full success, then follow mutation order. Process-local lifecycle events need not be durable: `message_end` precedes the entry append. + +```ts +type HarnessEventPayload = + // Run lifecycle + | { type: "run_start"; runId: string } + | { type: "run_resume"; runId: string } + | { type: "run_suspend"; runId: string; reason: "deferred"; + deferred: DeferredHandle } + | { type: "run_suspend"; runId: string; reason: "missing_identities"; + missing: { tools: string[]; models: string[] } } + | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } + | ({ type: "run_end"; runId: string; leafId: string | null } & ( + | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) + | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant))) + | { type: "fault"; code: string; message: string } + | ({ type: "handler_error"; error: string; stack?: string } & + ({ kind: "hook"; hook: string } | { kind: "event"; event: string })) + + // Steps and retries. First-try success emits no retry events. + | { type: "turn_start"; runId: string; turnId: string } + | { type: "turn_end"; runId: string; turnId: string; + message: AssistantMessage; toolResults: ToolResultMessage[] } + | { type: "retry_scheduled"; runId: string; step: string; attempt: number; + maxAttempts: number; delayMs: number; errorMessage: string } + | { type: "retry_start"; runId: string; step: string; attempt: number } + | { type: "retry_end"; runId: string; step: string; attempt: number; + success: boolean; finalError?: string } + + // Messages + | { type: "message_start"; runId?: string; message: AgentMessage } + | { type: "message_update"; runId: string; message: AgentMessage; + event: AssistantMessageEvent } + | { type: "message_end"; runId?: string; message: AgentMessage; entryId?: string } + + // Tools + | { type: "tool_start"; runId: string; turnId: string; toolCallId: string; + toolName: string; args: unknown } + | { type: "tool_update"; runId: string; turnId: string; toolCallId: string; + toolName: string; partialResult: AgentToolResult } + | { type: "tool_end"; runId: string; turnId: string; toolCallId: string; + toolName: string; result: AgentToolResult; isError: boolean; terminate: boolean } + + // Tree, queues, facts + | { type: "entry_added"; entry: Entry } + | { type: "write_pending"; runId: string; nodeId: string; type: EntryType } + | { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; + nextRun: QueuedItem[] } + | ({ type: "fact_update" } & ( + | { fact: "name"; name: string | undefined } + | { fact: "label"; targetId: string; label: string | undefined } + | { fact: "custom"; key: string; value: JsonValue | undefined })) + + // Configuration + | ({ type: "config_update" } & ( + | { property: "model"; value: { provider: string; modelId: string }; previous: unknown } + | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel } + | { property: "activeTools"; value: string[]; previous: string[] } + | { property: "tools" | "resources" | "streamOptions" | "retryPolicy" + | "compactionSettings" | "steeringMode" | "followUpMode" })) + + // Structural + | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" } + | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & ( + | { outcome: "completed"; entry: CompactionEntry; fromHook: boolean } + | { outcome: "declined" | "aborted" } + | { outcome: "failed"; error: OperationError })) + | { type: "navigation_start"; runId: string; targetId: string | null } + | ({ type: "navigation_end"; runId: string; + oldLeafId: string | null; newLeafId: string | null } & ( + | { outcome: "completed"; summaryEntry?: BranchSummaryEntry } + | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never } + | { outcome: "failed"; error: OperationError; summaryEntry?: never })) + + // Lanes and cost + | { type: "lane_created"; at: string | null } + | { type: "usage"; lane: string; seq: number; entry: UsageEntry; totals: Usage }; + +type SpecialEventPayload = Extract; +type LaneEventPayload = Exclude; +type ConfigEventPayload = Extract; +type LaneConfigEventPayload = Extract; +type GlobalConfigEventPayload = Exclude; +type HandlerErrorPayload = Extract; + +type HarnessEvent = + | (LaneEventPayload & { lane: string; recovery?: true }) + | (LaneConfigEventPayload & { lane: string; recovery?: true }) + | (Extract & + { lane?: never; recovery?: never }) + | (Extract & { recovery?: never }) + | (GlobalConfigEventPayload & { lane?: never; recovery?: never }) + | (HandlerErrorPayload & ( + | { lane: string; recovery?: true } + | { lane?: never; recovery?: never } + )); + +type HarnessEventType = HarnessEvent["type"]; +type EventListener = + (event: E) => void | Promise; + +interface Events { + on( + type: T, + listener: EventListener>, + ): () => void; +} +``` + +`lane` is required on run/turn/retry/message/tool, entry/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. `handler_error` follows the failed handler's scope. `usage` is the global-delivery exception: base `lane` is absent, while its payload carries origin lane and durable `seq`. `recovery: true` appears on process-local lifecycle re-emitted by `resume()`, never on events for already-existing durable entries. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage `seq` it has applied, preventing a late older event from regressing totals. + +Ordering for a streamed assistant response, asserted exactly by the conformance tests: + +``` +message_start → message_update* → after_response hook → message_end (final value, +optional reserved id) → atomic response + usage + classified-state commit +→ entry_added → usage +``` + +Only `entry_added` proves durability. Classification is computed before the transaction and becomes durable with it; it is not a separate event. Abort and overflow classification may normalize the committed response after `message_end`, so `entry_added` is authoritative for those two cases. A synthetic settlement performs no provider effect, update, or response hook: `message_start → message_end → atomic commit → entry_added → usage`. + +Nesting: + +``` +run_start + message_start / message_end / entry_added consumed prompt and queue messages + turn_start + message_start / message_update* / message_end assistant stream finished + entry_added response committed + tool_start / tool_update* / tool_end per real call + message_start / message_end tool results, source order + entry_added each result committed + turn_end + compaction_start … entry_added … compaction_end auto, at a checkpoint + turn_start … turn_end until nothing is pending +run_end +``` + +Deferred and recovery brackets are deterministic: + +- initial assistant generation uses `turnId = stepId`; a durable deferred response ends that turn, then emits `run_suspend`; +- every application `resume()` emits `run_resume`; `recovery:true` is present only when this harness restored the operation after process loss, not for same-process deferred resume; +- one deferred poll opens a turn whose durable id is `${stepId}:poll:${poll}`. Pending/error/ready settlement and any ready tool batch complete inside that turn, followed by `turn_end` and then suspend/failure/checkpoint; +- restored unresolved tools re-open their persisted `ToolBatch.turnId` with `recovery:true`, emit only new replay/interruption tool lifecycle, then close that recovery turn. Existing message/entry events are never replayed; +- resumed structural work re-emits its structural start with `recovery:true`; structural streams emit no message lifecycle and their typed result alone emits `entry_added`. + +Deferred polls emit no retry lifecycle. Events may contain sensitive conversation and tool content. Serving layers own authorization and redaction. Payload objects are isolated from mutable procedure state. Telemetry alone is content- and secret-free by default. + +## 5.6 Hooks + +Hooks are awaited interception points. Registration is harness-global; every payload carries `lane`. + +```ts +type BeforeResumePrepared = + | { kind: "run"; prompt: AgentMessage[]; systemPromptOverride?: string } + | { kind: "compaction"; sourceLeafId: string | null; + customInstructions?: string } + | { kind: "navigation"; sourceLeafId: string | null; targetId: string | null; + summarize: boolean; label?: string; customInstructions?: string }; + +interface HookMap { + before_run: { + event: { prompt: AgentMessage[]; systemPrompt: string; resources: Resources }; + result: { messages?: AgentMessage[]; systemPrompt?: string; resumeData?: JsonValue } | undefined; + }; + before_resume: { + event: BeforeResumePrepared & { resumeData?: JsonValue }; + result: void; + }; + before_run_end: { + event: { runId: string; messages: AgentMessage[] }; + result: { followUp?: string } | undefined; + }; + transform_context: { + event: { messages: AgentMessage[] }; + result: { messages: AgentMessage[] } | undefined; + }; + before_request: { + event: { model: Model; + step: "assistant" | "deferred" | "compaction" | "branch_summary"; + attempt: number; streamOptions: AgentHarnessStreamOptions }; + result: { streamOptions?: AgentHarnessStreamOptionsPatch } | undefined; + }; + before_payload: { + event: { model: Model; payload: unknown }; + result: { payload: unknown } | undefined; + }; + after_response: { + event: { status?: number; headers?: Record; + message: SettledAssistantMessage }; + result: { message?: SettledAssistantMessage } | undefined; + }; + before_tool: { + event: { toolCallId: string; toolName: string; args: Record }; + result: { args?: Record; + block?: { reason: string; terminate?: boolean } } | undefined; + }; + after_tool: { + event: { toolCallId: string; toolName: string; args: Record; + content: AgentToolResult["content"]; details?: JsonValue; + isError: boolean; usage?: Usage }; + result: { content?: AgentToolResult["content"]; details?: JsonValue; + isError?: boolean; usage?: Usage; terminate?: boolean } | undefined; + }; + before_compaction: { + event: { reason: "manual" | "threshold" | "overflow"; + preparation: CompactionPreparation; customInstructions?: string }; + result: { decline?: boolean; compaction?: CompactResult } | undefined; + }; + before_navigation: { + event: { targetId: string; preparation: BranchPreparation; + customInstructions?: string }; + result: { decline?: boolean; summary?: BranchSummaryResult } | undefined; + }; +} + +type HookName = keyof HookMap; +type HookInvocation = HookMap[K]["event"] & { + lane: string; + /** Durable operation id, provisional for pre-acceptance before_run. */ + runId: string; +}; +type HookHandler = + (event: HookInvocation) => Promise | HookMap[K]["result"]; + +interface Hooks { + on(name: K, handler: HookHandler, + options?: { id?: string }): () => void; +} +``` + +Uniform semantics: + +- `before_run` and `before_resume` require a stable `id`, unique within each hook name; duplicates reject synchronously. An extension reuses its id across both hooks and across restarts; the runner stores `resumeData` by id and gives each resume handler only its own value. +- Handlers run in registration order, each seeing the prior output. `messages` append; `systemPrompt` replaces. +- A throw emits `handler_error`, skips that handler, and lets the rest continue. **`before_tool` instead fails closed and blocks the tool.** +- Durable hook outputs commit before execution continues. A return alone is not durable; a pre-commit crash may rerun the hook. +- Events expose post-hook values. Passive listeners cannot transform them. + +One `EffectPlan{kind:"hook"}` runs the complete registered pipeline for that hook name and returns its final aggregate; individual handlers are not separate durable/manual actions. The runner still isolates and telemetry-wraps each handler internally. Aggregation is deterministic: + +- `before_run` appends messages and lets the latest defined system prompt replace the prior one; resume data is stored under each handler id. +- context/request/payload/response and `after_tool` transformations run in registration order, each seeing the prior transformed value; option/result patches merge field by field. +- `before_tool` argument replacements chain and are revalidated; the first block is terminal and later handlers do not run. +- `before_compaction`/`before_navigation` stop at the first decline or supplied result; if all handlers return neither, generation is selected. Returning decline plus a result is a handler error and is ignored like a throw. +- `before_run_end` uses the latest defined follow-up. + +| Hook | When | Event | Result | +|---|---|---|---| +| `before_run` | once, before acceptance, outside the mutation line | `{ prompt, systemPrompt, resources }` | `{ messages?, systemPrompt?, resumeData? }` | +| `before_resume` | on `resume()`, before any effect; must be idempotent | `BeforeResumePrepared + { lane, runId, resumeData? }` | `void` | +| `before_run_end` | at a normal finish boundary | `{ runId, messages }` | `{ followUp? }` | +| `transform_context` | per request, `AgentMessage` level, before `toProviderMessages` | `{ messages }` | `{ messages }` | +| `before_request` | per request, provider-neutral options | `{ model, step, attempt, streamOptions }` | `{ streamOptions? }` | +| `before_payload` | per request, provider-specific wire payload | `{ model, payload }` | `{ payload }` | +| `after_response` | per response, after streaming settles, before `message_end` and the commit | `{ status, headers, message }` | `{ message? }` (must keep role) | +| `before_tool` | after validation, before execution | `{ toolCallId, toolName, args }` | `{ args?, block?: { reason: string; terminate?: boolean } }` | +| `after_tool` | after execution, before the result commits; patch semantics | `{ toolCallId, toolName, args, content, details, isError, usage? }` | `{ content?, details?, isError?, usage?, terminate? }` | +| `before_compaction` | in `deciding` | `{ reason, preparation, customInstructions? }` | `{ decline?, compaction? }` | +| `before_navigation` | in `deciding` | `{ targetId, preparation, customInstructions? }` | `{ decline?, summary? }` | + +`before_request` receives `AgentHarnessStreamOptions` and returns `AgentHarnessStreamOptionsPatch`; neither can contain a signal or provider lifecycle callback. `after_response` must preserve the assistant role and may return `aborted` only when the harness signal is already aborted. `before_navigation` runs only for summarized navigation; unsummarized navigation cannot decline. + +Replay across retry and resume: + +| Hook | fresh | retry | resume | +|---|---|---|---| +| `before_run` | once | no | no (persisted in `Operation`) | +| `before_resume` | no | no | yes, idempotent | +| `transform_context`, `before_request`, `before_payload` | per request | yes | yes | +| `after_response` | per response unless abort wins before it starts | per response | same rule | +| `before_tool` | per call | — | not when the call is already `effect_pending` | +| `after_tool` | per executed result unless abort wins before it starts | — | on safe replay only, with the same abort rule | +| `before_compaction`, `before_navigation` | once, until a structural source commits | no | never once `generating` is durable | +| `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | + +`before_run_end` may fire again after a crash at the same boundary. Handlers that must not double-fire keep their own durable marker. This is the exactly-once non-goal (§0.6) surfacing in the hook layer. + +## 5.7 Agent-loop building blocks + +The existing `agent-loop.ts` remains behavior-compatible and is refactored into these exported phases. Existing fields on `AgentTool`, `AgentToolResult`, and provider messages are retained. Add recovery declaration `replay?: "never" | "safe"` to `AgentTool`; omission means `"never"`. `AgentHarnessTool` inherits it. The `AgentEventSink` below is the existing agent-loop sink, not the harness event listener; the harness adapts agent events into §5.5 events. + +```ts +interface StreamAssistantConfig { + model: Model; + thinkingLevel: ThinkingLevel; + systemPrompt?: string; + tools?: AgentTool[]; + transformContext?: (messages: AgentMessage[], signal: AbortSignal) => + Promise; + toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise; + requests: ModelRequestLease; // no registry re-resolution + streamOptions?: AgentHarnessStreamOptions; + /** Harness-owned before_payload adapter; undefined keeps the payload. */ + transformPayload?: (payload: unknown, model: Model) => + unknown | undefined | Promise; + /** Final settled-message transform used by after_response, before message_end. */ + transformResponse?: (message: SettledAssistantMessage, + metadata: { status?: number; headers?: Record }) => + Promise; + telemetryContext: TelemetryContext; + signal: AbortSignal; +} + +function streamAssistant(messages: AgentMessage[], config: StreamAssistantConfig, + emit: AgentEventSink): Promise; +// The implementation converts curated streamOptions to provider options and +// installs harness-owned payload/response callbacks; callers cannot replace them. +// Existing summary helpers gain ModelRequestLease overloads and use the same +// bound request path for every split request. + +type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; + tool: AgentTool; args: Record }; +type ImmediateOutcome = { kind: "immediate"; result: AgentToolResult; + isError: true; terminate: boolean }; +type FinalizedToolCall = { toolCall: AgentToolCall; result: AgentToolResult; + isError: boolean; terminate: boolean }; + +interface ToolCallbacks { + beforeToolCall?(call: AgentToolCall, args: Record): + Promise; + afterToolCall?(call: AgentToolCall, args: Record, + result: AgentToolResult, isError: boolean): + Promise; + executeTool?(call: PreparedToolCall): + Promise<{ result: AgentToolResult; isError: boolean }>; + onToolStart?(call: AgentToolCall, effectiveArgs: Record): Promise; + onToolResult?(call: AgentToolCall, message: ToolResultMessage, + terminate: boolean): Promise; +} + +function prepareToolCall(call: AgentToolCall, tools: AgentTool[], callbacks: ToolCallbacks, + telemetry: TelemetryContext, signal: AbortSignal): + Promise; +function executeToolCall(call: PreparedToolCall, emit: AgentEventSink, + telemetry: TelemetryContext, signal: AbortSignal): + Promise<{ result: AgentToolResult; isError: boolean }>; +function finalizeToolCall(call: PreparedToolCall, + executed: { result: AgentToolResult; isError: boolean }, + callbacks: ToolCallbacks, telemetry: TelemetryContext, + signal: AbortSignal): Promise; +``` + +External output that violates durable JSON/schema contracts is converted before settlement: an invalid provider message becomes a synthetic assistant `error` under the reserved response id; an invalid tool result becomes a synthetic error under its planned result id. Valid reported usage is retained when it can be validated independently, otherwise the synthetic entry reports zero. Invalid hook output is handled like a throwing handler (`before_tool` still fails closed); invalid caller input returns `InvalidMessage` before acceptance. No invalid value reaches `Storage.commit()`. + +`AgentTool.prepareArguments` is deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. `ToolCallbacks` contains the existing before/after callbacks plus `executeTool`, `onToolStart`, and `onToolResult` durability callbacks described in §3.8. `onToolStart` receives effective arguments after `prepareArguments`, validation, and `before_tool`; `onToolResult` receives the finalized message and terminate decision. Blocked calls may terminate when `before_tool.block.terminate` is true. Replacement arguments are validated again. + +For each live tool batch, the harness resolves `toolContext` exactly once, caches bound `AgentHarnessTool` adapters in `DriveState.toolBatches`, and passes that same context as the fifth execute argument for every call. Safe replay after restart creates one new batch snapshot; context is environmental and never persisted. + +`executeToolBatch` preserves the existing sequential/parallel behavior: source-ordered preparation and dispatch, concurrent effects in parallel mode, source-ordered finalization/results, no effect for blocked/invalid/genuine-length calls, and `terminate: true` only when every finalized outcome terminates. Compatibility wrappers keep existing public loop signatures and events. + +## 5.8 Telemetry + +Use the existing callback-based `TelemetryContext`, no-op/reference implementations, typed schema machinery, and agent-owned schemas. Do not invent a second contract. Context is passed explicitly; no core `AsyncLocalStorage` or global active span. + +Required spans remain: + +```text +pi.harness.run | compaction | navigation +pi.harness.checkpoint | turn | step | tool | hook | sleep | event_handler +pi.session.write +pi.ai.request +``` + +Operation, step, tool, hook, event, and write parents follow the actual interpreter/effect nesting. Sleep spans permit run, compaction, navigation, turn, and checkpoint parents. `stepId`/`taskId` correlate retries and recovery. Every provider request/fetch/cancel uses `pi.ai.request`; each real or safely replayed phase-two tool effect uses one tool span. + +Every storage transaction uses one `pi.session.write`. Its start attributes include `pi.session.item_count` and `pi.session.item_kinds` (`object`, `node`, `ref`); homogeneous lane/operation ids may be included, mixed transactions omit them. End attributes include first and last committed sequence. Update the existing schema from old single-mutation vocabulary to this transaction shape; no span is emitted for a conditional no-write result. Synthetic settlements and blocked/invalid tools emit no provider/tool-effect span. + +Telemetry attributes may contain declared ids, names, counts, durations, statuses, and usage. They must never contain prompts, completions, tool arguments/results, file contents, provider payloads, headers, handles, or credentials. Events and hooks may contain such content. The existing generated schema document and adapter/runtime conformance tests remain authoritative; implementation slices extend instrumentation only through those schemas. + +# Part 6 — Build order + +Build the following vertical slices in order, except SQLite work may proceed after the tree contract stabilizes. Each slice implements the named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. Passing those tests and `npm run check` is its acceptance criterion. + +The current source tree is a work-in-progress implementation of the superseded record-log design. Replace its durable shapes rather than supporting both. Each slice updates or removes incompatible consumers/tests immediately so the repository compiles and `npm run check` passes after every merge; there is no compile-only legacy quarantine. Reuse existing behavior and tests where still valid: compaction preparation/split-turn generation, agent-loop streaming/tool behavior, event buffering, telemetry contracts, repository lifecycle, `BEGIN IMMEDIATE`, and fenced SQLite leases. + +If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it to the user for review. Do not silently improvise a new durable contract inside a slice. + +| # | Slice | Implement | Required focused tests | +|---|---|---|---| +| 1 | **Single-session substrate** | Write-once objects/nodes, refs/history, atomic transactions, runtime object/custom-message schemas, stats, Memory backend, and shared conformance helpers. | Rollback, sequence order, duplicate ids, target-kind/schema validation, unknown custom roles, tombstones, immutable reads, stats, close. | +| 2 | **JSONL v4 and v3** | Object/array transaction lines, projections, torn-tail handling, format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, every v3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | +| 3 | **Tree and repositories** | Entry materialization, lane/config/state refs, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle, coherent branch/tree forks. | Placement, divergence, filters/cursors/stops, custom null/tombstones, context, fork before first attachment, configured fork snapshots/facts/zero ledger. | +| 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation/transitions, `Models.lease` and runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory, identity leases, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, compatible-descendant settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads. | +| 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance, captured request lease/options/thinking, payload/response hooks, one generation intent/effect/settlement, usage, finish, results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, automatic/manual identical state, close at every boundary. | +| 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | +| 7 | **Tools** | Refactor existing loop into three phases, bind `AgentHarnessTool` context, durable complete plans/effective args, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage. | Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, safe/unsafe replay, ordering, termination, abort-ready states. | +| 8 | **Inbox, configuration, and writes** | `nextRun`, steer/follow-up modes, dispositions/cancellation, durable drain markers, checkpoint consumption, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation, one-at-a-time crash after one drain, custom-write continuation, config-step race, writes surviving reopen. | +| 9 | **Abort, close, and failure drain** | Orthogonal control, stable drained input, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close. | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, close races, failure revived only by projecting input. | +| 10 | **Deferred provider redemption** | One poll per resume, copied configuration/options, leased request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of slice 9 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | +| 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, JSON preparation DTO/object, total structural state, hook/generated sources, leased nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | +| 12 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | +| 13 | **Navigation** | Validation, summarized decision/generation, and one final move/summary/leaf/label/finish transaction; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication. | +| 14 | **SQLite** | Rework the current unfinished schema/backend directly to objects/nodes/refs, transactions, stats, leases, repository operations, segmented branch cache, node-id-keyed FTS search projection, and explicit repair. No migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, placed-only search, forks/search/stats/repair. | +| 15 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | + +Existing source guidance: + +- `packages/agent/src/harness/session/**` and the old record reducer/tests: slices 1–3. Remove incompatible reducer code as soon as slice 1 replaces its inputs; do not preserve both durable models. +- `packages/agent/src/harness/agent-harness.ts` and new small transition/effects modules: slices 4–13 and 15. +- `packages/agent/src/agent-loop.ts`: preserve behavior while slice 7 extracts phases. +- `packages/agent/src/harness/compaction/**`: adapt, do not rewrite gratuitously, in slices 11–13. +- `packages/session-backends/sqlite-node`: slice 14; retain working transaction and lease primitives. +- Existing tests are evidence, not authority. Keep those that assert unchanged behavior and replace those tied to the record-log format. + +# Part 7 — Invariants and tests + +## 7.1 Invariants + +Storage: + +1. Objects and nodes are **write-once** and share one id namespace. Reusing an existing id for either kind is corruption. +2. Transactions are all-or-none, with consecutive `seq`. `seq` is monotonic session-wide. +3. Refs are the only mutable state. `null` is a tombstone and differs from unbound; every non-null target exists and matches its namespace's required kind. +4. No read on a hot path may fold history or depend on the absence of a record, and no query may be a table scan. + +Tree: + +5. A node's parent chain never changes. Branches share prefixes; nothing is copied. +6. A node whose `objectId` is missing or has a kind incompatible with §2.1 is corruption; only a custom node may use null. +7. Configuration and orchestration never enter the tree. Deleting every `operation` and `operation_state` object must leave a complete, valid conversation. +8. A lane's leaf moves only by append or navigation. +9. A branch segment chain, followed to its end, yields the full root path. + +Operations: + +10. An `operation_state` must name an existing operation, and that operation must be the lane's `currentOperationId` — unless the state is `finished` and the same transaction clears the lane pointer. +11. A `finished` state and `currentOperationId = null` must land in the **same** transaction. +12. Acceptance must observe `currentOperationId === null`. +13. A reserved id (response, usage, tool result, structural result) may exist only with the content its intent state named. +14. Only transition functions construct `FinishedState`. +15. At most one operation is open per lane. Two is corruption. +16. `overflowRecoveryUsed` is `true` only after overflow compaction. A transition that adds projecting conversational input or tool results and requires an assistant writes `false`; an unprojected custom write preserves it. +17. **A committed response with `stopReason: "aborted"` must have `control.status === "cancel_requested"` in the same operation state.** Providers must comply with the harness-owned signal contract; violation is corruption. +18. Current-state validation in §3.3 runs on every decoded latest lane/operation state before execution. Queue dispositions are never recovery inputs. + +Everything that used to require a bounded historical validity audit is now either unrepresentable in the types or covered by one of the above. + +## 7.2 Race catalog + +Each entry has exactly two durable histories. Test both, in manual drive, in both orders. + +| Race | Orders | +|---|---| +| `prompt` vs `prompt` on one lane | one accepts, one gets `LaneBusy` | +| `abort` vs response settlement | marker first → normalized `aborted`; response first → stop reason preserved | +| `abort` vs tool result commit | planned result synthesized; or the real result stands | +| `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | +| `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | +| `setModel` vs generation step start | old snapshot used; or new snapshot used | +| `abort` vs structural commit | `aborted` with no entry; or `completed` | +| `nextRun` vs acceptance | captured by this run; or stays for the next | +| manual-compaction reservation vs idle tree write | reservation first → write waits; write first → preparation uses the new leaf | +| deferred write vs abort | write survives abort either way | +| `close` vs parked manual action | action rejected unexecuted; durable state is the committed prefix | +| `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | + +## 7.3 Test tiers + +**Tier A — state and resume.** For every state in Part 3, construct it durably, close, reopen, and assert the next action. Coverage must include: restore with no branch or configuration walk; assistant intent with no settlement, below and at the retry cap; settlement followed by each classification branch; every settled stop reason surviving except the two deliberate normalizations; a self-contained deferred step with copied configuration, consecutive polls, repeated equal-handle pending responses, ready and terminal responses, and handle-mismatch normalization into durable failure; every tool state including planned, effect_pending safe and unsafe, and completed; a batch where every call sets `terminate` finishing the run with no further request; genuine-`length` batches proving no execution and one explanatory result per call; every overflow crash position, including that the compacted `retainedTail` omits the normalized-`error` response by the ordinary projection rule; every navigation state with no post-move generation; abort at every position; missing identities on accept and on resume; and every half-completed recovery prefix. + +For each recovery prefix: close, reopen, resume, and compare against uninterrupted recovery. Invoking recovery twice from the initial prefix is **not** sufficient. + +One corruption assertion constructs an `aborted` response with running control directly and requires load rejection. Provider conformance separately proves implementations emit `aborted` only for the supplied signal. + +**Tier B — writer conformance.** Run the public harness against an instrumented storage recording every object, node, ref, and hook. Assert exact order against the Part 3 transaction tables and the §5.5 ordering rules. This tier catches the critical regression classes: an effect starting before its intent commit, a response omitted for one stop reason, classification starting before usage is durable, or a result id reserved after clearance began. + +**Tier C — deterministic interleavings.** Every race in §7.2, both orders, manual drive. + +**Cross-cutting:** + +- **Backend conformance.** One suite, three backends, identical results — including `getLog` ordering and torn-transaction handling. +- **Drive equivalence.** The same scenario in automatic and manual drive must produce byte-identical durable state. +- **Signal ownership.** No public surface accepts a signal; a `before_request` patch carrying one has it stripped. Assert by type and by test. +- **Ledger completeness.** Every settled attempt commits its response and its usage. Failed structural attempts retain their cost. `getStats()` equals the ledger sum after every commit. A fork starts at zero. +- **Query-plan guards.** `EXPLAIN QUERY PLAN` for `scanBranch` matches §1.6 exactly — no `nodes` scan or temporary ordering b-tree. Segment tests assert copied rows are bounded by the newest compaction interval. +- **Transaction discipline.** Assert every SQLite transaction opens with `BEGIN IMMEDIATE`. Add a regression test that reads, lets a second connection commit, then writes — it must succeed, and would fail with `database is locked` under a deferred `BEGIN`. +- **Segment chain soundness.** Build a chain by alternating branch-and-append across several compactions, then assert that a full-to-root scan through the chain returns exactly the nodes a flat branch would, with no duplicates and no gaps. Both §2.6 rules — resolve-through base, chain-searched `csq` — fail this test when violated, and fail silently without it. + +--- + +# Appendix A — Glossary + +| Term | Meaning | +|---|---| +| **Object** | Write-once payload with an id. Messages, state frames, configs, usage. | +| **Node** | Write-once tree position. The public "entry id". References an object. | +| **Entry** | Node + object, materialized by storage for the application. | +| **Ref** | A mutable name bound to an object or node id. The only mutable state. | +| **Session** | One conversation: tree, facts, ledger, lanes. | +| **Lane** | Named cursor into the tree with its own config, queues, and one operation. | +| **Operation** | One accepted unit of work: run, compaction, or navigation. | +| **Effect** | Anything not pure computation: commit, provider request, tool, hook, timer. | +| **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | +| **Operation state** | The complete state of one operation at one moment. The program counter. | +| **Reserved id** | An id fixed in an intent commit and used by the matching settlement. | +| **Lane mutation line** | Per-lane serialization point where all state-dependent mutations queue. | +| **Control** | Orthogonal cancellation flag: `running` or `cancel_requested`. | +| **Checkpoint** | The state between turns where queues, writes, and finishing are decided. | +| **Continuation** | Durable answer to "does this run still owe an assistant turn?" | +| **Segment** | A branch-index range that references an older branch instead of copying it. | + +# Appendix B — Changes from harness-v2.md + +| Change | Reason | +|---|---| +| Objects and nodes replace single-row entries | Content and placement have different birth times; splitting makes both write-once and stops queued content being serialized twice | +| Refs replace four latest-wins mechanisms | One mechanism, one query | +| Durable operation state replaces journal-and-reduce recovery | Recovery is a read, not a fold; crash states are enumerable | +| Operation state holds **ids**, not payloads | Keeps immutable payload size out of repeated total state | +| Tool calls recovered from the assistant object, not repeated in state | Same | +| **Segmented** branch index replaces full prefix copy | Bounds copied rows by the compaction interval | +| `scanBranch` returns hydrated entries; `getNodes` removed | Callers receive the materialized value they need; structure-only reads remain separate | +| Navigation completes in one transaction | Removes prepared-summary and post-move recovery states entirely | +| `firstKeptEntryId` → self-contained `retainedTail` | Context never reads past a compaction | +| **Overflow responses commit with stop reason normalized to `error`** | The response describes itself, so §2.5 rule 3 excludes it. Deletes `supersededResponseNodeId`, the compaction link, and the omission rule | +| **`aborted` ⟺ the harness's own signal fired** | v2 claimed transport timeouts and provider cancellation could produce `aborted`; adapters show they produce `error`. Deletes the "unmarked aborted" retry path, its deferred source-tracking, and a test tier, and turns the case into invariant 17 | +| Provider `AbortSignal` removed from every public options type | Makes the above an invariant rather than a convention | +| Close specified as a controlled crash | No close-specific recovery machinery; reuses §4.5 | +| Retained queue dispositions; dropped state revision counters, `genuineLength`, `nextToFinalize`, and a separate adjustment kind | Dispositions preserve `already_cleared`; the remaining fields are derivable or represented directly | +| Historical validity audit replaced by bounded current-state validation | Restore validates one current materialized state and its exact immutable references, never completed history | + +The hook/event behavior, agent-loop compatibility, telemetry policy, and v3 normalization are restated here so this specification is self-contained with the named source types. + +# Appendix C — v3 compatibility + +Old coding-agent v3 JSONL files must open unchanged and restore idle. Normalization on load: + +- `custom_message` becomes a custom agent message. +- `label` and `session_info` become facts (latest by file position wins) and leave the tree. A label targets its nearest retained parent. +- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` entries disappear. They do **not** initialize or alter `LaneConfiguration`; a normalized `main` uses the immutable options seed. +- Each retained child of a discarded entry is reparented to its nearest retained ancestor. +- `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. +- An old compaction resolves `firstKeptEntryId` against its own branch and materializes that range as `retainedTail`. Format 4 never exposes or persists `firstKeptEntryId`. +- Existing `details`, `usage`, and `fromHook` are preserved; an absent `fromHook` normalizes to `false`. +- v3 ISO timestamps convert to Unix milliseconds. +- A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`. +- On first format-4 write, append one aggregate adjustment usage object with `details: { source: "v3-import" }`, summing v3 entry usage so ledger-derived totals remain unchanged. + +Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived. A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. + +# Appendix D — Open questions + +1. **Repairing a missing model captured inside an open operation.** Registering the same provider/model identity unblocks it without changing state. Replacing it with a different durable identity needs an explicit repair API and is not silently performed by `setModel`. +2. **Overflow detection remains heuristic.** The normalization specified in §3.7 is authoritative. Preserve the original reason in `errorMessage` for diagnosis. +3. **Object deduplication.** The object model permits an optional content-hash layer, but reserved ids remain canonical and no initial implementation includes deduplication. +4. **JSONL readability.** Object/node separation makes raw lines less self-contained. Implement a hydrating log/debug command only if ordinary inspection proves insufficient. From afae6a1a998dbdfacc1004cbd26c6c6a99395bb2 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 10 Aug 2026 15:22:18 +0200 Subject: [PATCH 072/284] fix(ai): update stale Gemini test model --- packages/ai/test/context-overflow.test.ts | 4 ++-- packages/ai/test/total-tokens.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 59a3fb6beeb..e66d219132c 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -203,8 +203,8 @@ describe("Context overflow error handling", () => { // ============================================================================= describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => { - it("gemini-2.0-flash - should detect overflow via isContextOverflow", async () => { - const model = getModel("google", "gemini-2.0-flash"); + it("gemini-2.5-flash - should detect overflow via isContextOverflow", async () => { + const model = getModel("google", "gemini-2.5-flash"); const result = await testContextOverflow(model, process.env.GEMINI_API_KEY!); logResult(result); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index ae4255e10de..fabff7f6962 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -222,10 +222,10 @@ describe("totalTokens field", () => { describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => { it( - "gemini-2.0-flash - should return totalTokens equal to sum of components", + "gemini-2.5-flash - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("google", "gemini-2.0-flash"); + const llm = getModel("google", "gemini-2.5-flash"); console.log(`\nGoogle / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm); From 6a214fab9577b16db06c00967c245caa0651b0f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 14:45:31 +0000 Subject: [PATCH 073/284] chore: approve contributors from issue #7748 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index a8e56d70994..24340aab629 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -341,3 +341,5 @@ PierrunoYT pr zhichli pr wesleyzhangwq pr + +dgokeeffe pr From 3709bef75e4f89508fe66f0e9d124c639de0695c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 10 Aug 2026 17:11:28 +0200 Subject: [PATCH 074/284] docs(agent): clarify durable storage terminology --- packages/agent/docs/agent-harness-spec.md | 701 +++++++++++----------- 1 file changed, 342 insertions(+), 359 deletions(-) diff --git a/packages/agent/docs/agent-harness-spec.md b/packages/agent/docs/agent-harness-spec.md index a8bf08f6098..3612098d1e6 100644 --- a/packages/agent/docs/agent-harness-spec.md +++ b/packages/agent/docs/agent-harness-spec.md @@ -25,17 +25,17 @@ a ── b ── c ── d └── e ── f ``` -A tree, because three features need history that does not move: branching (explore an approach, back out, keep the record), compaction (replace a long prefix with a summary while the original stays queryable), and forking (copy a prefix into a new session). Entries are appended and never modified or deleted. +A tree, because three features need history that does not move: branching (explore an approach, back out, keep the record), compaction (replace a long prefix with a summary while the original stays queryable), and forking (copy a prefix into a new session). Nodes are appended and never modified or deleted. -A session also holds **facts** (session name, entry labels, application key-value state — latest write wins, not part of the tree) and a **usage ledger** (every token and cost event, append-only). +A session also holds **facts** (session name, node labels, application key-value state — latest write wins, not part of the tree) and a **usage ledger** (every token and cost event, append-only). ### Lane — a cursor into the conversation -A lane is a **name plus a leaf**: the entry that new work extends. Every session has `main`. Applications create more. +A lane is a **name plus a leaf**: the node that new work extends. Every session has `main`. Applications create more. A lane owns its leaf, its configuration (model, thinking level, active tools), its queues, and at most one operation in flight. Lanes run in parallel and share nothing except the tree beneath them. -Why lanes exist: a Slack channel is one session, and each thread is a lane. Threads share the channel's history but take turns independently. Two lanes can sit on the same entry and diverge on their next append — the tree handles that, and no coordination is needed. +Why lanes exist: a Slack channel is one session, and each thread is a lane. Threads share the channel's history but take turns independently. Two lanes can sit on the same node and diverge on their next append — the tree handles that, and no coordination is needed. **Lane vs fork:** a lane *shares* history; a fork *copies* it for isolation. Use a lane for a thread in a shared conversation, a fork for a subagent, an export, or a what-if. @@ -47,17 +47,17 @@ An **operation** is one accepted unit of work on a lane — a `run` (prompt to f ## 0.3 Worked example — a Slack thread -A user posts in a channel that already has 400 entries of history. The application creates a lane for the thread, anchored at the channel's current leaf. +A user posts in a channel that already has 400 nodes of history. The application creates a lane for the thread, anchored at the channel's current leaf. ``` -harness.createLane("slack:1719432.0021", at: "e_400") +harness.createLane("slack:1719432.0021", at: "n_400") lane.prompt("what changed in auth last week?") ``` What happens, in order: 1. **Acceptance.** The harness validates, runs the `before_run` hook, and commits one transaction: the user message, the operation, and the operation's first state — *"I am at a checkpoint, and I need an assistant response."* -2. **Intent.** It commits a second transaction: *"I am about to make a provider request. The response will be entry `e_401` and the usage record will be `u_1`."* Nothing has been sent yet. +2. **Intent.** It commits a second transaction: *"I am about to make a provider request. The response will be node `n_401` and the usage record will be `u_1`."* Nothing has been sent yet. 3. **The request.** Streaming happens. This is the only part that is not durable. 4. **Settlement.** One transaction commits the response, its usage, and the next state: *"the response has tool calls; here is the batch plan, with result ids already assigned."* 5. Tool calls follow the same intent → effect → settlement shape, one pair of commits each. @@ -65,7 +65,7 @@ What happens, in order: Kill the process between any two of those transactions and restart. The harness reads the lane's state, sees exactly which of those sentences was the last one committed, and continues. If it died in step 3, it knows a request may have been billed and may or may not have produced output — that is the one genuinely uncertain window in the whole system, and there is a stated policy for it. -Meanwhile a second thread in the same channel is running its own lane, over the same 400 entries of shared history, with no coordination between them. +Meanwhile a second thread in the same channel is running its own lane, over the same 400 nodes of shared history, with no coordination between them. ## 0.4 Worked example — a crash mid-tool @@ -75,7 +75,7 @@ lane.prompt("delete the stale migrations and run the test suite") The model returns two tool calls. The harness commits the batch plan, then commits `call 0 is about to execute, with these exact arguments, and it declares itself unsafe to replay`. The tool starts deleting files. The process is killed. -On restart the harness reads one object and finds `calls[0].status = "effect_pending", replay = "never"`. It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1. The conversation stays coherent — every tool call has a result — and nothing ran twice. +On restart the harness reads one value and finds `calls[0].status = "effect_pending", replay = "never"`. It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1. The conversation stays coherent — every tool call has a result — and nothing ran twice. Had the tool declared `replay: "safe"` (a read, a query), the harness would have re-executed it with the persisted arguments instead. @@ -83,11 +83,11 @@ Had the tool declared `replay: "safe"` (a read, a query), the harness would have Everything in Parts 1–5 follows from these. -**1. Write-once objects, moving names.** Every durable thing is created once and never modified: messages, tool arguments, tree nodes, state frames. The only mutable things in the system are **refs** — names that point at objects. `lane.leaf/main` is a name. Moving a lane is moving a name. Recovery is reading three names. +**1. Write-once values and nodes, mutable slots.** Every durable value and node is created once and never modified. A **slot** is a namespaced key whose target is a value id, node id, or null. `lane.leaf/main` is a slot. Moving a lane updates that slot. Recovery reads three slots. -**2. Atomic transactions.** A transaction is a set of object creations plus ref moves, committed all-or-none with consecutive sequence numbers. There is no crash state inside a transaction. This is the only write primitive. +**2. Atomic transactions.** A transaction is a set of value/node creations plus slot updates, committed all-or-none with consecutive sequence numbers. There is no crash state inside a transaction. This is the only write primitive. -**3. The durable program counter.** After every step, the harness writes one object holding the *complete* current state of the operation, and points `op.state/{id}` at it. Recovery does not replay a journal or infer position from what is missing; it reads the state and switches on it. The state is *total* — it never depends on a previous state — but its fields are mostly **ids**, not copied payloads. +**3. The durable program counter.** After every step, the harness writes one value holding the *complete* current state of the operation, and points `op.state/{id}` at it. Recovery does not replay a journal or infer position from what is missing; it reads the state and switches on it. The state is *total* — it never depends on a previous state — but its fields are mostly **ids**, not copied payloads. **4. The effect sandwich.** Provider requests and real tool calls are wrapped in two commits: @@ -109,8 +109,8 @@ Hooks follow their replay contract instead: a result becomes durable in the tran ## 0.7 Notation - `TX[ a, b, c ]` — one atomic commit containing writes `a`, `b`, `c` in that order. -- `obj_*` object ids (internal), `e_*` tree node ids (public entry ids), `u_*` usage ids. -- `S(next)` — write the new operation-state object and rebind `op.state`. `L(next)` — the same for lane state. +- `v_*` value ids (internal), `n_*` node ids (public), `u_*` usage ids. +- `S(next)` — write the new operation-state value and update `op.state`. `L(next)` — the same for lane state. - **must / must not** are normative. Everything else is explanation. Source type provenance: @@ -145,13 +145,13 @@ interface ModelRequestLease { // Models.lease(provider: string, modelId: string): ModelRequestLease | undefined ``` -There are no orchestration "records" in this system. Every durable thing is an **object**, a **node**, or a **ref**. +There are no orchestration "records" in this system. Every durable thing is a **value**, a **node**, or a **slot**. --- # Part 1 — Storage substrate -Storage knows nothing about agents, lanes, or conversations. It stores objects, moves names, and answers a small fixed set of queries. Parts 2–4 are built entirely on this. +Storage knows nothing about agents, lanes, or conversations. It stores values and nodes, updates slots, and answers a small fixed set of queries. Parts 2–4 are built entirely on this. ## 1.1 The model @@ -159,32 +159,30 @@ Storage knows nothing about agents, lanes, or conversations. It stores objects, type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; /** Write-once. Created in exactly one transaction, never modified or deleted. */ -type StoredObject = { +type StoredValue = { [P in K]: { id: string; // globally unique within the session kind: P; seq: number; // storage-assigned at commit - lane?: string; // denormalized for scanObjects - operationId?: string; // denormalized for scanObjects - payload: ObjectPayloads[P]; + payload: ValuePayloads[P]; } }[K]; -/** A node in the conversation tree. Write-once, like every object. */ -interface TreeNode { - id: string; // the public "entry id" +/** An unmaterialized conversation-tree node. Write-once, like every value. */ +interface StoredNode { + id: string; // the public "node id" parentId: string | null; seq: number; // storage-assigned at commit - type: EntryType; + type: NodeType; customType?: string; // when type === "custom" - objectId: string | null; // the content; null for a custom entry with no data + valueId: string | null; // the content; null for a custom node with no data timestamp: number; // Unix ms, storage-assigned } -type EntryType = "message" | "compaction" | "branch_summary" | "custom"; +type NodeType = "message" | "compaction" | "branch_summary" | "custom"; -/** The only mutable thing. A name bound to an object or node id. */ -interface Ref { +/** The only mutable thing. A namespaced key whose target can change. */ +interface Slot { namespace: N; key: string; targetId: string | null; // null = explicitly unbound (a tombstone) @@ -192,12 +190,12 @@ interface Ref { } ``` -**Why nodes and objects are separate.** Content and placement have different birth times. A queued message has content at enqueue and placement much later. An assistant response needs its id fixed *before* the content exists. Splitting them lets both be write-once: an object is created when its content exists; a node is created when placement happens; neither is ever updated. Reserving an id costs nothing, because a reserved id is just a string in a state object — there is no placeholder row. +**Why nodes and values are separate.** Content and placement have different birth times. A queued message has content at enqueue and placement much later. An assistant response needs its id fixed *before* the content exists. Splitting them lets both be write-once: a value is created when its content exists; a node is created when placement happens; neither is ever updated. Reserving an id costs nothing, because a reserved id is just a string in a state value — there is no placeholder row. -## 1.2 Object kinds +## 1.2 Value kinds ```ts -interface ObjectPayloads { +interface ValuePayloads { message: { message: AgentMessage; terminate?: true }; compaction: CompactionPayload; branch_summary: BranchSummaryPayload; @@ -206,13 +204,13 @@ interface ObjectPayloads { lane_state: LaneState; operation: Operation; operation_state: OperationState; - usage: UsageEntry; + usage: UsageValue; tool_args: Record; structural_preparation: DurableStructuralPreparation; queue_disposition: { nodeId: string; disposition: "cancelled" | "cleared_by_abort" }; fact_value: { value: JsonValue }; } -type ObjectKind = keyof ObjectPayloads; +type ValueKind = keyof ValuePayloads; interface CompactionPayload { summary: string; retainedTail: AgentMessage[]; tokensBefore: number; @@ -233,25 +231,22 @@ type DurableStructuralPreparation = | { kind: "branch_summary"; messages: AgentMessage[]; fileOps: DurableFileOperations; totalTokens: number }; -interface UsageEntry { +interface UsageValue { usage: Usage; - nodeId?: string; // the entry this cost belongs to, when there is one + nodeId?: string; // the node this cost belongs to, when there is one adjustment: boolean; // true = caller-supplied reconciliation, not a provider report details?: JsonValue; } ``` -`lane` is set on `lane_config`, `lane_state`, `operation`, `operation_state`, `usage`, `tool_args`, structural preparations, and queue dispositions. `operationId` is set on `operation`, `operation_state`, structural preparations, and operation-owned usage/tool-args objects; adjustments and lane-level objects omit it. The remaining kinds carry neither. - ## 1.3 Transactions ```ts type Write = - | { kind: "object"; id: string; objectKind: ObjectKind; payload: JsonValue; - lane?: string; operationId?: string } - | { kind: "node"; id: string; parentId: string | null; type: EntryType; - customType?: string; objectId: string | null } - | { kind: "ref"; namespace: RefNamespace; key: string; targetId: string | null }; + | { kind: "value"; id: string; valueKind: ValueKind; payload: JsonValue } + | { kind: "node"; id: string; parentId: string | null; type: NodeType; + customType?: string; valueId: string | null } + | { kind: "slot"; namespace: SlotNamespace; key: string; targetId: string | null }; interface Transaction { writes: Write[] } @@ -262,9 +257,9 @@ Rules: 1. A transaction commits **all-or-none**. There is no observable state in which some of its writes exist and others do not. 2. Writes receive **consecutive** `seq` values in the order given. `seq` is monotonic session-wide across all lanes and all write kinds. -3. Within a transaction, writes apply in order: a node may reference an object created earlier in the same transaction; a ref may target an object or node created earlier in the same transaction. -4. Object and node ids share one session-wide namespace. Writing either kind under any existing object/node id is **corruption**, not an update. -5. A ref write with the same `(namespace, key)` as an earlier binding replaces it. History is retained for `getLog`, but only the latest binding is live. +3. Within a transaction, writes apply in order: a node may reference a value created earlier in the same transaction; a slot may target a value or node created earlier in the same transaction. +4. Value and node ids share one session-wide namespace. Writing either kind under any existing value/node id is **corruption**, not an update. +5. A slot write with the same `(namespace, key)` replaces the current target. History is retained for `getLog`, but only the latest slot value is live. 6. Transactions on one session are **serialized**. There is one writer and one queue. Session validates the complete transaction, including JSON serialization and runtime schemas, before storage admission. A failed admitted commit **faults the harness**: all effects stop, all calls reject, and the process must be restarted. A partially applied transaction is not tolerated. @@ -277,17 +272,16 @@ One `Storage` instance serves one session. Repository discovery and lifecycle ar interface Storage { commit(tx: Transaction): Promise; - getObjects(ids: string[]): Promise>; - /** Node joined to its object. */ - getEntries(ids: string[]): Promise>; + getValues(ids: string[]): Promise>; + /** Stored node joined to its value. */ + getNodes(ids: string[]): Promise>; - getRef(namespace: N, key: string): Promise | undefined>; - listRefs(namespace: N): Promise[]>; + getSlot(namespace: N, key: string): Promise | undefined>; + listSlots(namespace: N): Promise[]>; - scanBranch(q: BranchScan): Promise; - scanBranchStructure(q: BranchScan): Promise; - scanEntries(q: EntryScan): Promise; // session-wide tree inventory - scanObjects(q: ObjectScan): Promise; + scanBranch(q: BranchScan): Promise; + scanBranchStructure(q: BranchScan): Promise; + scanNodes(q: NodeScan): Promise; // session-wide tree inventory getStats(): Promise; // maintained projection /** Debug/audit history. Hot-path recovery never calls this. */ @@ -295,37 +289,28 @@ interface Storage { close(): Promise; } -interface NodeRef { - id: string; parentId: string | null; seq: number; - type: EntryType; customType?: string; -} - -interface EntryScan { - type?: EntryType; customType?: string; - fromSeq?: number; toSeq?: number; - order?: "asc" | "desc"; limit?: number; -} - -interface ObjectScan { - kind?: ObjectKind; lane?: string; operationId?: string; +interface NodeScan { + type?: NodeType; customType?: string; fromSeq?: number; toSeq?: number; order?: "asc" | "desc"; limit?: number; } type LogItem = - | { kind: "object"; seq: number; object: StoredObject } - | { kind: "node"; seq: number; node: TreeNode } - | { kind: "ref"; seq: number; ref: Ref }; + | { kind: "value"; seq: number; value: StoredValue } + | { kind: "node"; seq: number; node: StoredNode } + | { kind: "slot"; seq: number; slot: Slot }; ``` -Recovery and execution reads must be index-driven and bounded. They may not fold history or infer state from an absent object. Exact dereference is allowed: one current state may name a bounded set of immutable payload objects, fetched in one batch without order-dependent reduction. Public inventory and debugging APIs may intentionally read more than a hot path; their `limit`/pagination behavior is explicit at the `SessionTree` layer. +There is deliberately no value scan and no denormalized lane/operation ownership on values. Restore, facts, forks, and execution follow exact ids; node inventory uses `scanNodes`; stats use their projection; debugging uses `getLog`. + +Recovery and execution reads must be index-driven and bounded. They may not fold history or infer state from an absent value. Exact dereference is allowed: one current state may name a bounded set of immutable payload values, fetched in one batch without order-dependent reduction. Public inventory and debugging APIs may intentionally read more than a hot path; their `limit`/pagination behavior is explicit at the `SessionTree` layer. `close()` is idempotent. It seals admission, rejects later reads/commits on that instance, drains commits admitted before the seal, then releases resources and the writer claim. Durable data is reopened through the repository. -## 1.5 Ref namespaces +## 1.5 Slot namespaces ```ts -type RefNamespace = +type SlotNamespace = | "lane.leaf" | "lane.config" | "lane.state" | "op.state" | "queue.disposition" | "fact.name" | "fact.label" | "fact.custom"; @@ -334,15 +319,15 @@ type RefNamespace = | Namespace | Key | Target | Meaning | |---|---|---|---| | `lane.leaf` | lane name | node id or null | where this lane appends next | -| `lane.config` | lane name | object id | total `LaneConfiguration` | -| `lane.state` | lane name | object id | total `LaneState` (§3.3) | -| `op.state` | operation id | object id | total `OperationState` (§3.2) — **the program counter** | -| `queue.disposition` | queued node id | object id | terminal cancellation/abort disposition; never used by restore | -| `fact.name` | `""` | object id or null | session name | -| `fact.label` | node id | object id or null | entry label | -| `fact.custom` | application key | object id or null | application state | +| `lane.config` | lane name | value id | total `LaneConfiguration` | +| `lane.state` | lane name | value id | total `LaneState` (§3.3) | +| `op.state` | operation id | value id | total `OperationState` (§3.2) — **the program counter** | +| `queue.disposition` | queued node id | value id | terminal cancellation/abort disposition; never used by restore | +| `fact.name` | `""` | value id or null | session name | +| `fact.label` | node id | value id or null | node label | +| `fact.custom` | application key | value id or null | application state | -That is the complete set. A ref bound to `null` is a **tombstone**: explicitly absent, which differs from never bound. Deleting a label sets a tombstone. `queue.disposition` is written only when a pending item is cancelled or cleared by abort; it is an exact public-API lookup, not current orchestration state. +That is the complete set. A slot bound to `null` is a **tombstone**: explicitly absent, which differs from never bound. Deleting a label sets a tombstone. `queue.disposition` is written only when a pending item is cancelled or cleared by abort; it is an exact public-API lookup, not current orchestration state. ## 1.6 Backends @@ -351,9 +336,9 @@ Three encodings of one model. All three pass the same conformance suite (§7.3). ### Memory ```ts -objects: Map -nodes: Map -refs: Map // key: `${namespace}\u0000${key}` +values: Map +nodes: Map +slots: Map // key: `${namespace}\u0000${key}` children: Map // parentId → node ids, for tree walks log: LogItem[] ``` @@ -362,16 +347,16 @@ One queue serializes commits. A commit validates and applies writes to temporary ### JSONL -One physical line per `commit()`. Storage assigns sequence/timestamp fields first, then encodes one committed log item as an object line or several as one **array line**. +One physical line per `commit()`. Storage assigns sequence/timestamp fields first, then encodes one committed log item as a JSON object line or several as one **array line**. ```jsonl {"v":4,"kind":"header","id":"s_1","createdAt":1700000000000,"cwd":"..."} -[{"kind":"object","seq":1,"id":"obj_7","objectKind":"message","payload":{"message":{"role":"user","content":[...]}}}, - {"kind":"object","seq":2,"id":"op_1","objectKind":"operation","payload":{...},"lane":"main","operationId":"op_1"}, - {"kind":"node","seq":3,"timestamp":1700000000000,"id":"e_1","parentId":null,"type":"message","objectId":"obj_7"}, - {"kind":"object","seq":4,"id":"obj_9","objectKind":"operation_state","payload":{...},"lane":"main","operationId":"op_1"}, - {"kind":"ref","seq":5,"namespace":"lane.leaf","key":"main","targetId":"e_1"}, - {"kind":"ref","seq":6,"namespace":"op.state","key":"op_1","targetId":"obj_9"}] +[{"kind":"value","seq":1,"id":"v_7","valueKind":"message","payload":{"message":{"role":"user","content":[...]}}}, + {"kind":"value","seq":2,"id":"op_1","valueKind":"operation","payload":{...}}, + {"kind":"node","seq":3,"timestamp":1700000000000,"id":"n_1","parentId":null,"type":"message","valueId":"v_7"}, + {"kind":"value","seq":4,"id":"v_9","valueKind":"operation_state","payload":{...}}, + {"kind":"slot","seq":5,"namespace":"lane.leaf","key":"main","targetId":"n_1"}, + {"kind":"slot","seq":6,"namespace":"op.state","key":"op_1","targetId":"v_9"}] ``` - This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix C). @@ -380,37 +365,35 @@ One physical line per `commit()`. Storage assigns sequence/timestamp fields firs - A malformed *interior* line, or a complete-but-invalid transaction, is corruption. - Durability is process-crash level: a resolved `commit()` survives process death. No fsync promise. - `getLog` reproduces the file's logical order, expanding arrays. -- Optional: retain `(offset, length)` per object and load payloads lazily, keeping only node/ref structure resident. Do this only if profiling demands it. +- Optional: retain `(offset, length)` per value and load payloads lazily, keeping only node/slot structure resident. Do this only if profiling demands it. ### SQLite ```sql -objects(session_id, id TEXT, kind TEXT, seq INTEGER, lane TEXT, operation_id TEXT, - payload TEXT, PRIMARY KEY (session_id, id)) WITHOUT ROWID; -CREATE INDEX ix_obj_scan ON objects(session_id, kind, seq); -CREATE INDEX ix_obj_seq ON objects(session_id, seq); -CREATE INDEX ix_obj_lane ON objects(session_id, lane, kind, seq); -CREATE INDEX ix_obj_op ON objects(session_id, operation_id, seq); +-- `values` is a SQLite keyword; the physical table is `stored_values`. +stored_values(session_id, id TEXT, kind TEXT, seq INTEGER, payload TEXT, + PRIMARY KEY (session_id, id)) WITHOUT ROWID; +CREATE INDEX ix_value_seq ON stored_values(session_id, seq); nodes(session_id, id TEXT, parent_id TEXT, seq INTEGER, type TEXT, custom_type TEXT, - object_id TEXT, timestamp INTEGER, PRIMARY KEY (session_id, id)) WITHOUT ROWID; + value_id TEXT, timestamp INTEGER, PRIMARY KEY (session_id, id)) WITHOUT ROWID; CREATE INDEX ix_node_parent ON nodes(session_id, parent_id); CREATE INDEX ix_node_seq ON nodes(session_id, seq, type); -refs(session_id, namespace TEXT, key TEXT, target_id TEXT, seq INTEGER, +slots(session_id, namespace TEXT, key TEXT, target_id TEXT, seq INTEGER, PRIMARY KEY (session_id, namespace, key)); -ref_history(session_id, seq INTEGER, namespace, key, target_id, +slot_history(session_id, seq INTEGER, namespace, key, target_id, PRIMARY KEY (session_id, seq)); --- Private branch index (§2.6). Not refs; no equivalent in the other backends. -branch_entries(session_id, branch_id TEXT, node_id TEXT, node_seq INTEGER, node_type TEXT, - PRIMARY KEY (session_id, branch_id, node_id)) WITHOUT ROWID; +-- Private branch index (§2.6). Not slots; no equivalent in the other backends. +branch_nodes(session_id, branch_id TEXT, node_id TEXT, node_seq INTEGER, node_type TEXT, + PRIMARY KEY (session_id, branch_id, node_id)) WITHOUT ROWID; -- Ordered scans. node_seq must follow branch_id directly or ORDER BY needs a -- temp b-tree; node_id and node_type trail so the index covers id-only reads. -CREATE INDEX ix_be_seq ON branch_entries(session_id, branch_id, node_seq, node_id, node_type); +CREATE INDEX ix_bn_seq ON branch_nodes(session_id, branch_id, node_seq, node_id, node_type); -- Type-filtered scans. -CREATE INDEX ix_be_type ON branch_entries(session_id, branch_id, node_type, node_seq, node_id); -CREATE INDEX ix_be_node ON branch_entries(session_id, node_id); +CREATE INDEX ix_bn_type ON branch_nodes(session_id, branch_id, node_type, node_seq, node_id); +CREATE INDEX ix_bn_node ON branch_nodes(session_id, node_id); branch_meta(session_id, branch_id TEXT, tip_node_id TEXT, tip_seq INTEGER, base_branch_id TEXT, base_seq INTEGER, PRIMARY KEY (session_id, branch_id)); @@ -422,7 +405,7 @@ session_sequences(session_id, next_seq); writer_leases(session_id, owner_id TEXT, fence INTEGER, expires_at_ms INTEGER); ``` -One `commit()` is one SQL transaction: insert objects, insert nodes, upsert refs plus append `ref_history`, maintain the branch index, bump `session_stats`. Never an UPDATE to an object or node row. +One `commit()` is one SQL transaction: insert values, insert nodes, upsert slots plus append `slot_history`, maintain the branch index, bump `session_stats`. Never an UPDATE to a value or node row. **Every transaction must open with `BEGIN IMMEDIATE`.** A deferred `BEGIN` that reads before it writes takes a read snapshot and must later upgrade to the write @@ -462,34 +445,34 @@ concurrent reader observes either none of a transaction's writes or all of them. Each physical segment of `scanBranch` uses one JOIN; §2.6 combines segment ranges: ```sql -SELECT n.id, n.parent_id, n.seq, n.type, n.custom_type, n.timestamp, o.payload -FROM branch_entries b +SELECT n.id, n.parent_id, n.seq, n.type, n.custom_type, n.value_id, n.timestamp, v.kind AS value_kind, v.payload +FROM branch_nodes b CROSS JOIN nodes n ON n.session_id = b.session_id AND n.id = b.node_id -LEFT JOIN objects o ON o.session_id = n.session_id AND o.id = n.object_id +LEFT JOIN stored_values v ON v.session_id = n.session_id AND v.id = n.value_id WHERE b.session_id = ? AND b.branch_id = ? AND b.node_seq > ? AND b.node_seq <= ? ORDER BY b.node_seq; ``` -`CROSS JOIN` is load-bearing: it forces `branch_entries` to be the outer loop. Left +`CROSS JOIN` is load-bearing: it forces `branch_nodes` to be the outer loop. Left to itself the planner may drive from `nodes`, scan the table, and sort through a temporary b-tree. Assert the plan in a test: ``` -SEARCH b USING COVERING INDEX ix_be_seq (session_id=? AND branch_id=? AND node_seq>?) +SEARCH b USING COVERING INDEX ix_bn_seq (session_id=? AND branch_id=? AND node_seq>?) SEARCH n USING PRIMARY KEY (session_id=? AND id=?) -SEARCH o USING PRIMARY KEY (session_id=? AND id=?) LEFT-JOIN +SEARCH v USING PRIMARY KEY (session_id=? AND id=?) LEFT-JOIN ``` Any plan containing `USE TEMP B-TREE FOR ORDER BY` or a scan of `nodes` is a regression. -`scanBranchStructure` is the same without the `objects` join. `getEntries` is the same JOIN keyed by `n.id IN (...)`. `getLog` is a three-way `UNION ALL` over `objects`, `nodes`, and `ref_history`, ordered by `seq` — which is the whole reason `ref_history` exists. +`scanBranchStructure` is the same without the `stored_values` join. `getNodes` is the same JOIN keyed by `n.id IN (...)`. `getLog` is a three-way `UNION ALL` over `stored_values`, `nodes`, and `slot_history`, ordered by `seq` — which is the whole reason `slot_history` exists. -The repository's existing `SessionSearch` surface remains. SQLite replaces its rowid-dependent entry index with an FTS projection keyed by stored `session_id` and `node_id`; searchable text is the JSON serialization of the materialized entry, matching the scanning fallback. The transaction that places a node also inserts its projection after validating the node/object pair. Queued objects are not searchable before placement. Fork import populates the same projection, and session deletion removes its rows. Search never depends on `nodes.rowid`. +The repository's existing `SessionSearch` surface remains. SQLite replaces its rowid-dependent node index with an FTS projection keyed by stored `session_id` and `node_id`; searchable text is the JSON serialization of the materialized node, matching the scanning fallback. The transaction that places a node also inserts its projection after validating the node/value pair. Queued values are not searchable before placement. Fork import populates the same projection, and session deletion removes its rows. Search never depends on `nodes.rowid`. ## 1.7 Why write-once is worth the discipline -- **Recovery is a read.** Three refs, then point lookups. No reducer exists to have a bug. +- **Recovery is a read.** Three slots, then point lookups. No reducer exists to have a bug. - **Crash states are enumerable.** Between transactions, never inside one. - **No repair-by-rewrite.** Recovery only ever *appends*, so recovery is itself crash-safe: interrupt it and rerun it and you get the same result. - **Concurrency is trivial.** Readers never see partial state; there is nothing to lock. @@ -499,97 +482,97 @@ The repository's existing `SessionSearch` surface remains. SQLite replaces its r # Part 2 — The conversation tree -## 2.1 Entries +## 2.1 Nodes -An **entry** is what the application sees: a node joined to its object. +A **node** is what the application sees: a stored node joined to its value. ```ts -interface EntryBase { id: string; seq: number; parentId: string | null; timestamp: number } +interface NodeBase { id: string; seq: number; parentId: string | null; timestamp: number } -interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; +interface MessageNode extends NodeBase { type: "message"; message: AgentMessage; terminate?: true } -interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; +interface CompactionNode extends NodeBase { type: "compaction"; summary: string; retainedTail: AgentMessage[]; tokensBefore: number; details?: JsonValue; usage?: Usage; fromHook: boolean } -interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; +interface BranchSummaryNode extends NodeBase { type: "branch_summary"; fromId: string; summary: string; details?: JsonValue; usage?: Usage; fromHook: boolean } -interface CustomEntry extends EntryBase { type: "custom"; customType: string; data?: JsonValue } +interface CustomNode extends NodeBase { type: "custom"; customType: string; data?: JsonValue } -type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; +type Node = MessageNode | CompactionNode | BranchSummaryNode | CustomNode; ``` Materialization is mechanical, and happens inside storage: ```ts -function toEntry(node: TreeNode, object: StoredObject | undefined): Entry { +function toNode(node: StoredNode, value: StoredValue | undefined): Node { const base = { id: node.id, seq: node.seq, parentId: node.parentId, timestamp: node.timestamp }; switch (node.type) { - case "message": return { ...base, type: "message", ...object!.payload }; - case "compaction": return { ...base, type: "compaction", ...object!.payload }; - case "branch_summary": return { ...base, type: "branch_summary", ...object!.payload }; + case "message": return { ...base, type: "message", ...value!.payload }; + case "compaction": return { ...base, type: "compaction", ...value!.payload }; + case "branch_summary": return { ...base, type: "branch_summary", ...value!.payload }; case "custom": return { ...base, type: "custom", customType: node.customType!, - data: object?.payload }; + data: value?.payload }; } } ``` Rules: -- `type` and `customType` live on the **node**, not the object, because they are structural filters used by branch queries and denormalized into the branch index. -- Node/object compatibility is exact: `message → message`, `compaction → compaction`, `branch_summary → branch_summary`, and `custom → custom_data | null`. Every other pairing is corruption. -- Assistant entries always contain a `SettledAssistantMessage`. Reject `pending` before writing. -- Tool-result entries carry `terminate?: true` on the message object. It is orchestration state that `ToolResultMessage` has no field for. +- `type` and `customType` live on the **stored node**, not the value, because they are structural filters used by branch queries and denormalized into the branch index. +- Node/value compatibility is exact: `message → message`, `compaction → compaction`, `branch_summary → branch_summary`, and `custom → custom_data | null`. Every other pairing is corruption. +- Assistant nodes always contain a `SettledAssistantMessage`. Reject `pending` before writing. +- Tool-result nodes carry `terminate?: true` on the message value. It is orchestration state that `ToolResultMessage` has no field for. - Every compaction and branch summary carries `fromHook`: `true` for hook output, `false` for generated. - Every compaction stores a complete `retainedTail` (`[]` when empty). **Context never reads past a compaction.** This is what makes a compaction a self-contained checkpoint rather than a pointer into history. -- Objects are never shared between nodes by the harness. Content-hash dedup is possible under this model and explicitly not built (Appendix D). +- Values are never shared between nodes by the harness. Content-hash dedup is possible under this model and explicitly not built (Appendix D). ## 2.2 Placement The tree's central rule: -> An **object** is created when its content exists. A **node** is created when placement happens. They may land in the same transaction or in two, and neither is ever modified. +> A **value** is created when its content exists. A **stored node** is created when placement happens. They may land in the same transaction or in two, and neither is ever modified. Three cases, all mechanical: **Born placed** — assistant responses, tool results, direct appends to an idle lane. One transaction: ``` -TX[ put obj_8 = , - put node e_a4 = { parent: e_q1, type: "message", object: obj_8 }, - setRef lane.leaf/main → e_a4 ] +TX[ put v_8 = , + put node n_a4 = { parent: n_q1, type: "message", value: v_8 }, + setSlot lane.leaf/main → n_a4 ] ``` **Content first, placement later** — queued input (`steer`, `followUp`, `nextRun`) and deferred writes. Two transactions, possibly far apart: ``` -t0 TX[ put obj_7 = <200KB message>, - S(next){ ...inbox.steer += { nodeId: "e_q1", objectId: "obj_7" } } ] +t0 TX[ put v_7 = <200KB message>, + S(next){ ...inbox.steer += { nodeId: "n_q1", valueId: "v_7" } } ] -t1 TX[ put node e_q1 = { parent: e_a3, type: "message", object: obj_7 }, - setRef lane.leaf/main → e_q1, +t1 TX[ put node n_q1 = { parent: n_a3, type: "message", value: v_7 }, + setSlot lane.leaf/main → n_q1, S(next){ ...inbox.steer -= that item } ] ``` The content is serialized **once**. The node references it. -**Id reserved before content exists** — assistant responses and tool results. The id is a string in a state object; no row exists until settlement. Reserving costs nothing. +**Id reserved before content exists** — assistant responses and tool results. The id is a string in a state value; no row exists until settlement. Reserving costs nothing. Consequences to rely on: - A pending write is **invisible to tree queries** (no node) but **visible in snapshots** (the operation state names it, and its content can be dereferenced). - "Has this been placed yet?" is answered by the operation state, which lists it as pending — never by the absence of a node. -- A node whose `objectId` names a nonexistent object is corruption. +- A node whose `valueId` names a nonexistent value is corruption. ## 2.3 Lanes -A configured lane is three refs and nothing else. Fresh or normalized-v3 `main` may temporarily lack `lane.config` until first harness attachment: +A configured lane is three slots and nothing else. Fresh or normalized-v3 `main` may temporarily lack `lane.config` until first harness attachment: ``` lane.leaf/{name} → node id or null -lane.config/{name} → object id (LaneConfiguration) // absent only for unconfigured main -lane.state/{name} → object id (LaneState) +lane.config/{name} → value id (LaneConfiguration) // absent only for unconfigured main +lane.state/{name} → value id (LaneState) ``` ```ts @@ -600,16 +583,16 @@ interface LaneConfiguration { } ``` -- A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that node), or the lane navigates (leaf jumps to an existing node). -- `LaneConfiguration` is **total**. A setter writes a whole new object and rebinds the ref; it is never a patch and never a tree entry. +- A lane's leaf moves in exactly two ways: the lane appends a node (leaf becomes that node), or the lane navigates (leaf jumps to an existing node). +- `LaneConfiguration` is **total**. A setter writes a whole new value and updates the slot; it is never a patch and never a tree node. - Creating a lane copies no tree content, no history, and no configuration from its anchor: ``` -TX[ put obj_cfg = , - put obj_ls = { currentOperationId: null, pendingNextRun: [] }, - setRef lane.config/{name} → obj_cfg, - setRef lane.leaf/{name} → anchorNodeId, - setRef lane.state/{name} → obj_ls ] +TX[ put v_cfg = , + put v_ls = { currentOperationId: null, pendingNextRun: [] }, + setSlot lane.config/{name} → v_cfg, + setSlot lane.leaf/{name} → anchorNodeId, + setSlot lane.state/{name} → v_ls ] ``` - Lanes are never deleted or renamed. Names are permanent application keys. @@ -621,9 +604,9 @@ TX[ put obj_cfg = , Session-scoped, latest-wins, not part of the tree. ``` -fact.name/"" → object id | null -fact.label/{nodeId} → object id | null -fact.custom/{key} → object id | null +fact.name/"" → value id | null +fact.label/{nodeId} → value id | null +fact.custom/{key} → value id | null ``` Setting a value to `undefined` binds `null` (a tombstone). JSON `null` is a legitimate custom value, stored as `{ value: null }`. The built-in and custom namespaces never overlap. Fact writes commit immediately and never move a leaf. @@ -633,15 +616,15 @@ Setting a value to `undefined` binds `null` (a tombstone). JSON `null` is a legi ```ts interface BranchScan { start?: string; // default: the view's lane leaf - stopAtType?: EntryType; // scan ends after the first match, inclusive + stopAtType?: NodeType; // scan ends after the first match, inclusive stopAtId?: string; - type?: EntryType; + type?: NodeType; customType?: string; order?: "newestFirst" | "oldestFirst"; // default newestFirst limit?: number; - cursor?: EntryCursor; + cursor?: NodeCursor; } -type EntryCursor = { seq: number }; +type NodeCursor = { seq: number }; ``` Semantics: take the path from `start` toward the root, order it (default `newestFirst`), stop **inclusively** at the first `stopAt` match, filter by `type`/`customType`, apply the exclusive cursor, then apply `limit`. For `newestFirst`, a cursor retains `seq < cursor.seq`; for `oldestFirst`, it retains `seq > cursor.seq`. A `stopAt` node is returned only if it also passes the filter. @@ -649,9 +632,9 @@ Semantics: take the path from `start` toward the root, order it (default `newest **Context projection** — how a provider request is built: 1. `scanBranch({ start: leaf, order: "newestFirst", stopAtType: "compaction" })`. -2. Reverse to oldest-first. If a compaction terminated the scan, the context is: its `summary`, then its `retainedTail`, then every entry after it. **Nothing earlier is read.** +2. Reverse to oldest-first. If a compaction terminated the scan, the context is: its `summary`, then its `retainedTail`, then every node after it. **Nothing earlier is read.** 3. Drop assistant responses whose stop reason is `error`, `aborted`, or `deferred`. Retain genuine output-limit `length`. -4. Run custom entries through `entryProjectors`. An unprojected custom entry never enters context. +4. Run custom nodes through `nodeProjectors`. An unprojected custom node never enters context. 5. Run `transform_context`, then `toProviderMessages`. There is no rule for omitting an overflow response, and no link anywhere pointing at one. An overflow response is committed with stop reason `error` (§3.7) and is therefore dropped by rule 3 like any other error, and by any downstream `transformMessages` that filters the same way. @@ -662,7 +645,7 @@ There is no rule for omitting an overflow response, and no link anywhere pointin Memory and JSONL walk parent pointers in RAM. SQLite maintains a private segmented branch cache so a diverging append does not copy an unbounded root prefix. -`branch_entries` stores the nodes physically present in one segment. `branch_meta` stores its tip and optional `{ baseBranchId, baseSeq }`. A segment logically contains its own rows above `baseSeq` plus the referenced base prefix through `baseSeq`. +`branch_nodes` stores the nodes physically present in one segment. `branch_meta` stores its tip and optional `{ baseBranchId, baseSeq }`. A segment logically contains its own rows above `baseSeq` plus the referenced base prefix through `baseSeq`. Append: @@ -689,17 +672,17 @@ Tests assert these invariants and the required query plans. No wall-clock thresh ## 2.7 Forks -A fork is a repository operation over one coherent source-session snapshot. It copies selected nodes and objects, latest facts, lane pointers, and total configuration; it never copies open operation state or usage ledger objects. +A fork is a repository operation over one coherent source-session snapshot. It copies selected nodes and values, latest facts, lane pointers, and total configuration; it never copies open operation state or usage ledger values. ```ts type ForkOptions = - | { scope?: "branch"; entryId?: string; position?: "before" | "at" } + | { scope?: "branch"; nodeId?: string; position?: "before" | "at" } | { scope: "tree" }; ``` - Memory and JSONL obtain the snapshot as one job on the source storage queue. SQLite uses one read transaction. - Branch scope copies one path and creates only destination `main`. Tree scope copies the whole tree and every lane pointer/configuration. -- The destination is idle and its token/cost ledger starts at zero. Entry-local display usage remains on copied entries. +- The destination is idle and its token/cost ledger starts at zero. Node-local display usage remains on copied nodes. - Facts follow the selected scope: name/custom facts always copy; labels copy only when their target copies unless tree scope copies all targets. - Any message may be the fork point. Request construction heals orphaned tool calls. - The destination metadata records `parentSessionId`. @@ -708,7 +691,7 @@ A source with only fresh/unconfigured `main`—new format 4 or read-only normali ## 2.8 Session and repository boundary -`Storage` is deliberately one-session only. `Session` supplies typed validation, lane-bound views, and object/node/ref materialization. `SessionRepo` owns discovery and storage-instance lifecycle: +`Storage` is deliberately one-session only. `Session` supplies typed validation, lane-bound views, and value/node/slot materialization. `SessionRepo` owns discovery and storage-instance lifecycle: ```ts interface SessionMetadata { @@ -726,7 +709,7 @@ interface SessionCodecOptions { interface SessionSearchOptions { text: string; cwd?: string } interface SessionSearchHit { - metadata: M; entryId: string; timestamp: string; snippet?: string; score?: number; + metadata: M; nodeId: string; timestamp: string; snippet?: string; score?: number; } interface SessionSearch { search(options: SessionSearchOptions): Promise[]>; @@ -750,10 +733,10 @@ interface Session extends SessionTr /** Package-internal harness substrate; validates before delegating to Storage. */ commit(tx: Transaction): Promise; - getObjects(ids: string[]): Promise>; - getEntries(ids: string[]): Promise>; - getRef(namespace: N, key: string): Promise | undefined>; - listRefs(namespace: N): Promise[]>; + getValues(ids: string[]): Promise>; + getNodes(ids: string[]): Promise>; + getSlot(namespace: N, key: string): Promise | undefined>; + listSlots(namespace: N): Promise[]>; getLog(fromSeq?: number, limit?: number): Promise; close(): Promise; @@ -773,7 +756,7 @@ interface Operation { sourceLeafId: string | null; startedAt: number; intent: - | { kind: "run"; promptObjectIds: string[]; + | { kind: "run"; promptValueIds: string[]; systemPromptOverride?: string; resumeData?: Record } | { kind: "compaction"; customInstructions?: string } | { kind: "navigation"; targetId: string | null; summarize: boolean; @@ -781,11 +764,11 @@ interface Operation { } ``` -The operation object's stored id is exactly `operationId`. It is written once at acceptance. +The operation value's stored id is exactly `operationId`. It is written once at acceptance. ## 3.2 Operation state — the program counter -`op.state/{operationId}` points at one total `operation_state` object: +`op.state/{operationId}` points at one total `operation_state` value: ```ts type OperationState = RunState | CompactionState | NavigationState | FinishedState; @@ -843,9 +826,9 @@ interface Inbox { writes: PendingWrite[]; } -interface QueuedInput { nodeId: string; objectId: string } -interface PendingWrite { nodeId: string; objectId: string | null; - type: EntryType; customType?: string } +interface QueuedInput { nodeId: string; valueId: string } +interface PendingWrite { nodeId: string; valueId: string | null; + type: NodeType; customType?: string } interface OperationError { code: string; message: string; details?: JsonValue } ``` @@ -861,7 +844,7 @@ interface NormalizedRetryPolicy { maxAttempts: number; baseDelayMs: number } interface GenerationContext { stepId: string; triggerNodeId: string; - configurationObjectId: string; + configurationValueId: string; streamOptions: AgentHarnessStreamOptions; retryPolicy: NormalizedRetryPolicy; } @@ -869,7 +852,7 @@ interface GenerationContext { type Generation = | { status: "ready"; context: GenerationContext; nextAttempt: number } | { status: "effect_pending"; context: GenerationContext; attempt: number; - responseNodeId: string; responseObjectId: string; usageObjectId: string; + responseNodeId: string; responseValueId: string; usageValueId: string; intendedOutputLimit: number; contextWindow: number } | { status: "retry_wait"; context: GenerationContext; nextAttempt: number; notBefore: number; errorMessage: string }; @@ -883,7 +866,7 @@ For each attempt, `before_request` runs from generation `ready` (an elapsed retr interface ToolBatch { assistantNodeId: string; /** Producing generation/fetch snapshot; active tool names come from here. */ - configurationObjectId: string; + configurationValueId: string; /** The assistant generation step id; recovered tool events use it as turnId. */ turnId: string; calls: ToolCall[]; @@ -893,7 +876,7 @@ type ToolCall = | { status: "planned"; sourceIndex: number; resultNodeId: string } | { status: "effect_pending"; sourceIndex: number; resultNodeId: string; /** Always the effective post-prepare/post-hook arguments. */ - argsObjectId: string; replay: "never" | "safe" } + argsValueId: string; replay: "never" | "safe" } | { status: "completed"; sourceIndex: number; resultNodeId: string; terminate: boolean }; ``` @@ -905,10 +888,10 @@ The source call comes from `assistantNodeId` plus `sourceIndex`; large effective ```ts type Deferred = | { status: "suspended"; stepId: string; sourceNodeId: string; poll: number; - configurationObjectId: string; streamOptions: AgentHarnessStreamOptions } + configurationValueId: string; streamOptions: AgentHarnessStreamOptions } | { status: "effect_pending"; stepId: string; sourceNodeId: string; poll: number; - responseNodeId: string; responseObjectId: string; usageObjectId: string; - configurationObjectId: string; streamOptions: AgentHarnessStreamOptions }; + responseNodeId: string; responseValueId: string; usageValueId: string; + configurationValueId: string; streamOptions: AgentHarnessStreamOptions }; ``` One `resume()` performs at most one `fetchDeferred(handle, { wait: 0 })`. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A pending response must have a completely equal handle and becomes the next source. A mismatched pending handle is normalized to a durable `error` response explaining the mismatch; response, usage, `latestAssistantNodeId`, and response-provenance `failure_drain` commit atomically. @@ -916,7 +899,7 @@ One `resume()` performs at most one `fetchDeferred(handle, { wait: 0 })`. Suspen ### Structural work ```ts -type StructuralDecision = { taskId: string; preparationObjectId: string } & ( +type StructuralDecision = { taskId: string; preparationValueId: string } & ( | { status: "deciding" } | { status: "generating"; generation: SummaryGeneration } ); @@ -925,7 +908,7 @@ interface SummaryContext { taskId: string; resultNodeId: string; kind: "compaction" | "branch_summary"; - configurationObjectId: string; + configurationValueId: string; streamOptions: AgentHarnessStreamOptions; retryPolicy: NormalizedRetryPolicy; reason?: "manual" | "threshold" | "overflow"; @@ -935,8 +918,8 @@ type SummaryGeneration = | { status: "ready"; context: SummaryContext; nextAttempt: number } | { status: "effect_pending"; context: SummaryContext; attempt: number; /** Current nested request intent; absent between requests. */ - request?: { index: number; usageObjectId: string }; - usageObjectIds: string[] } + request?: { index: number; usageValueId: string }; + usageValueIds: string[] } | { status: "retry_wait"; context: SummaryContext; nextAttempt: number; notBefore: number; errorMessage: string }; @@ -967,9 +950,9 @@ type FinishedState = { ); ``` -Structural preparation is built from the reserved source leaf and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once as `structural_preparation` before the decision hook. State carries only `preparationObjectId`; hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. +Structural preparation is built from the reserved source leaf and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once as `structural_preparation` before the decision hook. State carries only `preparationValueId`; hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. -A normal run finish copies `RunState.latestAssistantNodeId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes always include the newest settled assistant when non-null and omit both final fields otherwise. Structural operations omit `runCompletion` and final assistant. One structural attempt may make one or two provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageObjectId}`, then performs that provider request through a nested Effects action, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any restored `effect_pending` attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. A durable `generating` decision prevents its decision hook from rerunning. +A normal run finish copies `RunState.latestAssistantNodeId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes always include the newest settled assistant when non-null and omit both final fields otherwise. Structural operations omit `runCompletion` and final assistant. One structural attempt may make one or two provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageValueId}`, then performs that provider request through a nested Effects action, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any restored `effect_pending` attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. A durable `generating` decision prevents its decision hook from rerunning. ## 3.3 Lane state and current-state validity @@ -980,21 +963,21 @@ interface LaneState { } ``` -Restore validates only current materialized state and objects it directly names; it never audits historical states. Required checks: +Restore validates only current materialized state and values it directly names; it never audits historical states. Required checks: -- lane state, operation object, operation state, and refs agree on lane, operation id, and operation kind; -- every ref targets an existing object/node of its namespace's required kind; -- every referenced queue/configuration/args/assistant/preparation object exists, has the expected kind, lane/operation identity, and valid JSON DTO; +- `lane.state/{lane}` targets a `LaneState`; when it names operation O, value O is an `Operation` for that lane, and `op.state/O` targets an `OperationState` compatible with O's intent kind; +- every slot targets an existing value/node of its namespace's required kind; +- every referenced queue/configuration/args/assistant/preparation value exists and has the expected kind and valid JSON DTO; - finished outcome/error/control combinations are valid for the operation kind, finished state is paired atomically with a cleared lane operation, and a completed run omits its final assistant only with `runCompletion:"terminated_tools"`; - tool source indices are complete, ordered, unique, in range, and use unique result ids; completed result nodes match their source calls; - reserved response/result/usage ids, if materialized, contain the intended kind and identity; - cancellation, navigation source/target, and structural-source combinations satisfy the state discriminants. -Runtime schemas validate every decoded object/state before publication. These bounded checks reject corrupted/imported state that TypeScript transition functions could not have produced. +Runtime schemas validate every decoded value/state before publication. These bounded checks reject corrupted/imported state that TypeScript transition functions could not have produced. ## 3.4 The atomic transition rule -> Compute the next total state in memory, then atomically commit every object, node, ref move, and state binding that makes that state true. +> Compute the next total state in memory, then atomically commit every value, node, and slot update that makes that state true. A transaction writing total `LaneState` rereads its latest value inside the lane mutation line and changes only the fields owned by that transition. In particular, finish clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. Every edge below is exactly one `commit()`. @@ -1050,12 +1033,12 @@ navigation: ready_to_commit ───────────────── | From | Trigger | Transaction | |---|---|---| -| idle lane | `prompt()` after `before_run` | `TX[ put new message objects (caller prompt and hook injections), put nodes for captured nextRun objects and new messages in order, setRef lane.leaf, put Operation, S(run{captured settings, checkpoint need_assistant(false), trigger=newest node, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured nextRun removed}) ]` | -| reserved idle lane | `compact()` with non-empty preparation | `TX[ put preparation P, put Operation, S(compaction{deciding, preparationObjectId:P}), L({currentOperationId: O}) ]` | +| idle lane | `prompt()` after `before_run` | `TX[ put new message values (caller prompt and hook injections), put nodes for captured nextRun values and new messages in order, setSlot lane.leaf, put Operation, S(run{captured settings, checkpoint need_assistant(false), trigger=newest node, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured nextRun removed}) ]` | +| reserved idle lane | `compact()` with non-empty preparation | `TX[ put preparation P, put Operation, S(compaction{deciding, preparationValueId:P}), L({currentOperationId: O}) ]` | | idle lane | unsummarized `navigateTree()` after validation | `TX[ put Operation, S(navigation{ready_to_commit}), L ]` | -| reserved idle lane | summarized `navigateTree()` with preparation | `TX[ put preparation P, put Operation, S(navigation{summary.deciding, preparationObjectId:P}), L ]` | +| reserved idle lane | summarized `navigateTree()` with preparation | `TX[ put preparation P, put Operation, S(navigation{summary.deciding, preparationValueId:P}), L ]` | -Captured `nextRun` items already have objects; acceptance places their nodes and removes them from `pendingNextRun`. Their content is not re-serialized. +Captured `nextRun` items already have values; acceptance places their nodes and removes them from `pendingNextRun`. Their content is not re-serialized. Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. @@ -1069,13 +1052,13 @@ Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `In |---|---|---|---| | checkpoint `need_assistant` | drive | conditionally snapshot current lane config and normalized retry policy in `TX[ S(assistant{ready, nextAttempt:1}) ]` | ready | | assistant `ready` | `before_request` aggregate completes | `TX[ S(assistant{effect_pending, attempt=nextAttempt, reserved R/U, intendedOutputLimit, contextWindow}) ]` | effect_pending | -| effect_pending | settles with tool calls | `TX[ put response object, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, tools{plan with reserved result ids}) ]` | tools | -| effect_pending | retryable error, attempts remain | `TX[ put response, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]` | retry_wait | -| effect_pending | first overflow, preparation non-empty | `TX[ put response **normalized to error**, node R, leaf ref, usage U, put preparation P, S(latestAssistantNodeId=R, compaction{reason:overflow, structural:{deciding, taskId, preparationObjectId:P}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]` | compaction | -| effect_pending | first overflow, preparation empty | `TX[ put normalized response, node R, leaf ref, usage U, S(latestAssistantNodeId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | -| effect_pending | `stopReason: "deferred"` | `TX[ put response, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, deferred{suspended, sourceNodeId R, poll 0, config/options copied}) ]` | deferred | -| effect_pending | `stop` or genuine `length` | `TX[ put response, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | -| effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ put response, put node R, setRef lane.leaf, put usage U, S(latestAssistantNodeId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| effect_pending | settles with tool calls | `TX[ put response value, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, tools{plan with reserved result ids}) ]` | tools | +| effect_pending | retryable error, attempts remain | `TX[ put response, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]` | retry_wait | +| effect_pending | first overflow, preparation non-empty | `TX[ put response **normalized to error**, node R, leaf slot, usage U, put preparation P, S(latestAssistantNodeId=R, compaction{reason:overflow, structural:{deciding, taskId, preparationValueId:P}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]` | compaction | +| effect_pending | first overflow, preparation empty | `TX[ put normalized response, node R, leaf slot, usage U, S(latestAssistantNodeId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| effect_pending | `stopReason: "deferred"` | `TX[ put response, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, deferred{suspended, sourceNodeId R, poll 0, config/options copied}) ]` | deferred | +| effect_pending | `stop` or genuine `length` | `TX[ put response, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | +| effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ put response, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | | retry_wait | `notBefore` elapsed | `TX[ S(assistant{ready, nextAttempt:k+1}) ]` | ready | **There is never a durable "response without usage" or "response and usage without a decision."** All three land together or none do. @@ -1113,9 +1096,9 @@ An overflow classification never produces a tool plan. A *genuine* `length` that | From | Trigger | Transaction | To | |---|---|---|---| -| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ put effective tool_args object, S(call i = effect_pending, argsObjectId, replay) ]` | dispatch | -| call *i* `effect_pending` | effect settled, `after_tool` applied | `TX[ put result object, put node, setRef lane.leaf, put tool usage (if reported), S(call i = completed, terminate) ]` | tools or checkpoint | -| call *i* `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | `TX[ put synthetic error result object, put node, setRef lane.leaf, S(call i = completed, terminate from an intentional block, otherwise false) ]` | tools | +| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ put effective tool_args value, S(call i = effect_pending, argsValueId, replay) ]` | dispatch | +| call *i* `effect_pending` | effect settled, `after_tool` applied | `TX[ put result value, put node, setSlot lane.leaf, put tool usage (if reported), S(call i = completed, terminate) ]` | tools or checkpoint | +| call *i* `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | `TX[ put synthetic error result value, put node, setSlot lane.leaf, S(call i = completed, terminate from an intentional block, otherwise false) ]` | tools | | all calls completed | — | folded into the last settlement | checkpoint | The batch's completion transition is: @@ -1123,7 +1106,7 @@ The batch's completion transition is: - **every** completed call set `terminate: true` → `checkpoint{may_finish, includeFinalAssistant: false}` - otherwise → `checkpoint{need_assistant(overflowRecoveryUsed: false)}` -`terminate` exists so a tool can end the run without another provider turn. The motivating case is a "submit final result" tool used in place of structured output: the model calls it, the harness commits the result, and the run finishes with those tool results as its final entries — `run_end` then carries no `finalMessage`. Without this, every such run would pay for one more model turn whose only job is to stop. +`terminate` exists so a tool can end the run without another provider turn. The motivating case is a "submit final result" tool used in place of structured output: the model calls it, the harness commits the result, and the run finishes with those tool results as its final nodes — `run_end` then carries no `finalMessage`. Without this, every such run would pay for one more model turn whose only job is to stop. Modes: @@ -1149,62 +1132,62 @@ Both operations generate a summary through the same `deciding → generating → | `threshold` | context-size check at a checkpoint | back to the stored `resumeAfter` | | `overflow` | a request that did not fit | `failure_drain` | -"Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and entry into `deciding` commit together (`put preparation P` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never creates `StructuralDecision`: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. +"Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and the transition into `deciding` commit together (`put preparation P` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never creates `StructuralDecision`: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. | From | Trigger | Transaction | |---|---|---| | deciding | hook declines | standalone: `TX[ S(finished{declined}), L({currentOperationId: null}) ]` · threshold: `TX[ S(restore marked resumeAfter) ]` · overflow: `TX[ S(failure_drain{error, provenance:structural taskId}) ]` | -| deciding | hook supplies compaction | standalone: `TX[ hook usage?, result object, node, leaf ref, S(finished), L(currentOperationId:null) ]`; in-run: same entry writes plus `S(resumeAfter)` | +| deciding | hook supplies compaction | standalone: `TX[ hook usage?, result value, node, leaf slot, S(finished), L(currentOperationId:null) ]`; in-run: same result-publication writes plus `S(resumeAfter)` | | deciding | hook supplies navigation summary | use §3.10's final transaction with the hook usage/result | | deciding | hook selects generation | conditionally snapshot current config/policy in `TX[ S(generating{ready}) ]` — **the decision hook will never run again** | | generating ready / retry elapsed | drive | `TX[ S(effect_pending, attempt k) ]` | -| generating effect_pending | one nested request returns | `TX[ put usage under request.usageObjectId, S(effect_pending, request cleared, usageObjectIds += id) ]`; commit another request intent before request two | +| generating effect_pending | one nested request returns | `TX[ put usage under request.usageValueId, S(effect_pending, request cleared, usageValueIds += id) ]`; commit another request intent before request two | | generating effect_pending | retryable attempt outcome | usage is already durable; `TX[ S(retry_wait) ]` | | generating effect_pending | terminal or attempts exhausted | standalone: `TX[ S(finished{failed}), L ]` · in-run: `TX[ S(failure_drain{provenance:structural taskId}) ]` | -| generating effect_pending | compaction succeeded | standalone: `TX[ result object, node, leaf ref, S(finished), L(currentOperationId:null) ]`; in-run: entry writes plus `S(resumeAfter)` | +| generating effect_pending | compaction succeeded | standalone: `TX[ result value, node, leaf slot, S(finished), L(currentOperationId:null) ]`; in-run: result-publication writes plus `S(resumeAfter)` | Structural provider streams are internal: they emit **no** public assistant-message lifecycle. The existing summary generator is retained, but its one/two request callback uses the nested request intent/effect/usage boundaries from §3.2 and §4.2. Intermediate content is not persisted; a crash before the final transaction makes the whole attempt unknown, and a later numbered attempt starts only under the captured retry policy. Failed-attempt usage stays in the ledger regardless. ### Worked example — overflow -`e_40` is a tool result awaiting an assistant turn. The request does not fit. +`n_40` is a tool result awaiting an assistant turn. The request does not fit. ``` -… e_38 ── e_39 ── e_40 phase: assistant, effect_pending +… n_38 ── n_39 ── n_40 phase: assistant, effect_pending continuation was need_assistant(false) ``` **1. Settlement.** Classification says overflow. Preparation is built against the would-be branch; because the known response is normalized to `error`, ordinary projection excludes it. Response and preparation then commit together: ``` -TX[ put obj(response, stopReason "error", errorMessage "context window exceeded: …"), - put node e_41, setRef lane.leaf → e_41, put usage u_41, +TX[ put response value { stopReason: "error", errorMessage: "context window exceeded: …" }, + put node n_41, setSlot lane.leaf → n_41, put usage u_41, put structural_preparation p_41, S(compaction{ reason: overflow, - structural: { deciding, taskId, preparationObjectId: p_41 }, - resumeAfter: { checkpoint, triggerNodeId: e_40, + structural: { deciding, taskId, preparationValueId: p_41 }, + resumeAfter: { checkpoint, triggerNodeId: n_40, continuation: need_assistant(true) } }) ] -… e_38 ── e_39 ── e_40 ── e_41 +… n_38 ── n_39 ── n_40 ── n_41 ``` -**2. Compaction.** The durable preparation was built by the ordinary rules in §2.5. `e_41` is an `error` response, so rule 3 dropped it — from the summary input and from `retainedTail` alike, with no special case: +**2. Compaction.** The durable preparation was built by the ordinary rules in §2.5. `n_41` is an `error` response, so rule 3 dropped it — from the summary input and from `retainedTail` alike, with no special case: ``` -… e_40 ── e_41 ── e_42 (compaction) - retainedTail: [e_39, e_40] ← e_41 absent by rule 3 +… n_40 ── n_41 ── n_42 (compaction) + retainedTail: [n_39, n_40] ← n_41 absent by rule 3 ``` -The tail ends on `e_40`, a tool result, which is the correct shape for a request that is about to ask for an assistant turn. +The tail ends on `n_40`, a tool result, which is the correct shape for a request that is about to ask for an assistant turn. -**3. Resume.** `resumeAfter` restores `need_assistant(overflowRecoveryUsed: true)`. Context is now summary + tail + anything after `e_42`, which is small: +**3. Resume.** `resumeAfter` restores `need_assistant(overflowRecoveryUsed: true)`. Context is now summary + tail + anything after `n_42`, which is small: ``` -… e_41 ── e_42 ── e_43 the answer to e_40 +… n_41 ── n_42 ── n_43 the answer to n_40 ✗ (error, out of context) ``` -`e_41` remains in the tree forever as durable history — a request was made and billed. If the retry overflows *again*, `overflowRecoveryUsed` is already `true` and the run goes to `failure_drain` rather than compacting in a loop. Consuming new user input appends to the tree and resets the flag to `false`. +`n_41` remains in the tree forever as durable history — a request was made and billed. If the retry overflows *again*, `overflowRecoveryUsed` is already `true` and the run goes to `failure_drain` rather than compacting in a loop. Consuming new user input appends to the tree and resets the flag to `false`. ## 3.10 Navigation @@ -1212,32 +1195,32 @@ Unsummarized and summarized both finish in **one** transaction: ``` TX[ put hook-reported usage (only for a hook-supplied summary), - setRef lane.leaf → target, - put summary object + node with its display usage snapshot (when summarize; parent is target), - setRef lane.leaf → summary node (when summarize), - put label fact object + setRef fact.label (when a label is present), + setSlot lane.leaf → target, + put summary value + node with its display usage snapshot (when summarize; parent is target), + setSlot lane.leaf → summary node (when summarize), + put label fact value + setSlot fact.label (when a label is present), S(finished{completed, leafId}), L({currentOperationId: null}) ] ``` -Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary node explicitly names the target as parent, and the following ref write makes that summary the completed lane leaf. A crash sees either an untouched navigation still at its source, or a fully completed one. **No prepared-summary state and no post-move recovery state exist.** Abort before this transaction finishes `aborted` with no entry; abort after it means the operation completed. +Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary node explicitly names the target as parent, and the following slot write makes that summary the completed lane leaf. A crash sees either an untouched navigation still at its source, or a fully completed one. **No prepared-summary state and no post-move recovery state exist.** Abort before this transaction finishes `aborted` with no node; abort after it means the operation completed. ## 3.11 Inbox, queues, deferred writes | Public input | Admitted when | Transaction | |---|---|---| -| `nextRun(msg)` | any state, including idle | `TX[ put message object, L(pendingNextRun += {nodeId, objectId}) ]` — never starts a run | -| `steer(msg)` | active running run | `TX[ put message object, S(inbox.steer += item) ]` | -| `followUp(msg)` | active running run | `TX[ put message object, S(inbox.followUp += item) ]` | -| tree write, run active | including suspended and cancelling | `TX[ put object, S(inbox.writes += item) ]` — survives abort | -| tree write, lane idle | idle | `TX[ put object, put node, setRef lane.leaf ]` | +| `nextRun(msg)` | any state, including idle | `TX[ put message value, L(pendingNextRun += {nodeId, valueId}) ]` — never starts a run | +| `steer(msg)` | active running run | `TX[ put message value, S(inbox.steer += item) ]` | +| `followUp(msg)` | active running run | `TX[ put message value, S(inbox.followUp += item) ]` | +| tree write, run active | including suspended and cancelling | `TX[ put value, S(inbox.writes += item) ]` — survives abort | +| tree write, lane idle | idle | `TX[ put value, put node, setSlot lane.leaf ]` | | tree write, structural op open | — | wait for the operation to end, then re-evaluate | -| `cancelQueued(id)` | item still pending | `TX[ S or L with the item removed, put queue disposition, setRef queue.disposition/{id} ]` | -| checkpoint consumes input | eligible | `TX[ put node(s), setRef lane.leaf, S(items removed, continuation → need_assistant(false), triggerNodeId = newest node, skipInboxOnce = true) ]` | +| `cancelQueued(id)` | item still pending | `TX[ S or L with the item removed, put queue disposition, setSlot queue.disposition/{id} ]` | +| checkpoint consumes input | eligible | `TX[ put node(s), setSlot lane.leaf, S(items removed, continuation → need_assistant(false), triggerNodeId = newest node, skipInboxOnce = true) ]` | | first `abort()` | run active | `TX[ S(control = cancel_requested, requestedAt, drainedSteer, drainedFollowUp, steer/followUp emptied), dispositions for every drained item ]` | | finish | inbox empty, no required continuation | `TX[ S(finished), L({currentOperationId: null}) ]` | -`cancelQueued` outcomes: pending → cancel and write its disposition; node exists → `already_consumed`; disposition ref exists → `already_cleared`; none of those → `UnknownQueueItem`. Dispositions are queried only by this exact public lookup and never participate in restore. +`cancelQueued` outcomes: pending → cancel and write its disposition; node exists → `already_consumed`; disposition slot exists → `already_cleared`; none of those → `UnknownQueueItem`. Dispositions are queried only by this exact public lookup and never participate in restore. Because acceptance, cancellation, consumption, abort, and finish all serialize on the lane mutation line, every race has exactly two possible histories, and **no item can be both pending and applied** in durable state. @@ -1255,7 +1238,7 @@ Order matters. At each queue drain point, `"all"` consumes every currently eligi Consumed steer/follow-up and projecting message writes enter `need_assistant(false)`, set `triggerNodeId` to the newest appended node, and set `skipInboxOnce`. Tool results do the same unless every result terminates. An unprojected custom write is appended and removed from the inbox but preserves the prior continuation, failure provenance, and overflow flag. Under cancelled control, every deferred write is appended and removed without changing phase/continuation or starting work; reconciliation finishes aborted after writes drain. -`before_run_end` may return a follow-up. It commits **only** if control is still running and the operation is still at the same finish boundary; otherwise the stale hook result is dropped. Its object, node, and the `need_assistant` state commit together. +`before_run_end` may return a follow-up. It commits **only** if control is still running and the operation is still at the same finish boundary; otherwise the stale hook result is dropped. Its value, node, and the `need_assistant` state commit together. `failure_drain` applies accepted writes, then eligible steer and follow-up input in the same order. Projecting user-context input atomically enters `checkpoint{need_assistant(false)}` and clears the failure. Unprojected custom writes do not. With no such input, it finishes failed without `before_run_end` or another provider request. @@ -1270,12 +1253,12 @@ The runtime plans from total durable state plus a small process-local scheduler. ```ts interface CurrentOperation { operation: Operation; - operationStateObjectId: string; + operationStateValueId: string; state: OperationState; - laneStateObjectId: string; + laneStateValueId: string; laneState: LaneState; leafId: string | null; - configurationObjectId: string; + configurationValueId: string; } type EffectKey = string; // deterministic from durable step/attempt or assistant/sourceIndex @@ -1307,7 +1290,7 @@ type EffectPlan = { telemetryContext: TelemetryContext } & ( generation: Extract; identity: RuntimeProviderLease } | { kind: "tool"; key: EffectKey; assistantNodeId: string; - sourceIndex: number; argsObjectId: string; identity: RuntimeToolLease } + sourceIndex: number; argsValueId: string; identity: RuntimeToolLease } | { kind: "deferred"; key: EffectKey; deferred: Extract; streamOptions: AgentHarnessStreamOptions; identity: RuntimeProviderLease } @@ -1359,7 +1342,7 @@ type PlannerInputs = { running: ReadonlyMap; deferredPollsRemaining: 0 | 1; deferredCancellations: ReadonlySet; - immutable: ReadonlyMap; + immutable: ReadonlyMap; runtime: RuntimeSnapshot; context?: AgentMessage[]; now: number; @@ -1370,7 +1353,7 @@ type OperationResult = RunOutcome | CompactionOutcome | NavigationOutcome; type Action = | { kind: "transition"; next: OperationState; telemetryContext: TelemetryContext; /** Required when this transition snapshots current mutable request state. */ - expectedConfigurationObjectId?: string; + expectedConfigurationValueId?: string; expectedSettingsRevision?: number } | { kind: "dispatch"; intent?: OperationState; effect: EffectPlan; consumeDeferredPoll?: true } @@ -1388,7 +1371,7 @@ async function drive(current: CurrentOperation, live: DriveState): Promise; commitEffectSettlement(current: CurrentOperation, plan: EffectPlan, @@ -1494,13 +1477,13 @@ interface Effects { Promise>; /** Composite summary plans use this reentrantly for each provider request. */ runSummaryRequest(plan: { taskId: string; attempt: number; requestIndex: number; - usageObjectId: string; configurationObjectId: string; + usageValueId: string; configurationValueId: string; messages: AgentMessage[]; identity: RuntimeProviderLease; telemetryContext: TelemetryContext }): Promise; settleSummaryRequest(current: CurrentOperation, plan: { taskId: string; attempt: number; requestIndex: number; - usageObjectId: string }, + usageValueId: string }, response: SettledAssistantMessage, telemetry: TelemetryContext): Promise; /** Revalidates/registers effect start on the lane mutation line before execution. */ @@ -1539,38 +1522,38 @@ Consequence: every race between two public calls has exactly **two** possible du async function restore(lane: string): Promise< { kind: "idle"; lane: string } | { kind: "suspended"; current: CurrentOperation } > { - const configRef = await storage.getRef("lane.config", lane); - const stateRef = await storage.getRef("lane.state", lane); - const leafRef = await storage.getRef("lane.leaf", lane); - const refs = { configRef, stateRef, leafRef }; + const configSlot = await storage.getSlot("lane.config", lane); + const stateSlot = await storage.getSlot("lane.state", lane); + const leafSlot = await storage.getSlot("lane.leaf", lane); + const slots = { configSlot, stateSlot, leafSlot }; - const laneRoots = await storage.getObjects([configRef.targetId, stateRef.targetId]); - const laneState = laneRoots.get(stateRef.targetId)!.payload; + const laneRoots = await storage.getValues([configSlot.targetId, stateSlot.targetId]); + const laneState = laneRoots.get(stateSlot.targetId)!.payload; - let opRoots = new Map(); + let opRoots = new Map(); if (laneState.currentOperationId) { - const opStateRef = await storage.getRef("op.state", laneState.currentOperationId); - opRoots = await storage.getObjects([ - laneState.currentOperationId, opStateRef.targetId + const opStateSlot = await storage.getSlot("op.state", laneState.currentOperationId); + opRoots = await storage.getValues([ + laneState.currentOperationId, opStateSlot.targetId ]); } const roots = merge(laneRoots, opRoots); - const objectIds = directObjectIds(roots); // includes pendingNextRun content - const nodeIds = directMaterializedNodeIds(roots, leafRef.targetId); - const [objects, entries] = await Promise.all([ - storage.getObjects(objectIds), storage.getEntries(nodeIds) + const valueIds = directValueIds(roots); // includes pendingNextRun content + const nodeIds = directMaterializedNodeIds(roots, leafSlot.targetId); + const [values, nodes] = await Promise.all([ + storage.getValues(valueIds), storage.getNodes(nodeIds) ]); - validateCurrent(refs, roots, objects, entries); + validateCurrent(slots, roots, values, nodes); - if (!laneState.currentOperationId) return idle(lane, laneRoots, entries, laneState); + if (!laneState.currentOperationId) return idle(lane, laneRoots, nodes, laneState); return suspended({ operation: ..., state: ... }); // drive() resumes it } ``` -That is the entire current-state restore path: three lane refs, the operation-state ref, root object lookup, then bounded batched object/entry lookups for exactly the immutable objects and nodes directly named by current lane/operation state. Restore performs §3.3's bounded validation over that set. It does not fold history, build provider context, probe for missing planned entries, or audit completed operations. +That is the entire current-state restore path: three lane slots, the operation-state slot, root value lookup, then bounded batched value/node lookups for exactly the immutable values and nodes directly named by current lane/operation state. Restore performs §3.3's bounded validation over that set. It does not fold history, build provider context, probe for missing planned nodes, or audit completed operations. -Restore already fetched directly referenced immutable objects for validation. The driver reuses/caches them and lazily builds only derived provider context or additional branch projections needed by the next action; `nextAction` itself switches on scalars and supplied immutable maps. +Restore already fetched directly referenced immutable values for validation. The driver reuses/caches them and lazily builds only derived provider context or additional branch projections needed by the next action; `nextAction` itself switches on scalars and supplied immutable maps. Admission resolves configured identities and returns `Err(MissingIdentities)` before writing when any are absent. Each later assistant, deferred, tool, or whole-summary-attempt preparation snapshots process-local provider/tool leases before its pre-intent hook. That registry/settings-line snapshot is the step-start order: lookup, `prepareArguments`, schema validation, hook event, intent, and dispatch all retain the same lease even if the registry is replaced while the hook runs. Both split-summary requests share the attempt's lease. If resolution fails while state is still safely dispatchable (`ready`, `planned`, or between summary requests), the accepted call resolves `Ok({kind:"suspended", reason:"missing_identities", ...})`; state is unchanged and the operation stays open. A later `resume()` precheck returns `Err(MissingIdentities)` on the same condition. Registering missing pieces does not auto-drive. Restored `effect_pending` has no lease and follows unknown-effect recovery rather than claiming the effect never started. Synthetic settlement, usage repair, queue application, finish, and non-replay reconciliation need no identities. @@ -1586,7 +1569,7 @@ Atomic transactions have no internal prefix, so for any repeat-sensitive effect | after the settlement commit | output + usage + next state | continue; never re-settle | | before / after a queue-application commit | the item is fully pending / the node exists and the item is gone | apply later / never apply twice | | before the final structural commit | source leaf intact, generated work uncommitted | recompute per the current state and policy | -| after the final structural commit | move + entry + label + usage + finished state | done | +| after the final structural commit | move + node + label + usage + finished state | done | | after the first abort commit | cancellation and drained payloads durable | start no new ordinary effects; reconcile | | after the terminal commit | finished state and cleared lane pointer | the lane is idle | @@ -1595,7 +1578,7 @@ Atomic transactions have no internal prefix, so for any repeat-sensitive effect | Restored state | Policy | |---|---| | generation `effect_pending` | start a later numbered attempt only if the **captured** retry policy allows. Otherwise persist a synthetic error under the already-reserved response id. If cancellation is durable, persist synthetic `aborted` under that id instead, and never retry. | -| tool `effect_pending` | re-execute the persisted `argsObjectId` only if the stored declaration **and** the current tool declaration both say `safe`. Otherwise append a synthetic `interrupted` error under the reserved result id. | +| tool `effect_pending` | re-execute the persisted `argsValueId` only if the stored declaration **and** the current tool declaration both say `safe`. Otherwise append a synthetic `interrupted` error under the reserved result id. | | deferred `effect_pending` | with running control, wait for the application's next `resume()`, which reserves fresh poll/response/usage ids; with cancelled control, synthetically settle the existing reserved response/usage ids as `aborted`. No cap. | ## 4.6 Abort @@ -1607,7 +1590,7 @@ Abort is not a phase. It is `control`. - **Still allowed after cancellation**: settling effects that were already intended, writing their usage, applying accepted deferred writes, committing configuration changes, and completing the cancellation. - **Forbidden**: starting any new provider request, tool, decision hook, or retry. - **Post-effect hooks**: abort and a not-yet-started `after_response`/`after_tool` serialize on the effect-start check. Abort-first skips the hook; assistant/fetch settlement uses the raw response then normalizes it to `aborted`, while a live tool keeps its raw result with `terminate:false`. Hook-first lets it finish and uses its transformed value. A hook already running is not forcibly interrupted. -- **Per-object reconciliation**: planned tool calls get an aborted error result; restored started calls get `interrupted`; live started calls keep their finalized or raw result as above; an assistant or fetch settlement after cancellation is stored under the reserved response id with stop reason `aborted` and moves to cancelled checkpoint state. +- **Per-output reconciliation**: planned tool calls get an aborted error result; restored started calls get `interrupted`; live started calls keep their finalized or raw result as above; an assistant or fetch settlement after cancellation is stored under the reserved response id with stop reason `aborted` and moves to cancelled checkpoint state. **Signal ownership makes `aborted` unambiguous.** Provider implementations must set `stopReason: "aborted"` if and only if the signal they were given was pulled, and the harness owns that signal exclusively (§4.2). Since `abort()` commits `control` before pulling it, a settled `aborted` response always has cancellation already durable. Timeouts, transport failures, malformed streams, and provider-side refusals all settle as `error` and take the ordinary retry path — which is correct, because those should retry and a user abort should not. An `aborted` response with `control.status === "running"` is unreachable; if one exists, the session is corrupt (invariant 17). @@ -1638,7 +1621,7 @@ This also keeps invariant 17 true. Close pulls the same signal as abort, but the ## 4.8 Faults -A failed storage commit faults the whole harness. A faulted harness stops all effects and rejects pending and future calls with `HarnessFault`; it is never an `Err` result. `faulted: true` appears in snapshots obtained before the fault closes observation. After the cause is fixed, reopening restores each lane from its refs. Close likewise rejects already-accepted local operation promises with `HarnessClosed`; calls not yet accepted return `Err(Closed)`. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from a trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or an `entryProjector`) is an application defect and faults the harness; it never escapes as an undeclared operation error. `AgentTool.prepareArguments` is the deliberate exception handled by the tool pipeline as a synthetic tool error. +A failed storage commit faults the whole harness. A faulted harness stops all effects and rejects pending and future calls with `HarnessFault`; it is never an `Err` result. `faulted: true` appears in snapshots obtained before the fault closes observation. After the cause is fixed, reopening restores each lane from its slots. Close likewise rejects already-accepted local operation promises with `HarnessClosed`; calls not yet accepted return `Err(Closed)`. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from a trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or a `nodeProjector`) is an application defect and faults the harness; it never escapes as an undeclared operation error. `AgentTool.prepareArguments` is the deliberate exception handled by the tool pipeline as a synthetic tool error. --- @@ -1705,8 +1688,8 @@ type Tagged> = Error & { readonly _tag: Tag } & Readonly

; type OptionalFinalAssistant = - | { finalEntryId: string; finalMessage: AssistantMessage } - | { finalEntryId?: never; finalMessage?: never }; + | { finalNodeId: string; finalMessage: AssistantMessage } + | { finalNodeId?: never; finalMessage?: never }; type MissingIdentitySuspension = { kind: "suspended"; reason: "missing_identities"; @@ -1718,18 +1701,18 @@ type RunOutcome = | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) | { kind: "suspended"; reason: "deferred"; leafId: string; - finalEntryId: string; deferred: DeferredHandle } + finalNodeId: string; deferred: DeferredHandle } | (MissingIdentitySuspension & { leafId: string }); type CompactionOutcome = - | { kind: "completed"; leafId: string; entry: CompactionEntry } + | { kind: "completed"; leafId: string; node: CompactionNode } | { kind: "declined" | "aborted"; leafId: string } | { kind: "failed"; leafId: string; error: OperationError } | (MissingIdentitySuspension & { leafId: string }); type NavigationOutcome = | { kind: "completed"; oldLeafId: string | null; newLeafId: string | null; - summaryEntry?: BranchSummaryEntry } + summaryNode?: BranchSummaryNode } | { kind: "declined" | "aborted"; leafId: string | null } | { kind: "failed"; leafId: string | null; error: OperationError } | (MissingIdentitySuspension & { leafId: string | null }); @@ -1771,7 +1754,7 @@ type CancelQueuedResult = Result< { kind: "cancelled" | "already_consumed" | "already_cleared" }, UnknownQueueItem | Closed>; type AbortResult = Result<{ runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, NoActiveOperation | Closed>; -type RecordUsageResult = Result<{ objectId: string }, Closed>; +type RecordUsageResult = Result<{ valueId: string }, Closed>; class HarnessFault extends Error { readonly cause: unknown; @@ -1788,8 +1771,8 @@ class HarnessClosed extends Error {} class AgentHarness implements AgentLane { /** Initializes an unconfigured main when needed, then restores every lane - without starting provider, tool, hook, or timer effects. One suspended - entry per lane with an open operation. */ + without starting provider, tool, hook, or timer effects. One suspension + descriptor per lane with an open operation. */ static create(options: AgentHarnessOptions): Promise<{ harness: AgentHarness; suspended: SuspendedOperation[]; @@ -1873,13 +1856,13 @@ interface AgentHarnessOptions Message[] | Promise; - entryProjectors?: Record; + nodeProjectors?: Record; /** Existing typed telemetry contract; defaults to no-op. */ telemetryContext?: TelemetryContext; } type Resources = AgentHarnessResources; -type EntryProjector = (entry: CustomEntry) => +type NodeProjector = (node: CustomNode) => AgentMessage[] | undefined | Promise; ``` @@ -1889,14 +1872,14 @@ type EntryProjector = (entry: CustomEntry) => Initial, replacement, and hook-patched stream options are normalized to detached JSON-safe values before publication because ready states persist them. Functions, symbols, bigint values, cycles, non-finite numbers, and unsupported prototypes in metadata reject construction/the setter without changing settings; an invalid hook patch is isolated as `handler_error` and ignored without changing operation state. Patch deletion semantics are applied before this validation. -`systemPrompt`, `toolContext`, `toProviderMessages`, and `entryProjectors` are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. `before_run` receives one preview evaluation of `systemPrompt`. A hook override is fixed in `Operation`; without one, the callback is evaluated again per provider request. +`systemPrompt`, `toolContext`, `toProviderMessages`, and `nodeProjectors` are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. `before_run` receives one preview evaluation of `systemPrompt`. A hook override is fixed in `Operation`; without one, the callback is evaluated again per provider request. ## 5.3 SessionTree ```ts interface SessionTree { getLeafId(): Promise; - getEntry(id: string): Promise; + getNode(id: string): Promise; getStats(): Promise; // Global facts. Latest wins; not branch-scoped. undefined deletes; JSON null @@ -1909,29 +1892,29 @@ interface SessionTree { setCustomFact(key: string, value: JsonValue | undefined): Promise; /** Session-wide, all branches, sequence order. */ - findEntries(query?: EntryQuery): Promise; - findEntry(query?: EntryQuery): Promise; + findNodes(query?: NodeQuery): Promise; + findNode(query?: NodeQuery): Promise; /** Branch-scoped: the path from start toward root (§2.5). */ - findEntriesOnBranch(query?: BranchScan): Promise; - findEntryOnBranch(query?: BranchScan): Promise; + findNodesOnBranch(query?: BranchScan): Promise; + findNodeOnBranch(query?: BranchScan): Promise; // Writes resolve on durable acceptance; the returned id is the node id, // reserved when the write defers. appendMessage(message: AgentMessage): Promise; - appendCustomEntry(customType: string, data?: JsonValue): Promise; + appendCustomNode(customType: string, data?: JsonValue): Promise; } -interface EntryQuery { type?: EntryType; customType?: string; - order?: "asc" | "desc"; limit?: number; cursor?: EntryCursor } +interface NodeQuery { type?: NodeType; customType?: string; + order?: "asc" | "desc"; limit?: number; cursor?: NodeCursor } interface SessionStats { messageCount: number; usage: Usage } ``` Global queries filter first, then apply the exclusive cursor, then `limit`; default order is `"desc"`. A descending cursor retains `seq < cursor.seq`, and an ascending cursor retains `seq > cursor.seq`. -Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. +Useful patterns: effective extension state is `findNodeOnBranch({ type: "custom", customType })`; a collection is `findNodesOnBranch(...)`; a global inventory is `findNodes(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. -`SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getEntry` return only committed entries: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. +`SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getNode` return only committed nodes: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. ## 5.4 Snapshots and subscription @@ -1948,7 +1931,7 @@ interface QueuedItem { nodeId: string; message: AgentMessage } interface LaneSnapshot { lane: string; - transcript: Entry[]; // this lane's context window plus its compaction entry + transcript: Node[]; // this lane's context window plus its compaction node leafId: string | null; operation: null | { @@ -1957,14 +1940,14 @@ interface LaneSnapshot { status: "running" | "suspended" | "aborting"; startedAt: number; suspended?: SuspendedOperation; - streamingMessage?: AssistantMessage; // message_start until entry commit + streamingMessage?: AssistantMessage; // message_start until node commit runningTools: { toolCallId: string; toolName: string; args: unknown; partialResult?: AgentToolResult }[]; retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; }; queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; - pendingWrites: { nodeId: string; type: EntryType; customType?: string; + pendingWrites: { nodeId: string; type: NodeType; customType?: string; message?: AgentMessage; data?: JsonValue }[]; faulted: boolean; } @@ -1975,22 +1958,22 @@ interface SessionSnapshot { } ``` -`operation.status` derives from durable state plus a process-local suspension marker: `suspended` for deferred, restored, or missing-identity suspension; `aborting` when `control.status === "cancel_requested"`; otherwise `running`. The missing-identity marker stores the exact `SuspendedOperation`, survives until a successful resume attempt or abort in this process, and is reconstructed as `reason:"crash"` after reopen. It changes snapshots but never durable recovery state. `queues` and `pendingWrites` derive from `inbox` and `pendingNextRun`, with content dereferenced by object id. `streamingMessage` and `runningTools` are process-local extras layered on top. +`operation.status` derives from durable state plus a process-local suspension marker: `suspended` for deferred, restored, or missing-identity suspension; `aborting` when `control.status === "cancel_requested"`; otherwise `running`. The missing-identity marker stores the exact `SuspendedOperation`, survives until a successful resume attempt or abort in this process, and is reconstructed as `reason:"crash"` after reopen. It changes snapshots but never durable recovery state. `queues` and `pendingWrites` derive from `inbox` and `pendingNextRun`, with content dereferenced by value id. `streamingMessage` and `runningTools` are process-local extras layered on top. Rules: - Configuration is **not** in snapshots. Getters return current values; `config_update` events tell a UI when to re-read. One source of truth. -- `streamingMessage` is not part of `transcript`. `message_end` replaces it with the final post-hook value but does not clear it; the matching `entry_added` confirms the append, adds the entry to `transcript`, and clears the draft. -- Direct messages and finalized tool results use the same immediate `message_start` → `message_end` lifecycle and enter `transcript` only on `entry_added`. They never populate `streamingMessage`. +- `streamingMessage` is not part of `transcript`. `message_end` replaces it with the final post-hook value but does not clear it; the matching `node_added` confirms the append, adds the node to `transcript`, and clears the draft. +- Direct messages and finalized tool results use the same immediate `message_start` → `message_end` lifecycle and enter `transcript` only on `node_added`. They never populate `streamingMessage`. - An `aborting` snapshot reports only state that actually exists. It never synthesizes a streaming assistant message. -- Reconnect means a new `watch()`. Only process death loses stream state; a restored harness shows the suspended operation instead. Every entry in the durable transcript is complete — a lost draft was never an entry. +- Reconnect means a new `watch()`. Only process death loses stream state; a restored harness shows the suspended operation instead. Every node in the durable transcript is complete — a lost draft was never a node. - A lane watcher receives events whose `lane` matches, plus events with no lane. The harness-global `usage` event is the explicit exception: it carries its originating lane but reaches every watcher, because its totals are session-wide. ## 5.5 Events -One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot mutate execution, payloads are isolated from procedure objects, and a throw produces `handler_error` plus telemetry without affecting execution. Only hooks intercept. +One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot mutate execution, payloads are isolated from procedure state, and a throw produces `handler_error` plus telemetry without affecting execution. Only hooks intercept. -Durable-fact events fire **after** commit — `entry_added` means queryable. Multi-write events wait for full success, then follow mutation order. Process-local lifecycle events need not be durable: `message_end` precedes the entry append. +Durable-fact events fire **after** commit — `node_added` means queryable. Multi-write events wait for full success, then follow mutation order. Process-local lifecycle events need not be durable: `message_end` precedes the node append. ```ts type HarnessEventPayload = @@ -2023,7 +2006,7 @@ type HarnessEventPayload = | { type: "message_start"; runId?: string; message: AgentMessage } | { type: "message_update"; runId: string; message: AgentMessage; event: AssistantMessageEvent } - | { type: "message_end"; runId?: string; message: AgentMessage; entryId?: string } + | { type: "message_end"; runId?: string; message: AgentMessage; nodeId?: string } // Tools | { type: "tool_start"; runId: string; turnId: string; toolCallId: string; @@ -2034,8 +2017,8 @@ type HarnessEventPayload = toolName: string; result: AgentToolResult; isError: boolean; terminate: boolean } // Tree, queues, facts - | { type: "entry_added"; entry: Entry } - | { type: "write_pending"; runId: string; nodeId: string; type: EntryType } + | { type: "node_added"; node: Node } + | { type: "write_pending"; runId: string; nodeId: string; type: NodeType } | { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] } | ({ type: "fact_update" } & ( @@ -2054,19 +2037,19 @@ type HarnessEventPayload = // Structural | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" } | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & ( - | { outcome: "completed"; entry: CompactionEntry; fromHook: boolean } + | { outcome: "completed"; node: CompactionNode; fromHook: boolean } | { outcome: "declined" | "aborted" } | { outcome: "failed"; error: OperationError })) | { type: "navigation_start"; runId: string; targetId: string | null } | ({ type: "navigation_end"; runId: string; oldLeafId: string | null; newLeafId: string | null } & ( - | { outcome: "completed"; summaryEntry?: BranchSummaryEntry } - | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never } - | { outcome: "failed"; error: OperationError; summaryEntry?: never })) + | { outcome: "completed"; summaryNode?: BranchSummaryNode } + | { outcome: "declined" | "aborted"; summaryNode?: never; error?: never } + | { outcome: "failed"; error: OperationError; summaryNode?: never })) // Lanes and cost | { type: "lane_created"; at: string | null } - | { type: "usage"; lane: string; seq: number; entry: UsageEntry; totals: Usage }; + | { type: "usage"; lane: string; seq: number; value: UsageValue; totals: Usage }; type SpecialEventPayload = Extract; @@ -2101,31 +2084,31 @@ interface Events { } ``` -`lane` is required on run/turn/retry/message/tool, entry/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. `handler_error` follows the failed handler's scope. `usage` is the global-delivery exception: base `lane` is absent, while its payload carries origin lane and durable `seq`. `recovery: true` appears on process-local lifecycle re-emitted by `resume()`, never on events for already-existing durable entries. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage `seq` it has applied, preventing a late older event from regressing totals. +`lane` is required on run/turn/retry/message/tool, node/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. `handler_error` follows the failed handler's scope. `usage` is the global-delivery exception: base `lane` is absent, while its payload carries origin lane and durable `seq`. `recovery: true` appears on process-local lifecycle re-emitted by `resume()`, never on events for already-existing durable nodes. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage `seq` it has applied, preventing a late older event from regressing totals. Ordering for a streamed assistant response, asserted exactly by the conformance tests: ``` message_start → message_update* → after_response hook → message_end (final value, optional reserved id) → atomic response + usage + classified-state commit -→ entry_added → usage +→ node_added → usage ``` -Only `entry_added` proves durability. Classification is computed before the transaction and becomes durable with it; it is not a separate event. Abort and overflow classification may normalize the committed response after `message_end`, so `entry_added` is authoritative for those two cases. A synthetic settlement performs no provider effect, update, or response hook: `message_start → message_end → atomic commit → entry_added → usage`. +Only `node_added` proves durability. Classification is computed before the transaction and becomes durable with it; it is not a separate event. Abort and overflow classification may normalize the committed response after `message_end`, so `node_added` is authoritative for those two cases. A synthetic settlement performs no provider effect, update, or response hook: `message_start → message_end → atomic commit → node_added → usage`. Nesting: ``` run_start - message_start / message_end / entry_added consumed prompt and queue messages + message_start / message_end / node_added consumed prompt and queue messages turn_start message_start / message_update* / message_end assistant stream finished - entry_added response committed + node_added response committed tool_start / tool_update* / tool_end per real call message_start / message_end tool results, source order - entry_added each result committed + node_added each result committed turn_end - compaction_start … entry_added … compaction_end auto, at a checkpoint + compaction_start … node_added … compaction_end auto, at a checkpoint turn_start … turn_end until nothing is pending run_end ``` @@ -2135,10 +2118,10 @@ Deferred and recovery brackets are deterministic: - initial assistant generation uses `turnId = stepId`; a durable deferred response ends that turn, then emits `run_suspend`; - every application `resume()` emits `run_resume`; `recovery:true` is present only when this harness restored the operation after process loss, not for same-process deferred resume; - one deferred poll opens a turn whose durable id is `${stepId}:poll:${poll}`. Pending/error/ready settlement and any ready tool batch complete inside that turn, followed by `turn_end` and then suspend/failure/checkpoint; -- restored unresolved tools re-open their persisted `ToolBatch.turnId` with `recovery:true`, emit only new replay/interruption tool lifecycle, then close that recovery turn. Existing message/entry events are never replayed; -- resumed structural work re-emits its structural start with `recovery:true`; structural streams emit no message lifecycle and their typed result alone emits `entry_added`. +- restored unresolved tools re-open their persisted `ToolBatch.turnId` with `recovery:true`, emit only new replay/interruption tool lifecycle, then close that recovery turn. Existing message/node events are never replayed; +- resumed structural work re-emits its structural start with `recovery:true`; structural streams emit no message lifecycle and their typed result alone emits `node_added`. -Deferred polls emit no retry lifecycle. Events may contain sensitive conversation and tool content. Serving layers own authorization and redaction. Payload objects are isolated from mutable procedure state. Telemetry alone is content- and secret-free by default. +Deferred polls emit no retry lifecycle. Events may contain sensitive conversation and tool content. Serving layers own authorization and redaction. Event payloads are isolated from mutable procedure state. Telemetry alone is content- and secret-free by default. ## 5.6 Hooks @@ -2335,7 +2318,7 @@ function finalizeToolCall(call: PreparedToolCall, signal: AbortSignal): Promise; ``` -External output that violates durable JSON/schema contracts is converted before settlement: an invalid provider message becomes a synthetic assistant `error` under the reserved response id; an invalid tool result becomes a synthetic error under its planned result id. Valid reported usage is retained when it can be validated independently, otherwise the synthetic entry reports zero. Invalid hook output is handled like a throwing handler (`before_tool` still fails closed); invalid caller input returns `InvalidMessage` before acceptance. No invalid value reaches `Storage.commit()`. +External output that violates durable JSON/schema contracts is converted before settlement: an invalid provider message becomes a synthetic assistant `error` under the reserved response id; an invalid tool result becomes a synthetic error under its planned result id. Valid reported usage is retained when it can be validated independently, otherwise the synthetic node reports zero. Invalid hook output is handled like a throwing handler (`before_tool` still fails closed); invalid caller input returns `InvalidMessage` before acceptance. No invalid value reaches `Storage.commit()`. `AgentTool.prepareArguments` is deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. `ToolCallbacks` contains the existing before/after callbacks plus `executeTool`, `onToolStart`, and `onToolResult` durability callbacks described in §3.8. `onToolStart` receives effective arguments after `prepareArguments`, validation, and `before_tool`; `onToolResult` receives the finalized message and terminate decision. Blocked calls may terminate when `before_tool.block.terminate` is true. Replacement arguments are validated again. @@ -2358,7 +2341,7 @@ pi.ai.request Operation, step, tool, hook, event, and write parents follow the actual interpreter/effect nesting. Sleep spans permit run, compaction, navigation, turn, and checkpoint parents. `stepId`/`taskId` correlate retries and recovery. Every provider request/fetch/cancel uses `pi.ai.request`; each real or safely replayed phase-two tool effect uses one tool span. -Every storage transaction uses one `pi.session.write`. Its start attributes include `pi.session.item_count` and `pi.session.item_kinds` (`object`, `node`, `ref`); homogeneous lane/operation ids may be included, mixed transactions omit them. End attributes include first and last committed sequence. Update the existing schema from old single-mutation vocabulary to this transaction shape; no span is emitted for a conditional no-write result. Synthetic settlements and blocked/invalid tools emit no provider/tool-effect span. +Every storage transaction uses one `pi.session.write`. Its start attributes include `pi.session.item_count` and `pi.session.item_kinds` (`value`, `node`, `slot`). A calling procedure may supply its lane/operation ids; storage never infers them from values. End attributes include first and last committed sequence. Update the existing schema from old single-mutation vocabulary to this transaction shape; no span is emitted for a conditional no-write result. Synthetic settlements and blocked/invalid tools emit no provider/tool-effect span. Telemetry attributes may contain declared ids, names, counts, durations, statuses, and usage. They must never contain prompts, completions, tool arguments/results, file contents, provider payloads, headers, handles, or credentials. Events and hooks may contain such content. The existing generated schema document and adapter/runtime conformance tests remain authoritative; implementation slices extend instrumentation only through those schemas. @@ -2372,9 +2355,9 @@ If implementation exposes a design contradiction, missing transition, or materia | # | Slice | Implement | Required focused tests | |---|---|---|---| -| 1 | **Single-session substrate** | Write-once objects/nodes, refs/history, atomic transactions, runtime object/custom-message schemas, stats, Memory backend, and shared conformance helpers. | Rollback, sequence order, duplicate ids, target-kind/schema validation, unknown custom roles, tombstones, immutable reads, stats, close. | -| 2 | **JSONL v4 and v3** | Object/array transaction lines, projections, torn-tail handling, format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, every v3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | -| 3 | **Tree and repositories** | Entry materialization, lane/config/state refs, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle, coherent branch/tree forks. | Placement, divergence, filters/cursors/stops, custom null/tombstones, context, fork before first attachment, configured fork snapshots/facts/zero ledger. | +| 1 | **Single-session substrate** | Write-once values/nodes, slots/history, atomic transactions, runtime value/custom-message schemas, stats, Memory backend, and shared conformance helpers. | Rollback, sequence order, duplicate ids, target-kind/schema validation, unknown custom roles, tombstones, immutable reads, stats, close. | +| 2 | **JSONL v4 and v3** | Single-item/array transaction lines, projections, torn-tail handling, format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, every v3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | +| 3 | **Tree and repositories** | Node materialization, lane/config/state slots, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle, coherent branch/tree forks. | Placement, divergence, filters/cursors/stops, custom null/tombstones, context, fork before first attachment, configured fork snapshots/facts/zero ledger. | | 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation/transitions, `Models.lease` and runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory, identity leases, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, compatible-descendant settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads. | | 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance, captured request lease/options/thinking, payload/response hooks, one generation intent/effect/settlement, usage, finish, results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, automatic/manual identical state, close at every boundary. | | 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | @@ -2382,10 +2365,10 @@ If implementation exposes a design contradiction, missing transition, or materia | 8 | **Inbox, configuration, and writes** | `nextRun`, steer/follow-up modes, dispositions/cancellation, durable drain markers, checkpoint consumption, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation, one-at-a-time crash after one drain, custom-write continuation, config-step race, writes surviving reopen. | | 9 | **Abort, close, and failure drain** | Orthogonal control, stable drained input, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close. | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, close races, failure revived only by projecting input. | | 10 | **Deferred provider redemption** | One poll per resume, copied configuration/options, leased request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of slice 9 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | -| 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, JSON preparation DTO/object, total structural state, hook/generated sources, leased nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | +| 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, JSON preparation DTO/value, total structural state, hook/generated sources, leased nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | | 12 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | | 13 | **Navigation** | Validation, summarized decision/generation, and one final move/summary/leaf/label/finish transaction; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication. | -| 14 | **SQLite** | Rework the current unfinished schema/backend directly to objects/nodes/refs, transactions, stats, leases, repository operations, segmented branch cache, node-id-keyed FTS search projection, and explicit repair. No migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, placed-only search, forks/search/stats/repair. | +| 14 | **SQLite** | Rework the current unfinished schema/backend directly to values/nodes/slots, transactions, stats, leases, repository operations, segmented branch cache, node-id-keyed FTS search projection, and explicit repair. No migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, placed-only search, forks/search/stats/repair. | | 15 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | Existing source guidance: @@ -2403,22 +2386,22 @@ Existing source guidance: Storage: -1. Objects and nodes are **write-once** and share one id namespace. Reusing an existing id for either kind is corruption. +1. Values and nodes are **write-once** and share one id namespace. Reusing an existing id for either kind is corruption. 2. Transactions are all-or-none, with consecutive `seq`. `seq` is monotonic session-wide. -3. Refs are the only mutable state. `null` is a tombstone and differs from unbound; every non-null target exists and matches its namespace's required kind. +3. Slots are the only mutable state. `null` is a tombstone and differs from unbound; every non-null target exists and matches its namespace's required kind. 4. No read on a hot path may fold history or depend on the absence of a record, and no query may be a table scan. Tree: 5. A node's parent chain never changes. Branches share prefixes; nothing is copied. -6. A node whose `objectId` is missing or has a kind incompatible with §2.1 is corruption; only a custom node may use null. -7. Configuration and orchestration never enter the tree. Deleting every `operation` and `operation_state` object must leave a complete, valid conversation. +6. A node whose `valueId` is missing or has a kind incompatible with §2.1 is corruption; only a custom node may use null. +7. Configuration and orchestration never enter the tree. Deleting every `operation` and `operation_state` value must leave a complete, valid conversation. 8. A lane's leaf moves only by append or navigation. 9. A branch segment chain, followed to its end, yields the full root path. Operations: -10. An `operation_state` must name an existing operation, and that operation must be the lane's `currentOperationId` — unless the state is `finished` and the same transaction clears the lane pointer. +10. `lane.state/{lane}` confers lane ownership, and `op.state/{operationId}` confers operation-state ownership. An open lane names operation O, immutable value O is that lane's compatible `Operation`, and `op.state/O` targets a compatible `OperationState`; state values carry no duplicate owner metadata. 11. A `finished` state and `currentOperationId = null` must land in the **same** transaction. 12. Acceptance must observe `currentOperationId === null`. 13. A reserved id (response, usage, tool result, structural result) may exist only with the content its intent state named. @@ -2432,7 +2415,7 @@ Everything that used to require a bounded historical validity audit is now eithe ## 7.2 Race catalog -Each entry has exactly two durable histories. Test both, in manual drive, in both orders. +Each race has exactly two durable histories. Test both, in manual drive, in both orders. | Race | Orders | |---|---| @@ -2442,7 +2425,7 @@ Each entry has exactly two durable histories. Test both, in manual drive, in bot | `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | | `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | | `setModel` vs generation step start | old snapshot used; or new snapshot used | -| `abort` vs structural commit | `aborted` with no entry; or `completed` | +| `abort` vs structural commit | `aborted` with no node; or `completed` | | `nextRun` vs acceptance | captured by this run; or stays for the next | | manual-compaction reservation vs idle tree write | reservation first → write waits; write first → preparation uses the new leaf | | deferred write vs abort | write survives abort either way | @@ -2457,7 +2440,7 @@ For each recovery prefix: close, reopen, resume, and compare against uninterrupt One corruption assertion constructs an `aborted` response with running control directly and requires load rejection. Provider conformance separately proves implementations emit `aborted` only for the supplied signal. -**Tier B — writer conformance.** Run the public harness against an instrumented storage recording every object, node, ref, and hook. Assert exact order against the Part 3 transaction tables and the §5.5 ordering rules. This tier catches the critical regression classes: an effect starting before its intent commit, a response omitted for one stop reason, classification starting before usage is durable, or a result id reserved after clearance began. +**Tier B — writer conformance.** Run the public harness against an instrumented storage recording every value, node, slot, and hook. Assert exact order against the Part 3 transaction tables and the §5.5 ordering rules. This tier catches the critical regression classes: an effect starting before its intent commit, a response omitted for one stop reason, classification starting before usage is durable, or a result id reserved after clearance began. **Tier C — deterministic interleavings.** Every race in §7.2, both orders, manual drive. @@ -2477,10 +2460,10 @@ One corruption assertion constructs an `aborted` response with running control d | Term | Meaning | |---|---| -| **Object** | Write-once payload with an id. Messages, state frames, configs, usage. | -| **Node** | Write-once tree position. The public "entry id". References an object. | -| **Entry** | Node + object, materialized by storage for the application. | -| **Ref** | A mutable name bound to an object or node id. The only mutable state. | +| **Value** | Write-once payload with an id. Messages, state frames, configs, usage. | +| **Stored node** | Write-once tree position referencing a value. Stored in `nodes`. | +| **Node** | Stored node plus value, materialized for the application. Its id is the public node id. | +| **Slot** | Mutable namespaced key targeting a value id, node id, or null. | | **Session** | One conversation: tree, facts, ledger, lanes. | | **Lane** | Named cursor into the tree with its own config, queues, and one operation. | | **Operation** | One accepted unit of work: run, compaction, or navigation. | @@ -2498,15 +2481,15 @@ One corruption assertion constructs an `aborted` response with running control d | Change | Reason | |---|---| -| Objects and nodes replace single-row entries | Content and placement have different birth times; splitting makes both write-once and stops queued content being serialized twice | -| Refs replace four latest-wins mechanisms | One mechanism, one query | +| Values and nodes replace single-row conversation items | Content and placement have different birth times; splitting makes both write-once and stops queued content being serialized twice | +| Slots replace four latest-wins mechanisms | One mechanism, one query | | Durable operation state replaces journal-and-reduce recovery | Recovery is a read, not a fold; crash states are enumerable | | Operation state holds **ids**, not payloads | Keeps immutable payload size out of repeated total state | -| Tool calls recovered from the assistant object, not repeated in state | Same | +| Tool calls recovered from the assistant value, not repeated in state | Same | | **Segmented** branch index replaces full prefix copy | Bounds copied rows by the compaction interval | -| `scanBranch` returns hydrated entries; `getNodes` removed | Callers receive the materialized value they need; structure-only reads remain separate | +| Branch queries return materialized nodes | Callers receive the hydrated node they need; structure-only reads remain separate | | Navigation completes in one transaction | Removes prepared-summary and post-move recovery states entirely | -| `firstKeptEntryId` → self-contained `retainedTail` | Context never reads past a compaction | +| legacy `firstKeptEntryId` → self-contained `retainedTail` | Context never reads past a compaction | | **Overflow responses commit with stop reason normalized to `error`** | The response describes itself, so §2.5 rule 3 excludes it. Deletes `supersededResponseNodeId`, the compaction link, and the omission rule | | **`aborted` ⟺ the harness's own signal fired** | v2 claimed transport timeouts and provider cancellation could produce `aborted`; adapters show they produce `error`. Deletes the "unmarked aborted" retry path, its deferred source-tracking, and a test tier, and turns the case into invariant 17 | | Provider `AbortSignal` removed from every public options type | Makes the above an invariant rather than a convention | @@ -2522,20 +2505,20 @@ Old coding-agent v3 JSONL files must open unchanged and restore idle. Normalizat - `custom_message` becomes a custom agent message. - `label` and `session_info` become facts (latest by file position wins) and leave the tree. A label targets its nearest retained parent. -- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` entries disappear. They do **not** initialize or alter `LaneConfiguration`; a normalized `main` uses the immutable options seed. -- Each retained child of a discarded entry is reparented to its nearest retained ancestor. -- `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. -- An old compaction resolves `firstKeptEntryId` against its own branch and materializes that range as `retainedTail`. Format 4 never exposes or persists `firstKeptEntryId`. +- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` nodes disappear. They do **not** initialize or alter `LaneConfiguration`; a normalized `main` uses the immutable options seed. +- Each retained child of a discarded node is reparented to its nearest retained ancestor. +- `main`'s leaf is the final physical node resolved through discarded nodes to its nearest retained ancestor. +- An old compaction resolves its legacy `firstKeptEntryId` field against its own branch and materializes that range as `retainedTail`. Format 4 never exposes or persists that field. - Existing `details`, `usage`, and `fromHook` are preserved; an absent `fromHook` normalizes to `false`. - v3 ISO timestamps convert to Unix milliseconds. - A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`. -- On first format-4 write, append one aggregate adjustment usage object with `details: { source: "v3-import" }`, summing v3 entry usage so ledger-derived totals remain unchanged. +- On first format-4 write, append one aggregate adjustment usage value with `details: { source: "v3-import" }`, summing v3 node usage so ledger-derived totals remain unchanged. -Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived. A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. +Read-only open leaves the file unchanged and computes stats from normalized node snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived. A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. # Appendix D — Open questions 1. **Repairing a missing model captured inside an open operation.** Registering the same provider/model identity unblocks it without changing state. Replacing it with a different durable identity needs an explicit repair API and is not silently performed by `setModel`. 2. **Overflow detection remains heuristic.** The normalization specified in §3.7 is authoritative. Preserve the original reason in `errorMessage` for diagnosis. -3. **Object deduplication.** The object model permits an optional content-hash layer, but reserved ids remain canonical and no initial implementation includes deduplication. -4. **JSONL readability.** Object/node separation makes raw lines less self-contained. Implement a hydrating log/debug command only if ordinary inspection proves insufficient. +3. **Value deduplication.** The value model permits an optional content-hash layer, but reserved ids remain canonical and no initial implementation includes deduplication. +4. **JSONL readability.** Value/node separation makes raw lines less self-contained. Implement a hydrating log/debug command only if ordinary inspection proves insufficient. From caf8fa6b521698d4bbd375f0716580b17af44fb5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 16:48:17 +0000 Subject: [PATCH 075/284] chore: approve contributors from issue #7828 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 24340aab629..39da0b3059e 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -343,3 +343,5 @@ zhichli pr wesleyzhangwq pr dgokeeffe pr + +vipentti pr From 0db3d3879a0affcf2e9f55b8c317150e31b34fb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 16:58:07 +0000 Subject: [PATCH 076/284] chore: approve contributors from issue #7830 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 39da0b3059e..53ae2ae283c 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -345,3 +345,5 @@ wesleyzhangwq pr dgokeeffe pr vipentti pr + +midastruth pr From c1a4e86c01931e5f636485cf5d63bf1b76c2eb34 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 17:00:55 +0000 Subject: [PATCH 077/284] chore: approve contributors from issue #7838 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 53ae2ae283c..d5b7c585655 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -347,3 +347,5 @@ dgokeeffe pr vipentti pr midastruth pr + +Maximo-Guk pr From 85b1efa147c67a6c656649178f702844eddb27fe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 17:04:49 +0000 Subject: [PATCH 078/284] chore: approve contributors from issue #7847 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index d5b7c585655..67526e942d2 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -349,3 +349,5 @@ vipentti pr midastruth pr Maximo-Guk pr + +bigoldcat123 pr From f858ae3e822489d6ef0fd9f72342b8fc214e9b95 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 17:09:47 +0000 Subject: [PATCH 079/284] chore: approve contributors from issue #7867 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 67526e942d2..e3135765162 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -351,3 +351,5 @@ midastruth pr Maximo-Guk pr bigoldcat123 pr + +johnatbasicas pr From 52771a07b09609a2e4b153bd8d11ab62b3c7e0b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 17:13:46 +0000 Subject: [PATCH 080/284] chore: approve contributors from issue #7876 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index e3135765162..2d08e08af93 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -353,3 +353,5 @@ Maximo-Guk pr bigoldcat123 pr johnatbasicas pr + +powerfooI pr From c2b06f0934a937b3cf1e888931e22c144a1977c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 17:17:39 +0000 Subject: [PATCH 081/284] chore: approve contributors from issue #7886 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 2d08e08af93..3a369467b3d 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -355,3 +355,5 @@ bigoldcat123 pr johnatbasicas pr powerfooI pr + +yearth pr From 3dd4623eee136edb2a7a470aa8d4744519a84246 Mon Sep 17 00:00:00 2001 From: distributedlock <7084995+distributedlock@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:18:05 -0400 Subject: [PATCH 082/284] Fix prompt formatting for current working directory (#7887) --- packages/coding-agent/src/core/system-prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 35f4ca40819..3b1aa63340b 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -66,7 +66,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { prompt += formatSkillsForPrompt(skills); } - prompt += `\nCurrent working directory: ${promptCwd}`; + prompt += `\nCurrent working directory: ${promptCwd}\n`; return prompt; } From 24c3b5e4c04e892b006a904016e4c92de64a221c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 10 Aug 2026 20:46:58 +0200 Subject: [PATCH 083/284] docs(agent): add storage and retention redesign proposal --- .../docs/agent-harness-storage-redesign.md | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 packages/agent/docs/agent-harness-storage-redesign.md diff --git a/packages/agent/docs/agent-harness-storage-redesign.md b/packages/agent/docs/agent-harness-storage-redesign.md new file mode 100644 index 00000000000..79c4547174c --- /dev/null +++ b/packages/agent/docs/agent-harness-storage-redesign.md @@ -0,0 +1,394 @@ +# AgentHarness storage and retention redesign + +**Status:** proposed delta to `agent-harness-spec.md`. This document assumes that specification is known. It describes only the proposed replacement storage model and the questions that remain. Until merged into the main specification, the main specification is authoritative. + +## Motivation + +The current design separates a tree node from its payload value because queued content may become durable long before tree placement. It also writes each lane/operation state revision as an immutable value and moves a slot to the newest revision. + +That produces three avoidable problems: + +1. Completed operations leave operation, state, tool-argument, preparation, fact, and configuration values behind. +2. `slot_history` retains state-transition history that execution never reads. +3. A pending value and its eventual node can land in different retention partitions. Deleting the old value partition then corrupts the newer node. + +Context compaction, operation garbage collection, and conversation retention are separate concerns: + +- **Context compaction** changes what is sent to a provider. +- **Operation cleanup** removes state that stopped being observable when an operation completed. +- **Conversation retention** intentionally removes old user-visible tree history. + +None should implicitly control another. + +## New storage model + +The durable model has three primary categories: + +```text +nodes complete immutable conversation nodes +slots current mutable state and pending node payloads +usage_ledger immutable billing/accounting events +``` + +SQLite keeps auxiliary tables for branch lookup, FTS, stats, partition metadata, sequencing, and writer leases. + +There is no general `stored_values` table, no `valueId`, and no node/value materialization join. + +### Complete nodes + +A stored node is also the application-facing node: + +```ts +interface NodeBase { + id: string; + parentId: string | null; + seq: number; + timestamp: number; + partitionId: string; +} + +interface MessageNode extends NodeBase { + type: "message"; + message: AgentMessage; + terminate?: true; +} + +interface CompactionNode extends NodeBase { + type: "compaction"; + summary: string; + retainedTail: AgentMessage[]; + tokensBefore: number; + details?: JsonValue; + usage?: Usage; + fromHook: boolean; +} + +interface BranchSummaryNode extends NodeBase { + type: "branch_summary"; + fromId: string; + summary: string; + details?: JsonValue; + usage?: Usage; + fromHook: boolean; +} + +interface CustomNode extends NodeBase { + type: "custom"; + customType: string; + data?: JsonValue; +} + +type Node = MessageNode | CompactionNode | BranchSummaryNode | CustomNode; +``` + +`StoredNode`, `StoredValue`, `ValueKind`, `valueId`, and `toNode()` disappear. + +### Typed slots + +A slot is a mutable namespaced key containing its current typed value, not an ID pointing at another state value: + +```ts +interface SlotValues { + "lane.leaf": string | null; + "lane.config": LaneConfiguration; + "lane.state": LaneState; + + "op.meta": Operation; + "op.state": OperationState; + "op.tool_args": Record; + "op.preparation": DurableStructuralPreparation; + + "pending.node": PendingNode; + "queue.disposition": QueueDisposition; + + "fact.name": string; + "fact.label": string; + "fact.custom": JsonValue; +} + +interface Slot { + namespace: N; + key: string; + seq: number; + value: SlotValues[N]; +} +``` + +Large state that stays unchanged across transitions gets its own stable operation slot: + +```text +op.meta/{operationId} +op.state/{operationId} +op.tool_args/{operationId}:{sourceIndex} +op.preparation/{operationId}:{taskId} +``` + +`op.state` contains the total mutable program counter but names deterministic operation slots for large stable data. This avoids repeatedly serializing tool arguments or preparation while keeping all operation-only data out of the conversation tables. + +Conditional transitions compare the expected slot `seq` rather than the ID of an immutable state value. + +Slot deletion is distinct from storing JSON `null`. Completing an operation deletes its operation slots. Deleting a fact deletes its slot; a custom fact may still store JSON `null` as a real value. + +### Pending nodes + +Content accepted before placement lives in a stable slot: + +```ts +interface PendingNode { + type: Node["type"]; + customType?: string; + payload: JsonValue; +} +``` + +Examples are `nextRun`, steer/follow-up input, and deferred tree writes: + +```text +pending.node/{reservedNodeId} -> payload +lane/op queue state -> reservedNodeId +``` + +Placement atomically: + +1. reads the pending slot; +2. creates one complete node with the current parent, sequence, timestamp, partition, and payload; +3. deletes the pending slot; +4. removes the node ID from queue state; +5. updates `lane.leaf`. + +Assistant responses and tool results need no pending slot. Their IDs are reserved in operation state, and settlement creates the complete node directly. + +A cancelled pending node is deleted without ever entering the conversation table. + +### Usage ledger + +Usage is neither mutable state nor necessarily a node. It has a dedicated append-only ledger: + +```sql +usage_ledger( + session_id, + id, + seq, + node_id, + adjustment, + usage_json, + details_json, + partition_id, + PRIMARY KEY (session_id, id) +); +``` + +`session_stats` remains the maintained aggregate. Retention may later collapse old usage rows into an adjustment without affecting totals. + +### No built-in slot history + +Remove: + +- `slot_history`; +- `getLog()` and `LogItem`; +- Memory's transaction log; +- SQLite history-union queries and history-only indexes. + +Execution reads only current slots. Applications that need an operational audit install a telemetry adapter and persist sanitized transaction/slot events externally. + +JSONL may physically retain old slot updates until snapshot compaction, but they are not a queryable history contract and may disappear on any rewrite. + +## Operation lifecycle and cleanup + +Operation-only durable data exists entirely in current slots. Completion atomically: + +1. publishes final nodes and usage; +2. writes the idle `lane.state` value; +3. deletes `op.meta/{operationId}` and `op.state/{operationId}`; +4. deletes all `op.tool_args` and `op.preparation` slots for the operation; +5. deletes any still-pending nodes owned by the operation; +6. clears the lane's current operation. + +There is no durable `FinishedState` after completion. The terminal transaction and idle lane are the durable completion boundary. The live result is computed before commit from data that either remains in nodes/usage or is still in memory. + +This makes operation cleanup explicit and bounded. It needs no historical reduction, value scan, or mark-and-sweep over old state revisions. + +## SQLite shape + +```sql +nodes( + session_id, + id, + parent_id, + seq, + timestamp, + partition_id, + type, + custom_type, + payload_json, + PRIMARY KEY (session_id, id) +) WITHOUT ROWID; + +slots( + session_id, + namespace, + key, + seq, + value_json, + PRIMARY KEY (session_id, namespace, key) +); +``` + +Branch and type indexes continue to use node metadata. Branch scans no longer join through a payload table. Pending-node placement can use `INSERT ... SELECT` from the pending slot followed by slot deletion in one transaction when catalog and nodes share a database. + +## What this enables + +### Trivial operation cleanup + +State updates overwrite current slot values. Stable operation slots are deleted at completion. No immutable administrative values accumulate. + +### Self-contained tree rows + +A retained node always contains its payload. Deleting a node deletes its content; retaining it cannot leave a missing payload dependency. + +### Simpler pending-content lifecycle + +Unplaced content is visibly pending because a slot exists. Placement creates exactly one tree row. Cancellation deletes one slot. + +### Easier partitioning + +Pending data remains in the unpartitioned hot slot store. Partition assignment occurs only when a complete node is placed, so payload and structure cannot land in different partitions. + +The normal partition contents become: + +```text +complete nodes +branch-index rows +FTS rows +associated usage rows +``` + +Routine partition expiry therefore does not need to discover or move values referenced by retained nodes. + +### Separation from context compaction + +Calendar/TTL partitioning can remove old conversation history independently of model-context compaction. Compaction remains a provider-context operation and does not become a storage lifecycle marker. + +## Retention mechanisms + +Two conversation-retention mechanisms remain possible and may coexist: + +1. **Partition expiry.** Assign complete nodes to context-safe time partitions and drop sealed partitions. This is the fast routine TTL path, but it must tolerate retired parents and current slots targeting expired nodes. +2. **Precise session rewrite.** Compute retained nodes from lane-specific policy, copy the retained set into a new store, briefly freeze commit admission, apply the tail, and atomically swap stores. This supports arbitrary branch-aware pruning but costs `O(retained data)` and is an administrative rewrite, not context compaction. + +For SQLite, a rewrite should copy retained rows to a new file rather than run `DELETE ... NOT IN (...)` over all historical rows while writes are frozen. Operation cleanup uses neither mechanism; it happens continuously through slot lifecycle. + +## Partitioning direction + +A likely physical design is: + +```text +hot session catalog + current slots + pending nodes + operation state + facts + partition inventory + aggregate stats + +immutable/sealable partition P + complete nodes + branch/FTS projections + usage rows +``` + +Nodes that must remain together for provider-valid context need the same partition key. At minimum, an assistant node containing tool calls and every corresponding tool-result node share a context-group partition. A simpler policy may assign all nodes from one accepted run to the run's partition epoch rather than each row's wall-clock timestamp. + +A node whose parent belongs to an explicitly retired partition may become a valid retained root. A missing parent in a live partition remains corruption. + +Retention duration does not need to be fixed when the session is created. It may be shortened later; it may be lengthened only for partitions that have not already been deleted. + +## Open questions + +### Branch segments across deleted partitions + +The current `branch_nodes` / `branch_meta` chain can name a base segment in an older partition. It is not yet specified: + +- whether each partition has independent branch projections; +- whether a retained segment may have a retired base; +- where the retired-boundary marker lives; +- how `scanBranch` distinguishes an expected retired base from corruption; +- whether partition deletion requires rebuilding retained branch metadata; +- how stale branches and branches shared by several lanes are represented after deletion. + +This is the largest unresolved interaction with the current branch-chain design. + +### Context-safe partition groups + +The exact grouping rule is unresolved: + +- assistant tool call plus results; +- one provider turn; +- one accepted run; +- another explicit context frame. + +The rule must prevent retained context from starting with an orphan tool result or ending with an unresolved assistant tool call. Long-running operations must not prevent old partitions from sealing forever. + +### Cross-partition parent semantics + +Possible choices are: + +1. retained nodes may point into retired partitions and traversal stops there; +2. create lightweight retention-boundary nodes; +3. rewrite crossing parents before deletion. + +The first is cheapest but changes the current invariant that every parent exists. + +### Physical SQLite partitioning + +Logical `partition_id` rows in one SQLite file preserve easy atomic transactions but do not make deletion `O(1)`. Separate files make deletion cheap but require coordination between the hot slot store and node partition. + +We need a crash-safe protocol for a transaction that both inserts a node into a partition and deletes/updates hot slots. Options include attached-database transactions, a small commit manifest, staging rows, or keeping an active writable partition together with the catalog. + +### Partition sealing with live operations + +If a run captures partition P and crosses a calendar boundary, either: + +- P remains writable until the run finishes; +- the run's unsettled nodes remain staged in the hot store; +- context-group rows are copied/promoted when the group closes; +- the newer partition records a dependency that temporarily pins P. + +The choice affects deletion latency and atomicity. + +### Lane leaves, labels, and navigation targets + +Current slots may name nodes in a partition selected for deletion. Policy must define whether to: + +- expire/rebase the lane; +- pin its target partition; +- retain a lightweight boundary node; +- drop labels; +- return an explicit expired-target result for navigation/bookmarks. + +### Compaction and copied old content + +A newer compaction node may embed `retainedTail` messages from an older period, and summaries contain information derived from old history. Deleting old partitions therefore does not guarantee that all old information disappears. Retention policy must define whether copied and summarized content may outlive its source partition. + +### Usage retention + +Decide whether usage rows: + +- share their node's partition; +- use independent calendar partitions; +- remain forever; +- collapse into aggregate adjustments when old partitions are removed. + +Failed structural attempts have usage but no node, so node-only partition assignment is insufficient. + +### JSONL physical cleanup + +Current-slot semantics remove history logically, but JSONL keeps old bytes until rewrite. Decide when snapshot compaction runs and whether operation completion or retention requires immediate physical removal of sensitive payloads. + +### Telemetry detail and privacy + +If telemetry replaces durable history, define the sanitized mutation event contract. Slot namespaces and target IDs are usually safe; custom fact keys and slot payloads may not be. Durable audit, if required, belongs to the adapter rather than session storage. + +### Pending-slot write amplification + +A delayed payload is written once into `pending.node` and again into `nodes` at placement. This is more physical I/O than the old value/node split, though only one live copy remains and SQLite can move it with `INSERT ... SELECT`. We should measure whether this matters for unusually large queued payloads. From 98145a6c063f00303405ef91ad4a5314670702e9 Mon Sep 17 00:00:00 2001 From: muyiyr <940955635@qq.com> Date: Tue, 11 Aug 2026 03:46:42 +0800 Subject: [PATCH 084/284] fix(ai): sanitize empty Bedrock tool argument keys (#7882) * fix(ai): sanitize empty Bedrock tool argument keys fixes #7782 * fix(ai): preserve Bedrock tool argument semantics --- .../ai/src/api/bedrock-converse-stream.ts | 16 +- .../ai/test/bedrock-convert-messages.test.ts | 146 +++++++++++++++++- 2 files changed, 156 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index c2021b1714c..c04b7c6ecd4 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -800,6 +800,20 @@ function createRequiredTextBlock(text: string): ContentBlock.TextMember { return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER }; } +function sanitizeBedrockDocument(value: DocumentType): DocumentType { + if (Array.isArray(value)) { + return value.map(sanitizeBedrockDocument); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key.length > 0) + .map(([key, nestedValue]) => [key, sanitizeBedrockDocument(nestedValue)]), + ); + } + return value; +} + function convertToolResultContent(content: (TextContent | ImageContent)[]): ToolResultContentBlock[] { const result: ToolResultContentBlock[] = []; for (const c of content) { @@ -872,7 +886,7 @@ function convertMessages( } case "toolCall": contentBlocks.push({ - toolUse: { toolUseId: c.id, name: c.name, input: c.arguments }, + toolUse: { toolUseId: c.id, name: c.name, input: sanitizeBedrockDocument(c.arguments) }, }); break; case "thinking": { diff --git a/packages/ai/test/bedrock-convert-messages.test.ts b/packages/ai/test/bedrock-convert-messages.test.ts index 5d5b7c319e0..fbc4330438b 100644 --- a/packages/ai/test/bedrock-convert-messages.test.ts +++ b/packages/ai/test/bedrock-convert-messages.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; const bedrockMock = vi.hoisted(() => ({ constructorCalls: [] as Array>, + streamEvents: undefined as unknown[] | undefined, })); vi.mock("@aws-sdk/client-bedrock-runtime", () => { @@ -13,7 +14,16 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { bedrockMock.constructorCalls.push(config); } - send(): Promise { + send(): Promise { + if (bedrockMock.streamEvents) { + const events = bedrockMock.streamEvents; + return Promise.resolve({ + $metadata: { httpStatusCode: 200 }, + stream: (async function* () { + yield* events; + })(), + }); + } return Promise.reject(new Error("mock send")); } } @@ -46,10 +56,29 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { }); import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; -import { getModel } from "../src/compat.ts"; -import type { Context, Message } from "../src/types.ts"; +import type { Context, Message, Model } from "../src/types.ts"; + +const baseModel: Model<"bedrock-converse-stream"> = { + id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + name: "Claude Sonnet 4.5 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200000, + maxTokens: 64000, + compat: { supportsStrictMode: true }, +}; -const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); +const novaModel: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "amazon.nova-lite-v1:0", + name: "Nova Lite", + reasoning: false, + compat: undefined, +}; async function capturePayload(context: Context, model = baseModel): Promise { let capturedPayload: unknown; @@ -85,7 +114,7 @@ describe("Bedrock constrained sampling", () => { expect(toolConfig.tools[0].toolSpec.strict).toBe(true); context.tools![0].constrainedSampling = { type: "json_schema", strict: "prefer" }; - const novaPayload = await capturePayload(context, getModel("amazon-bedrock", "amazon.nova-lite-v1:0")); + const novaPayload = await capturePayload(context, novaModel); const novaToolConfig = ( novaPayload as { toolConfig: { tools: Array<{ toolSpec: { strict?: boolean } }> }; @@ -95,6 +124,55 @@ describe("Bedrock constrained sampling", () => { }); }); +describe("Bedrock tool arguments", () => { + it("preserves empty property names in streamed tool arguments", async () => { + bedrockMock.streamEvents = [ + { messageStart: { role: "assistant" } }, + { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: "tool-1", name: "edit" } }, + }, + }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { + toolUse: { + input: '{"path":"/workspace/foobar/file.js","edits":[{"oldText":"first","newText":"updated first"},{"oldText":"second","newText":"updated second","":""}]}', + }, + }, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: "tool_use" } }, + ]; + + try { + const message = await streamBedrock( + baseModel, + { messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }] }, + { cacheRetention: "none" }, + ).result(); + + expect(message.content[0]).toEqual({ + type: "toolCall", + id: "tool-1", + name: "edit", + arguments: { + path: "/workspace/foobar/file.js", + edits: [ + { oldText: "first", newText: "updated first" }, + { oldText: "second", newText: "updated second", "": "" }, + ], + }, + }); + } finally { + bedrockMock.streamEvents = undefined; + } + }); +}); + describe("bedrock convertMessages skips unknown content types", () => { it("skips unknown user content blocks instead of throwing", async () => { const messages: Message[] = [ @@ -271,4 +349,62 @@ describe("bedrock convertMessages skips unknown content types", () => { const p = payload as { messages: Array<{ role: string; content: unknown[] }> }; expect(p.messages).toHaveLength(0); }); + + it("removes empty property names only from replayed Bedrock input", async () => { + const toolArguments = { + path: "/workspace/foobar/file.js", + edits: [ + { oldText: "first", newText: "updated first" }, + { oldText: "second", newText: "updated second", "": "" }, + ], + }; + const messages: Message[] = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "tool-1", + name: "edit", + arguments: toolArguments, + }, + ], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: baseModel.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "edit", + content: [{ type: "text", text: "done" }], + isError: false, + timestamp: Date.now(), + }, + { role: "user", content: "Continue", timestamp: Date.now() }, + ]; + + const payload = await capturePayload({ messages }); + const p = payload as { + messages: Array<{ content: Array<{ toolUse?: { input: unknown } }> }>; + }; + expect(p.messages[0].content[0].toolUse?.input).toEqual({ + path: "/workspace/foobar/file.js", + edits: [ + { oldText: "first", newText: "updated first" }, + { oldText: "second", newText: "updated second" }, + ], + }); + expect(toolArguments.edits[1]).toEqual({ oldText: "second", newText: "updated second", "": "" }); + }); }); From 7a6a1c2dbb5ef07040bac7a2b1c6a589a4f41e56 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 10 Aug 2026 23:55:13 +0200 Subject: [PATCH 085/284] docs(agent): add harness-v3 skeleton merging spec and storage redesign --- packages/agent/docs/harness-v3.md | 140 ++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/agent/docs/harness-v3.md diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md new file mode 100644 index 00000000000..efc7d1c1e27 --- /dev/null +++ b/packages/agent/docs/harness-v3.md @@ -0,0 +1,140 @@ +# AgentHarness v3 — implementation specification + +**Status:** construction skeleton. When complete, this document supersedes `agent-harness-spec.md` in full, which itself superseded `harness-v2.md`. Until then, `agent-harness-spec.md` remains authoritative. + +**Sources being merged:** + +- `agent-harness-spec.md` — the audited base spec ("base" below). Its interpreter, effects boundary, hooks, events, classifier, abort/close, and race catalog carry over with mechanical renames only; do not rewrite them. +- The storage walkthrough jot ("jot" below, parts 1–9) — the three-store model, pending registers, terminal cleanup, recovery, retention/partitioning, schema evolution, backend stories, API deltas, and the binding decisions in jot part 9. +- The storage-redesign critique findings, as resolved by jot part 9. + +**Global renames applied throughout v3:** + +```text +node / Node* → entry / *Entry (continuity with coding-agent) +slot → register +"storage substrate" → "Storage" +StoredValue / valueId → gone (no values table) +``` + +**Section disposition markers used in this skeleton:** + +- `[CARRY]` — take the base section verbatim, apply global renames. +- `[CARRY+]` — carry, plus the listed deltas. +- `[REWRITE]` — rewrite against the listed jot part / decision. +- `[NEW]` — no base equivalent; write from the listed jot part. + +--- + +# Part 0 — Orientation + +- **0.1 What this is** `[CARRY]` +- **0.2 Three concepts** `[CARRY]` — session/lane/harness unchanged. +- **0.3 Worked example — a Slack thread** `[CARRY+]` — example ids become UUIDv7-shaped. +- **0.4 Worked example — a crash mid-tool** `[CARRY]` +- **0.5 The three stores** `[REWRITE: jot 1]` — replaces "the four ideas": entries (immutable conversation), registers (current mutable state incl. pending payloads), usage ledger. The one-sentence invariant: *every payload is in an entry, a register, or the ledger; there is no third place.* Keep the effect sandwich and durable-program-counter ideas from base 0.5. +- **0.6 Non-goals** `[CARRY+]` — add: durable history/audit (telemetry adapter instead, jot 1); partition expiry is TTL, not compliance deletion (jot 5). +- **0.7 Notation and source types** `[REWRITE]` — TX notation gains `upsert register` / `delete register`; id examples are UUIDv7 prefixes; source-type provenance list updated (no value types). + +# Part 1 — Storage + +- **1.1 The model** `[REWRITE: jot 1]` — `Entry` (complete row: placement + payload), `Register` (namespace, key, seq, typed value), usage row. Register deletion is a first-class write, distinct from storing JSON `null`. +- **1.2 Identity and partitions** `[NEW: jot 9]` — UUIDv7 entry ids; the 48-bit time prefix *is* the partition assignment, truncated to the period; follower ids (tool results only) mint with their assistant id's prefix; reserved ids are minted at reservation; no partition columns anywhere. Retired-vs-corrupt traversal rules (vacuous on non-partitioned backends). +- **1.3 Register namespaces** `[REWRITE: jot 1, 3, 9]` + +```text +lane.leaf | lane.config | lane.state | lane.lastResult +op.meta | op.state | op.tool_args/{opId}:{i} | op.preparation/{opId}:{t} +pending.entry/{entryId} +fact.name | fact.label | fact.custom +``` + + No `queue.disposition` (removed, jot 9). Lifetimes: `lane.*`/`fact.*` = session; `op.*`/`pending.*` = operation, deleted at terminal TX. +- **1.4 Transactions** `[REWRITE: jot 8]` — write kinds: `entry`, `register set`, `register delete`, `usage`. All-or-none, consecutive session-wide `seq`, serialized writer, validation before admission, fault on failed admitted commit (carry those rules from base 1.3). +- **1.5 Queries** `[REWRITE: jot 8]` — `getEntries`, `getRegister`/`listRegisters` (typed values), `scanBranch`/`scanBranchStructure`/`scanEntries`, `getStats`. No `getLog`, no value scans. Bounded/index-driven rules carry from base 1.4. +- **1.6 Usage ledger** `[NEW-ish: jot 1]` — append-only rows incl. failed attempts; `getStats` as maintained aggregate; adjustment rows. +- **1.7 Backends** `[REWRITE: jot 7; base 1.6]` + - Memory: maps are the store; register delete = map delete; no log. + - JSONL **format 4**: one line per commit (object/array); line kinds entry/register-set/register-del/usage/header with `storageVersion`; replay = decoding; torn final line discarded whole; **snapshot compaction** (temp+rename) with the growth-asymmetry rationale and eager-compaction note for sensitive cancelled payloads. Keep base's fsync/durability language. + - SQLite: entries/registers/usage tables, branch index, FTS, leases, `BEGIN IMMEDIATE` — carry base's discipline sections; registers become upsert/delete rows; drop values/`slot_history`/`getLog` machinery. + - Postgres `[NEW: jot 5, 9]` — future fourth backend; `PARTITION BY RANGE (id)` on the uuid column with period-boundary UUID bounds; hot unpartitioned catalog (registers, branch_meta, inventory, stats, leases) + partitioned entries/usage/branch index/FTS; single-transaction atomicity across both; DETACH/DROP expiry protocol driven by inventory. +- **1.8 Why write-once plus registers** `[REWRITE of base 1.7]` — recovery is register reads; crash states enumerable; operation cleanup is register deletion; nothing to collect. + +# Part 2 — The conversation tree + +- **2.1 Entries** `[REWRITE: jot 8]` — the four complete entry types; no materialization function; assistant entries settled-only; `terminate` on tool results; `fromHook`; self-contained `retainedTail` — carry base rules minus node/value compatibility (moot). +- **2.2 Placement** `[REWRITE: jot 2]` — born-placed (one TX) vs pending-register content (`pending.entry` written at enqueue, complete entry inserted + register deleted at placement, one TX, no third state); reserved-id regimes: plain strings for assistant/tool ids, pending registers for queued content. +- **2.3 Lanes** `[CARRY]` — three registers plus `lane.lastResult`; creation TX; permanence rules. +- **2.4 Facts** `[CARRY+]` — deletion is register deletion (no tombstones); JSON `null` remains a legal `fact.custom` value. +- **2.5 Branch queries and context** `[CARRY]` — scans, cursors, context projection, append-only context invariant, overflow-normalization interaction. Add: branch-scan truncation marker at retention boundaries (jot 5/8). +- **2.6 The branch index** `[CARRY+ jot 9]` — segment machinery carries; add the two partition-purity rules: appends crossing a partition boundary close the segment (new segment, old as base); copy-on-diverge caps at partition boundaries and chains instead of copying; base-in-retired-partition = boundary, lazy truncation. `branch_meta` stays hot. +- **2.7 Forks** `[CARRY+]` — copy retained path/tree; normalize retained roots at retention boundaries; destination gets relevant inventory entries (critique 26). +- **2.8 Session and repository boundary** `[CARRY+]` — `storageVersion` in metadata; codec options; fork coordination; coding-agent v3-format normalization pointer. + +# Part 3 — The operation state machine + +Semantics carry from base Part 3 wholesale; the representation changes (jot 3, 8): + +- **3.1 Operations** `[CARRY+]` — `op.meta` register; `promptEntryIds`. +- **3.2 Operation state** `[REWRITE repr]` — inline `LaneConfiguration`/streamOptions/retryPolicy in generation/deferred/batch/summary contexts; tool args and preparation via deterministic `op.*` register keys; queue items are plain entry-id lists; **no `FinishedState`** in the union. +- **3.3 Lane state and validity** `[CARRY+]` — validation checks registers/entries; no value checks. +- **3.4 Atomic transition rule** `[CARRY]` +- **3.5 The graph** `[CARRY]` — terminal node becomes "terminal TX" rather than a state. +- **3.6 Acceptance** `[REWRITE TX tables]` — pending-capture placement; reservation admission for compact/summarized-nav carries. +- **3.7 Assistant generation** `[REWRITE TX tables]` — classifier table, overflow rules, worked example carry verbatim; transactions re-expressed as entry inserts + register upserts. +- **3.8 Tools** `[REWRITE TX tables]` — `op.tool_args` register at clearance; batch semantics carry. +- **3.9 Summary generation** `[REWRITE TX tables]` — `op.preparation` register; decision/generation machinery carries. +- **3.10 Navigation** `[CARRY+]` — one-TX completion; terminal writes per 3.13. +- **3.11 Inbox, queues, deferred writes** `[REWRITE: jot 2, 9]` — pending registers; `cancelQueued` triage without dispositions: pending → `cancelled`; entry exists → `already_consumed`; else → `not_found`. Abort drains keep pending registers alive until terminal. +- **3.12 The checkpoint procedure** `[CARRY]` +- **3.13 Terminal transactions** `[NEW: jot 3]` — delete `op.meta`/`op.state`/`op.tool_args/*`/`op.preparation/*` and operation-owned pending registers (`inbox.* ∪ control.drained*`, never `pendingNextRun`); upsert `lane.lastResult`; clear `lane.state.currentOperationId`; result computed pre-commit; observation contract (live promise + lastResult until next terminal TX). + +# Part 4 — Execution, recovery, abort, close + +- **4.1 The interpreter** `[CARRY+]` — CAS tokens become register `seq` (`operationStateSeq`, `laneStateSeq`, expected `lane.config` seq). +- **4.2 The effects boundary** `[CARRY]` +- **4.3 The lane mutation line** `[CARRY]` +- **4.4 Restore** `[REWRITE: jot 4]` — the five register reads; bounded hydration of named entries/registers; worked crash example; per-backend notes; missing identities. +- **4.5 Crash positions and recovery policy** `[CARRY]` +- **4.6 Abort** `[CARRY+]` — drained pending registers survive until terminal. +- **4.7 Close** `[CARRY]` +- **4.8 Faults** `[CARRY]` + +# Part 5 — Public surface + +- **5.1–5.8** `[CARRY+ deltas from jot 8]` — `RecordUsageResult { usageId }`; usage event carries the ledger row; `lane.lastResult` read path; `cancelQueued` `not_found`; expired-lane condition and truncation markers (only meaningful on partitioned backends); everything else — lane surface, results/errors, harness, SessionTree, snapshots/watch, events, hooks, agent-loop building blocks, telemetry — verbatim with renames. + +# Part 6 — Retention and partitioning `[NEW: jot 5, 9]` + +- Three lifecycles (operation cleanup / context compaction / retention) and why they never couple. +- Postgres layout, hot catalog vs partitions, expiry protocol (seal → inventory aggregates → detach → drop; recoverable). +- Placement via id minting; follower inheritance; late-placement pins; drop preflight = bounded register scan (pending + open-op reserved ids) + per-lane compaction horizon. +- Retired-boundary semantics for traversal, lanes (expired-lane condition), labels, forks. +- Yes/no-dialog worked example (jot 9). +- Expiry ≠ deletion (`retainedTail` copies forward); compliance deletion = precise rewrite. +- Precise rewrite mechanism (copy-retained-and-swap; JSONL snapshot compaction with a keep-predicate is the same operation). +- Lease constraint: retention daemon does lease-free DDL/inventory only; all per-session repair is lazy, owner-executed. + +# Part 7 — Schema evolution `[NEW: jot 6]` + +- `storageVersion`, migrate-on-open under the writer lease, chained migrations. +- The settlement kernel (stable minimal fragment of `op.state`); migrate-or-force-settle rule for open operations. +- The three strata: entries/usage stable forever; lane/fact registers migrate mechanically; `op.*`/`pending.*` may churn. +- JSONL lenient replay of superseded shapes; compaction after migration. + +# Part 8 — Build order `[REWRITE]` + +- Rewrite slices 1–3 (storage substrate, JSONL, tree/repos) for the three-store model; carry slices 4–13, 15 with renames; SQLite slice drops values/history machinery; add slices: schema-version/migration scaffold, retention/partition inventory (design-complete, Postgres-deferred). Keep the stop-and-report rule. + +# Part 9 — Invariants and tests `[REWRITE: jot 8]` + +- The six restated invariants (write-once entries/usage; one home per payload; op registers ⇔ open operation; two reservation regimes; observation contract; prefix-retired = boundary vs prefix-live = corruption). +- Race catalog `[CARRY]`. +- Test tiers `[CARRY+]` — Tier B oracle is the instrumented-storage decorator recording `commit(tx)`; backend conformance drops `getLog` equality; add retention-boundary tier exercised via the abstract retired-range set on Memory/JSONL. + +# Appendices + +- **A Glossary** `[REWRITE]` — entry, register, usage row, pending entry, partition, retention boundary, settlement kernel. +- **B Changes from agent-harness-spec.md** `[NEW]` — the jot part 8 delta table plus part 9 decisions, each with its reason. +- **C Coding-agent v3-format compatibility** `[CARRY]` — unchanged normalization rules (note: "v3" there names the old JSONL session format, not this document). +- **D Open questions** `[NEW]` — whatever survives: per-session retention length vs shared partitions; expired-lane product semantics; usage rows with no entry (partition of structural-failure usage); Postgres partition count/ops limits; pending-payload write-amplification measurement. From 87142a8d50640e93d43fcb35123439d642bc0304 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 00:26:38 +0200 Subject: [PATCH 086/284] docs(agent): write harness-v3 parts 0-2 --- packages/agent/docs/harness-v3.md | 838 ++++++++++++++++++++++++++++-- 1 file changed, 804 insertions(+), 34 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index efc7d1c1e27..e8078882330 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -28,48 +28,818 @@ StoredValue / valueId → gone (no values table) # Part 0 — Orientation -- **0.1 What this is** `[CARRY]` -- **0.2 Three concepts** `[CARRY]` — session/lane/harness unchanged. -- **0.3 Worked example — a Slack thread** `[CARRY+]` — example ids become UUIDv7-shaped. -- **0.4 Worked example — a crash mid-tool** `[CARRY]` -- **0.5 The three stores** `[REWRITE: jot 1]` — replaces "the four ideas": entries (immutable conversation), registers (current mutable state incl. pending payloads), usage ledger. The one-sentence invariant: *every payload is in an entry, a register, or the ledger; there is no third place.* Keep the effect sandwich and durable-program-counter ideas from base 0.5. -- **0.6 Non-goals** `[CARRY+]` — add: durable history/audit (telemetry adapter instead, jot 1); partition expiry is TTL, not compliance deletion (jot 5). -- **0.7 Notation and source types** `[REWRITE]` — TX notation gains `upsert register` / `delete register`; id examples are UUIDv7 prefixes; source-type provenance list updated (no value types). +## 0.1 What this is + +A durable runtime for agent conversations. You hand it a prompt; it talks to a language model, runs tools, and produces a response. The difference from an ordinary agent loop is that **the process can die at any instant** — mid-stream, between a tool call and its result, halfway through a summary — and a new process picks up exactly where the old one stopped, without repeating durable work and without losing anything that had committed. + +It is a library, not a server. One process owns one session at a time. + +## 0.2 Three concepts + +### Session — the conversation + +A session is one conversation, stored as a **tree** rather than a list. + +``` +a ── b ── c ── d + └── e ── f +``` + +A tree, because three features need history that does not move: branching (explore an approach, back out, keep the record), compaction (replace a long prefix with a summary while the original stays queryable), and forking (copy a prefix into a new session). Entries are appended and never modified or deleted. + +A session also holds **facts** (session name, entry labels, application key-value state — latest write wins, not part of the tree) and a **usage ledger** (every token and cost event, append-only). + +### Lane — a cursor into the conversation + +A lane is a **name plus a leaf**: the entry that new work extends. Every session has `main`. Applications create more. + +A lane owns its leaf, its configuration (model, thinking level, active tools), its queues, and at most one operation in flight. Lanes run in parallel and share nothing except the tree beneath them. + +Why lanes exist: a Slack channel is one session, and each thread is a lane. Threads share the channel's history but take turns independently. Two lanes can sit on the same entry and diverge on their next append — the tree handles that, and no coordination is needed. + +**Lane vs fork:** a lane *shares* history; a fork *copies* it for isolation. Use a lane for a thread in a shared conversation, a fork for a subagent, an export, or a what-if. + +### Harness — what runs a lane + +The harness is the API surface. Per lane: `prompt`, `steer`, `followUp`, `nextRun`, `abort`, `resume`, `compact`, `navigateTree`, plus configuration getters and setters and a tree view. Harness-wide: lane management, tool and resource registries, hooks, events. + +An **operation** is one accepted unit of work on a lane — a `run` (prompt to final answer, including all tool calls), a `compaction`, or a `navigation`. One per lane at a time. + +## 0.3 Worked example — a Slack thread + +A user posts in a channel that already has 400 entries of history. The application creates a lane for the thread, anchored at the channel's current leaf. Entry ids are UUIDv7s (§1.2); examples abbreviate them. + +``` +harness.createLane("slack:1719432.0021", at: "0195c8d1-4a2e-7b31-…") +lane.prompt("what changed in auth last week?") +``` + +What happens, in order: + +1. **Acceptance.** The harness validates, runs the `before_run` hook, and commits one transaction: the user-message entry, the operation's `op.meta` register, and its first `op.state` — *"I am at a checkpoint, and I need an assistant response."* +2. **Intent.** It commits a second transaction: *"I am about to make a provider request. The response will be entry `0195c8d1-53a0-7c44-…` and the usage row will be `0195c8d1-53a0-7d18-…`."* Both ids are minted now; nothing has been sent yet. +3. **The request.** Streaming happens. This is the only part that is not durable. +4. **Settlement.** One transaction commits the response entry, its usage row, and the next state: *"the response has tool calls; here is the batch plan, with result ids already assigned."* +5. Tool calls follow the same intent → effect → settlement shape, one pair of commits each. +6. When the model stops without tool calls, a terminal transaction deletes the operation's registers, records the outcome in `lane.lastResult`, and leaves the lane idle. + +Kill the process between any two of those transactions and restart. The harness reads the lane's registers, sees exactly which of those sentences was the last one committed, and continues. If it died in step 3, it knows a request may have been billed and may or may not have produced output — that is the one genuinely uncertain window in the whole system, and there is a stated policy for it. + +Meanwhile a second thread in the same channel is running its own lane, over the same 400 entries of shared history, with no coordination between them. + +## 0.4 Worked example — a crash mid-tool + +``` +lane.prompt("delete the stale migrations and run the test suite") +``` + +The model returns two tool calls. The harness commits the batch plan, then commits `call 0 is about to execute, with these exact arguments, and it declares itself unsafe to replay`. The tool starts deleting files. The process is killed. + +On restart the harness reads one register and finds `calls[0].status = "effect_pending", replay = "never"`. It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1. The conversation stays coherent — every tool call has a result — and nothing ran twice. + +Had the tool declared `replay: "safe"` (a read, a query), the harness would have re-executed it with the persisted arguments instead. + +## 0.5 The three stores + +Everything in Parts 1–5 follows from these. + +**1. Three stores, one invariant.** Everything durable is one of: + +```text +entries the conversation tree — write-once, append-only +registers current mutable state — namespaced typed cells, overwrite or delete +usage ledger cost history — append-only rows +``` + +*Every payload is in an entry, a register, or the ledger; there is no third place.* An entry is the complete conversation record — placement and payload in one row. A register holds its current typed value directly; overwriting discards the old value, and deletion removes the key. Content that durably exists before it has a place in the tree (queued input, deferred writes) waits in a `pending.entry` register and becomes an entry in the transaction that places it. Per-backend projections — branch index, full-text search, stats, partition inventory — are rebuildable from the three stores and carry no authority. + +**2. Atomic transactions.** A transaction is a set of entry inserts, usage inserts, and register writes (set or delete), committed all-or-none with consecutive sequence numbers. There is no crash state inside a transaction. This is the only write primitive. + +**3. The durable program counter.** After every step, the harness overwrites one register — `op.state/{operationId}` — with the *complete* current state of the operation. Recovery does not replay a journal or infer position from what is missing; it reads that register and switches on it. The state is *total* — it never depends on a previous state. Small captured values (configuration, stream options, retry policy) are inline; large stable payloads live in sibling `op.*` registers or are named by id. When the operation ends, the terminal transaction deletes its registers: a finished session holds exactly the conversation, the ledger, and a handful of lane and fact registers. There is no dead state to collect. + +**4. The effect sandwich.** Provider requests and real tool calls are wrapped in two commits: + +``` +commit: "about to do X; its output will use ids R and U" ← intent + do X ← the uncertain part +commit: output + usage + next state ← settlement +``` + +Hooks follow their replay contract instead: a result becomes durable in the transaction that consumes it, and a crash before that transaction may rerun the hook. Thus every external effect can still happen without durable settlement. Provider/tool intents make that uncertainty explicit where replay policy depends on it; idempotent hooks accept it as a non-goal. + +## 0.6 Non-goals + +- **Exactly-once external effects.** See above. Hooks with their own side effects must be idempotent, keyed by operation id. +- **Provider stream resumption.** Partial streams are process-local, never persisted. A settled response is persisted *completely* before anything classifies it. +- **Multiple writers.** One process per session. The serving layer routes accordingly, and the SQLite backend enforces it with a fenced lease (§1.7). Lanes cover the workload that looks like multi-writer. +- **Replication.** A session lives in one place. +- **Durable write history.** Registers hold only current values: an overwritten register is gone, and there is no `getLog` or history table. Order-of-write assertions in tests use an instrumented storage decorator around `commit()` (Part 9); production auditing belongs to the telemetry layer (§5.8). +- **Compliance deletion through retention expiry.** Partition expiry is TTL and cost control, not erasure: `retainedTail` copies old messages forward into newer compaction entries, and summaries derive from old content. Compliance-grade "erase this" uses the precise-rewrite path (Part 6). + +## 0.7 Notation and source types + +- `TX[ a, b, c ]` — one atomic commit containing writes `a`, `b`, `c` in that order. The write vocabulary is `insert entry`, `insert usage`, `upsert namespace/key = value`, and `delete namespace/key`. +- Ids are UUIDv7s (§1.2). Examples abbreviate them: short tags — `e_*` entry ids, `u_*` usage ids, `op_*` operation ids — stand in for full ids where the time prefix is irrelevant; where the prefix matters, examples show it (`0195c8d1-4a2e-7b31-…`). +- `S(next)` — overwrite the `op.state/{operationId}` register with the next total operation state. `L(next)` — the same for `lane.state/{lane}`. +- **must / must not** are normative. Everything else is explanation. + +Source type provenance: + +- `AgentMessage`, `AgentTool`, `AgentToolResult`, `AgentEventSink`, `QueueMode`, and `ThinkingLevel`: `packages/agent/src/types.ts`. +- `Skill`, `PromptTemplate`, `AgentHarnessResources` (`Resources` below), `AgentHarnessTool`, `AgentHarnessStreamOptions`, and `AgentHarnessStreamOptionsPatch`: `packages/agent/src/harness/types.ts`. +- `Model`, `Models`, `Usage`, `RetryPolicy`, `StopReason`, `AssistantMessage`, `ImageContent`, provider messages, stream options, and deferred handles: `packages/ai`. +- `CompactionSettings`, `CompactionPreparation`, `CompactResult`, `BranchPreparation`, and `BranchSummaryResult`: `packages/agent/src/harness/compaction/`. Existing preparation and split-turn algorithms remain the implementation starting point unless this document explicitly changes them. +- `TelemetryContext` and typed schema helpers: `packages/telemetry`; the agent-owned schemas remain in `packages/agent/src/harness/telemetry.ts`. +- `TSchema` for durable custom-message registration: `typebox`. + +The public `QueueMode` remains `"all" | "one-at-a-time"`. Public `RetryPolicy` remains the pi-ai shape `{ enabled, maxRetries, baseDelayMs }`; operation state stores its normalized `{ maxAttempts, baseDelayMs }` equivalent. `maxRetries` and `baseDelayMs` must be finite non-negative safe integers and `maxRetries + 1` must remain safe; disabled retry normalizes to one attempt. Exponential delay and `notBefore` arithmetic saturate at `Number.MAX_SAFE_INTEGER`. Public `CompactionSettings` remains `{ enabled, reserveTokens, keepRecentTokens }`; both token counts must be finite non-negative safe integers. Constructors and setters reject invalid settings before publication. This design adds `deferred?: boolean | { window?: "15m" | "1h" | "24h" }` to `AgentHarnessStreamOptions` and its patch type; structural requests always force it to false. + +```ts +type SettledAssistantMessage = AssistantMessage & { + stopReason: Exclude; +}; + +/** Added to packages/ai: a synchronous registry lease that captures the exact + provider/model and Models auth resolver without resolving auth yet. */ +interface ModelRequestLease { + readonly model: Model; + stream(context: Context, options?: ModelsApiStreamOptions): + AssistantMessageEventStream; + streamSimple(context: Context, options?: ModelsSimpleStreamOptions): + AssistantMessageEventStream; + fetchDeferred(handle: DeferredHandle, options?: ModelsDeferredFetchOptions): + Promise; + cancelDeferred(handle: DeferredHandle, options?: ModelsDeferredCancelOptions): + Promise; +} +// Models.lease(provider: string, modelId: string): ModelRequestLease | undefined +``` + +There are no orchestration "records" in this system. Every durable thing is an **entry**, a **register**, or a **usage row**. + +--- # Part 1 — Storage -- **1.1 The model** `[REWRITE: jot 1]` — `Entry` (complete row: placement + payload), `Register` (namespace, key, seq, typed value), usage row. Register deletion is a first-class write, distinct from storing JSON `null`. -- **1.2 Identity and partitions** `[NEW: jot 9]` — UUIDv7 entry ids; the 48-bit time prefix *is* the partition assignment, truncated to the period; follower ids (tool results only) mint with their assistant id's prefix; reserved ids are minted at reservation; no partition columns anywhere. Retired-vs-corrupt traversal rules (vacuous on non-partitioned backends). -- **1.3 Register namespaces** `[REWRITE: jot 1, 3, 9]` +Storage knows nothing about agents, lanes, or conversations. It stores entries and usage rows, updates registers, and answers a small fixed set of queries. Parts 2–4 are built entirely on this. + +## 1.1 The model + +```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; + +/** Write-once. The complete conversation record: placement and payload in one + row. Created in exactly one transaction, never modified or deleted. The + four concrete entry types extending this base are defined in §2.1. */ +interface EntryBase { + id: string; // UUIDv7 (§1.2) + parentId: string | null; + seq: number; // storage-assigned at commit + timestamp: number; // Unix ms, storage-assigned at commit + type: EntryType; + customType?: string; // when type === "custom" + // ...payload fields per entry type (§2.1) +} + +type EntryType = "message" | "compaction" | "branch_summary" | "custom"; + +/** The only mutable store. A namespaced key holding its current typed value + directly. Overwrite replaces the value; delete removes the key. */ +interface Register { + namespace: N; + key: string; + value: RegisterValues[N]; + seq: number; // seq of the write that last set this register +} + +/** Append-only cost ledger row. Never modified, never deleted (§1.6). */ +interface UsageRow { + id: string; // UUIDv7 (§1.2) + seq: number; // storage-assigned at commit + usage: Usage; + entryId?: string; // the entry this cost belongs to, when there is one + adjustment: boolean; // true = caller-supplied reconciliation, not a provider report + details?: JsonValue; +} +``` + +**Why placement and payload are one row.** The superseded design split content ("values") from placement ("nodes") because they can have different birth times: queued input has content at enqueue and placement much later; an assistant response needs its id fixed *before* the content exists. The split is gone; the differing birth times remain, and two reservation regimes cover them (§2.2). Content that is durable before placement is *current mutable state* and waits in a `pending.entry` register keyed by its reserved entry id; the placement transaction writes the complete entry and deletes the register. An id that must exist before its content — an assistant response, a tool result — is just a minted string inside `op.state`, and settlement inserts the complete entry. Every read returns the whole entry with no join, no `valueId`, and no way for content to exist without an owner. + +**Registers hold values, not pointers.** A register's value is the current typed state itself, never an id pointing at an immutable state value. Overwriting a register discards the previous value; nothing accumulates and there is no history to fold (§1.8). Deleting a register removes the key entirely and is a first-class write, distinct from storing JSON `null`, which remains a legal value where a namespace's type permits it (`lane.leaf` at the root, `fact.custom`). + +## 1.2 Identity and partitions + +Every id storage stores — entry ids, usage ids, and every reserved id that will become one — is a **UUIDv7**, minted through the session's id generator (§2.8). A UUIDv7 begins with 48 bits of Unix milliseconds: the first 12 hex characters of the id *are* a timestamp, and that timestamp, truncated to the partition period, *is* the id's partition assignment. There are no partition columns anywhere — not on entries, not on ledger rows, not in any register value. The period length (monthly in every example) is a deployment property of the partitioned backend; Memory, JSONL, and SQLite never partition. + +What the embedded prefix buys: + +- **Every reference is self-describing.** A `parentId`, a `lane.leaf` value, a `fact.label` key, an id inside `op.state` JSON — any of them can be classified against the partition retirement inventory by reading its prefix, with no lookup. +- **Native partition pruning.** Postgres compares `uuid` bytewise and UUIDv7 sorts in time order, so `PARTITION BY RANGE (id)` works directly, with period-boundary UUIDs (zeroed tails) as bounds. The primary key stays `(session_id, id)`, and a point lookup prunes to one partition from the id itself (§1.7). +- **The cost.** Ids leak their creation period to applications. Accepted: the alternative is a denormalized partition column on every row, plus no answer at all for references held inside register values. + +Minting rules: + +1. An id is minted with `now()` **at reservation**. For born-placed entries — the hot path — reservation and placement are the same transaction, so the prefix equals the placement date. +2. **Followers inherit the leader's timestamp.** Tool-result ids are minted with their assistant entry id's 48-bit timestamp (fresh random bits keep them unique), so an assistant and its tool results share a partition by construction, even across a midnight or month boundary. This is a deliberate, documented deviation from "UUID timestamp = wall clock". It exists because dropping a partition must never orphan half of a call/result exchange: a retained tool result whose assistant call is gone heads a context every provider rejects. +3. **Synthetic settlement needs no special case.** Crash recovery and force-expiry write under already-reserved ids (§4.5), so synthetic responses and results land in the partition their intent promised. +4. **Late placement pins.** A `nextRun` message minted in January and consumed in April is placed as a January-partition entry — exact, but it means unplaced reservations pin their partitions. All such reservations are enumerable from hot registers (`pending.entry` keys and the reserved ids inside open `op.state` values are UUIDv7s: decode, take the minimum), so drop preflight is a bounded register scan. Retention policy for abandoned reservations is Part 6. + +Traversal discrimination is exact by construction: + +```text +parent entry exists → continue +parent missing, id prefix in a retired period → retention boundary — clean stop +parent missing, id prefix in a live period → corruption — loud +``` + +Memory, JSONL, and SQLite never retire periods, so the middle case is unreachable there: a missing parent is always corruption. The rules are still core — branch scans and forks must implement the boundary stop (§2.5, §2.7) — but only the future Postgres backend (§1.7) and the conformance suite's abstract retired-range set (Part 9) exercise it. + +## 1.3 Register namespaces + +```ts +interface RegisterValues { + "lane.leaf": string | null; // entry id; null = lane at the root + "lane.config": LaneConfiguration; // §2.3 + "lane.state": LaneState; // §3.3 + "lane.lastResult": LaneLastResult; // §3.13 + "op.meta": Operation; // §3.1 + "op.state": OperationState; // §3.2 — the program counter + "op.tool_args": Record; // effective tool arguments (§3.8) + "op.preparation": DurableStructuralPreparation; // §3.9 + "pending.entry": PendingEntry; // §2.2 + "fact.name": string; + "fact.label": string; + "fact.custom": JsonValue; // JSON null is a legal value +} +type RegisterNamespace = keyof RegisterValues; + +/** Unplaced content: current mutable state until the placement transaction + writes the complete entry and deletes this register (§2.2). */ +interface PendingEntry { + type: "message" | "custom"; + customType?: string; + payload: JsonValue; // the content that becomes the entry's payload +} + +interface DurableFileOperations { + read: string[]; written: string[]; edited: string[]; +} +type DurableStructuralPreparation = + | { kind: "compaction"; messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; retainedTail: AgentMessage[]; + isSplitTurn: boolean; tokensBefore: number; previousSummary?: string; + fileOps: DurableFileOperations; settings: CompactionSettings } + | { kind: "branch_summary"; messages: AgentMessage[]; + fileOps: DurableFileOperations; totalTokens: number }; +``` + +| Namespace | Key | Value | Meaning | +|---|---|---|---| +| `lane.leaf` | lane name | entry id or `null` | where this lane appends next | +| `lane.config` | lane name | `LaneConfiguration` | total lane configuration | +| `lane.state` | lane name | `LaneState` (§3.3) | `currentOperationId`, `pendingNextRun` | +| `lane.lastResult` | lane name | `LaneLastResult` (§3.13) | terminal outcome of the lane's most recent operation | +| `op.meta` | operation id | `Operation` (§3.1) | acceptance data; written once, never overwritten | +| `op.state` | operation id | `OperationState` (§3.2) | total operation state — **the program counter** | +| `op.tool_args` | `{opId}:{sourceIndex}` | effective arguments | written once at tool clearance (§3.8) | +| `op.preparation` | `{opId}:{taskId}` | `DurableStructuralPreparation` | written once before the decision hook (§3.9) | +| `pending.entry` | reserved entry id | `PendingEntry` | queued content awaiting placement (§2.2) | +| `fact.name` | `""` | string | session name | +| `fact.label` | entry id | string | entry label | +| `fact.custom` | application key | `JsonValue` | application state | + +That is the complete set. Two lifetimes are visible in the key shape: ```text -lane.leaf | lane.config | lane.state | lane.lastResult -op.meta | op.state | op.tool_args/{opId}:{i} | op.preparation/{opId}:{t} -pending.entry/{entryId} -fact.name | fact.label | fact.custom -``` - - No `queue.disposition` (removed, jot 9). Lifetimes: `lane.*`/`fact.*` = session; `op.*`/`pending.*` = operation, deleted at terminal TX. -- **1.4 Transactions** `[REWRITE: jot 8]` — write kinds: `entry`, `register set`, `register delete`, `usage`. All-or-none, consecutive session-wide `seq`, serialized writer, validation before admission, fault on failed admitted commit (carry those rules from base 1.3). -- **1.5 Queries** `[REWRITE: jot 8]` — `getEntries`, `getRegister`/`listRegisters` (typed values), `scanBranch`/`scanBranchStructure`/`scanEntries`, `getStats`. No `getLog`, no value scans. Bounded/index-driven rules carry from base 1.4. -- **1.6 Usage ledger** `[NEW-ish: jot 1]` — append-only rows incl. failed attempts; `getStats` as maintained aggregate; adjustment rows. -- **1.7 Backends** `[REWRITE: jot 7; base 1.6]` - - Memory: maps are the store; register delete = map delete; no log. - - JSONL **format 4**: one line per commit (object/array); line kinds entry/register-set/register-del/usage/header with `storageVersion`; replay = decoding; torn final line discarded whole; **snapshot compaction** (temp+rename) with the growth-asymmetry rationale and eager-compaction note for sensitive cancelled payloads. Keep base's fsync/durability language. - - SQLite: entries/registers/usage tables, branch index, FTS, leases, `BEGIN IMMEDIATE` — carry base's discipline sections; registers become upsert/delete rows; drop values/`slot_history`/`getLog` machinery. - - Postgres `[NEW: jot 5, 9]` — future fourth backend; `PARTITION BY RANGE (id)` on the uuid column with period-boundary UUID bounds; hot unpartitioned catalog (registers, branch_meta, inventory, stats, leases) + partitioned entries/usage/branch index/FTS; single-transaction atomicity across both; DETACH/DROP expiry protocol driven by inventory. -- **1.8 Why write-once plus registers** `[REWRITE of base 1.7]` — recovery is register reads; crash states enumerable; operation cleanup is register deletion; nothing to collect. +lane.* fact.* session-lived; facts are deleted only by explicit application action +op.* operation-lived; deleted by the terminal transaction (§3.13) +pending.entry lives until its content is placed or cancelled +``` + +- `op.meta`, `op.tool_args`, and `op.preparation` keys are written exactly once and then deleted at the terminal transaction; only `op.state` is overwritten during the operation. +- Operation-owned `pending.entry` registers still unconsumed at the end (remaining inbox items and abort-drained items) are deleted by the terminal transaction — a consumed item's register dies in its placement transaction; lane-owned ones (`pendingNextRun`) outlive operations and die when consumed or cancelled (§3.11). +- `lane.lastResult` is written only by terminal transactions and overwritten by the next one on its lane — one bounded register per lane, forever. Recovery never reads it; it exists so an application that accepted an operation, crashed, and reopened can still learn its outcome (§3.13). +- Deleting a fact removes its register. Storing JSON `null` in `fact.custom` is a different, legal state; there are no tombstones. +- There is no `queue.disposition` namespace. It existed solely so a repeated `cancelQueued` could answer `already_cleared`, at the cost of one immortal register per cancelled item. Triage is now: pending → `cancelled`; entry exists → `already_consumed`; else → `not_found` (§3.11). Clients that retry a lost cancel treat `not_found` as success. + +## 1.4 Transactions + +```ts +type Write = + | { kind: "entry"; entry: Omit } + | { kind: "usage"; row: Omit } + | { kind: "register"; op: "set"; namespace: RegisterNamespace; key: string; + value: JsonValue } + | { kind: "register"; op: "delete"; namespace: RegisterNamespace; key: string }; + +interface Transaction { writes: Write[] } + +interface CommitResult { firstSeq: number; seqs: number[]; timestamp: number } +``` + +Rules: + +1. A transaction commits **all-or-none**. There is no observable state in which some of its writes exist and others do not. +2. Writes receive **consecutive** `seq` values in the order given. `seq` is monotonic session-wide across all lanes and all write kinds. A register `set` stamps the register with its assigned `seq`. +3. Within a transaction, writes apply in order: an entry may name a parent created earlier in the same transaction; a register value may reference entry or usage ids created earlier in the same transaction. A placement transaction inserts the complete entry and deletes its `pending.entry` register together (§2.2) — there is never a moment where both exist. +4. Entry and usage ids share one session-wide id namespace. Writing either kind under any existing id is **corruption**, not an update. +5. A register `set` with the same `(namespace, key)` replaces the current value; `delete` removes the key; a later `set` recreates it. No history is retained. A `delete` naming an absent key is a no-op, so public deletions such as clearing an unset label stay legal. +6. Transactions on one session are **serialized**. There is one writer and one queue. + +Session validates the complete transaction, including JSON serialization and runtime schemas, before storage admission. A failed admitted commit **faults the harness**: all effects stop, all calls reject, and the process must be restarted. A partially applied transaction is not tolerated. + +## 1.5 Queries + +One `Storage` instance serves one session. Repository discovery and lifecycle are outside this interface (§2.8). + +```ts +interface Storage { + commit(tx: Transaction): Promise; + + getEntries(ids: string[]): Promise>; + + getRegister(namespace: N, key: string): + Promise | undefined>; + listRegisters(namespace: N): Promise[]>; + + scanBranch(q: BranchScan): Promise; // §2.5 + scanBranchStructure(q: BranchScan): Promise; + scanEntries(q: EntryScan): Promise; // session-wide tree inventory + getStats(): Promise; // maintained projection (§1.6) + + close(): Promise; +} + +/** Placement metadata without payload fields. */ +type EntryStructure = Pick; + +interface EntryScan { + type?: EntryType; customType?: string; + fromSeq?: number; toSeq?: number; + order?: "asc" | "desc"; limit?: number; +} +``` + +There is deliberately no cross-namespace register scan, no ledger scan, and no durable write log. Restore, facts, forks, and execution follow exact ids and keys; entry inventory uses `scanEntries`; totals use the stats projection (§1.6); test-order assertions wrap `commit()` with the instrumented-storage decorator (Part 9); production auditing belongs to telemetry (§5.8). + +Recovery and execution reads must be index-driven and bounded. They may not infer state from an absent value, and there is no register history to fold. Exact dereference is allowed: one current state may name a bounded set of entries and registers, fetched in one batch without order-dependent reduction. Public inventory and debugging APIs may intentionally read more than a hot path; their `limit`/pagination behavior is explicit at the `SessionTree` layer. + +`close()` is idempotent. It seals admission, rejects later reads/commits on that instance, drains commits admitted before the seal, then releases resources and the writer claim. Durable data is reopened through the repository. + +## 1.6 Usage ledger + +Every settled provider attempt writes one `UsageRow` — successful, failed, retried, and synthetic attempts alike, including attempts whose operation later aborts. Settlement transactions write the response entry and its usage row together (§3.7); synthetic settlements write zero usage under the reserved usage id. Rows are append-only: terminal cleanup deletes an operation's registers but never its ledger rows, so billing survives everything that can happen to orchestration state. + +```jsonc +{ "id": "u_7", "seq": 815, "entryId": "e_51", "adjustment": false, + "usage": { "input": 12000, "output": 431, "cost": { ... } } } +``` + +- `entryId` names the entry the cost belongs to, when there is one. Structural (summary) attempts that fail before producing an entry, and standalone adjustments, have none. +- `adjustment: true` marks a caller-supplied reconciliation (`recordUsage`, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix C). +- Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows and import aggregates mint their ids at commit; nothing reserves them. +- `getStats()` is a maintained projection over the ledger and the entry count. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). There is no ledger scan: totals come from the projection, and individual rows reach the application through the `usage` event at commit time (§5.5). + +## 1.7 Backends + +Three encodings of one model ship now — Memory, JSONL, SQLite — and all three pass the same conformance suite (Part 9). Postgres is a planned fourth; it appears here because its native partitioning shapes the retention design (Part 6). Each backend records the session's `storageVersion` (Part 7): a JSONL header field, a SQLite/Postgres catalog column. Memory sessions are always current. + +### Memory + +```ts +entries: Map +registers: Map // key: `${namespace}\u0000${key}` +usage: Map +children: Map // parentId → entry ids, for tree walks +``` + +One queue serializes commits. A commit validates and applies writes to temporary transactional state, then publishes the maps together. A register delete is a map delete. Reads are map lookups; `scanBranch` walks `parentId` and filters in RAM. There is no log: Memory holds exactly the live state and nothing else. + +### JSONL + +The file is not the state; it is the **replay recipe** for the Memory maps above. One physical line per `commit()`. Storage assigns sequence/timestamp fields first, then encodes one committed write as a JSON object line or several as one **array line**. + +```jsonl +{"v":4,"kind":"header","id":"s_1","storageVersion":1,"createdAt":1700000000000,"cwd":"..."} +[{"kind":"entry","seq":101,"timestamp":1700000000000,"id":"e_50","parentId":"e_41","type":"message","message":{"role":"user","content":[...]}}, + {"kind":"register","op":"set","seq":102,"namespace":"op.meta","key":"op_9","value":{...}}, + {"kind":"register","op":"set","seq":103,"namespace":"op.state","key":"op_9","value":{...}}, + {"kind":"register","op":"set","seq":104,"namespace":"lane.leaf","key":"main","value":"e_50"}, + {"kind":"register","op":"set","seq":105,"namespace":"lane.state","key":"main","value":{...}}] +{"kind":"usage","seq":110,"id":"u_7","entryId":"e_51","adjustment":false,"usage":{...}} +{"kind":"register","op":"delete","seq":131,"namespace":"op.state","key":"op_9"} +``` + +- This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix C). +- Open replays lines in order into the Memory maps: entries and usage rows accumulate; a later register `set` overwrites the key, `delete` removes it. That is *decoding*, not recovery logic. Open verifies persisted sequence continuity and timestamps and never regenerates committed timestamps. All queries then run in RAM. +- **A torn final line is discarded whole**, including every element of an array, and is truncated before new writes are admitted. This is what makes "no crash prefix inside a transaction" true here. +- A malformed *interior* line, or a complete-but-invalid transaction, is corruption. The one exception: superseded old-shape register lines from before a schema migration decode leniently as keyed raw JSON during replay (Part 7); compaction retires them. +- Durability is process-crash level: a resolved `commit()` survives process death. No fsync promise. +- Optional: retain `(offset, length)` per entry and load payloads lazily, keeping only structure and registers resident. Do this only if profiling demands it. + +**Snapshot compaction.** In SQLite a register `set` is an in-place upsert — a 30-turn run leaves one `op.state` row and then zero. In JSONL every `set` appends, so the same run appends ~10 full `op.state` lines, all dead the moment the terminal `delete` line lands: the file grows with *write history* even though the logical state does not. The fix is rewriting the file as `header + current entries + current registers + usage rows`, via temp file + atomic rename. For a four-entry run: + +```text +before compaction: ~10 transaction lines, ~27 writes — op.state revisions, + tool args, pending payloads, all dead since the terminal line +after compaction: header + 4 entry lines + 2 usage lines + 4 lane register lines +``` + +When to compact: on open when the dead-bytes ratio crosses a threshold; optionally after terminal transactions; always after a schema migration (Part 7). Between compactions, normal operation is append-only and O(1) per commit. One consequence worth stating: deleted pending payloads and superseded state revisions **linger as bytes** until compaction — logical deletion is immediate, physical deletion is deferred. A deployment that needs prompt physical removal of sensitive cancelled content compacts eagerly at terminal boundaries. + +### SQLite + +```sql +entries(session_id, id TEXT, parent_id TEXT, seq INTEGER, type TEXT, custom_type TEXT, + timestamp INTEGER, payload TEXT, PRIMARY KEY (session_id, id)) WITHOUT ROWID; +CREATE INDEX ix_entry_parent ON entries(session_id, parent_id); +CREATE INDEX ix_entry_seq ON entries(session_id, seq, type); + +registers(session_id, namespace TEXT, key TEXT, seq INTEGER, value TEXT, + PRIMARY KEY (session_id, namespace, key)); + +usage_ledger(session_id, id TEXT, seq INTEGER, entry_id TEXT, adjustment INTEGER, + usage TEXT, details TEXT, PRIMARY KEY (session_id, id)) WITHOUT ROWID; +CREATE INDEX ix_usage_seq ON usage_ledger(session_id, seq); + +-- Private branch index (§2.6). Not registers; no equivalent in the other backends. +branch_entries(session_id, branch_id TEXT, entry_id TEXT, entry_seq INTEGER, entry_type TEXT, + PRIMARY KEY (session_id, branch_id, entry_id)) WITHOUT ROWID; +-- Ordered scans. entry_seq must follow branch_id directly or ORDER BY needs a +-- temp b-tree; entry_id and entry_type trail so the index covers id-only reads. +CREATE INDEX ix_be_seq ON branch_entries(session_id, branch_id, entry_seq, entry_id, entry_type); +-- Type-filtered scans. +CREATE INDEX ix_be_type ON branch_entries(session_id, branch_id, entry_type, entry_seq, entry_id); +CREATE INDEX ix_be_entry ON branch_entries(session_id, entry_id); +branch_meta(session_id, branch_id TEXT, tip_entry_id TEXT, tip_seq INTEGER, + base_branch_id TEXT, base_seq INTEGER, + PRIMARY KEY (session_id, branch_id)); +CREATE UNIQUE INDEX ix_bm_tip ON branch_meta(session_id, tip_entry_id); + +sessions(session_id, created_at, parent_session_id, storage_version, metadata); +session_stats(session_id, message_count, usage_payload); +session_sequences(session_id, next_seq); +writer_leases(session_id, owner_id TEXT, fence INTEGER, expires_at_ms INTEGER); +``` + +One `commit()` is one SQL transaction: insert entries, insert ledger rows, upsert or delete registers, maintain the branch index, bump `session_stats`. Never an UPDATE or DELETE on an entry or ledger row; mutability is confined to registers, the branch index (`branch_meta` tips and bases), stats, sequences, the session catalog row, and leases. + +**Every transaction must open with `BEGIN IMMEDIATE`.** A deferred `BEGIN` that +reads before it writes takes a read snapshot and must later upgrade to the write +lock; if another writer committed in between, SQLite fails that upgrade — and +`busy_timeout` does **not** rescue it, because no amount of waiting can refresh a +stale snapshot. The only recovery is rollback and full retry. + +Every commit has this shape, not just a few. Allocating the sequence range reads +`session_sequences.next_seq` and then writes it, so a read precedes a write in every +transaction the system performs. Branch creation (§2.6) adds a second instance, +reading the newest compaction before inserting. `BEGIN IMMEDIATE` takes the write +lock up front and avoids an unrecoverable stale-snapshot upgrade, so there is no case +where a deferred `BEGIN` is the right choice here. + +**`writer_leases` enforces the single-writer rule.** Expiring fenced ownership: +`open()` acquires the claim, storage renews it on appends and while idle, and close +stops renewal after the queue drains and deletes only its matching `(owner_id, +fence)` pair — so a stale owner cannot release the replacement that succeeded it. +This is what makes "one process owns one session" an enforced property rather than +a convention the serving layer is trusted to uphold. Memory and JSONL have no +equivalent and rely on process ownership; a JSONL session opened twice is corrupt +and undetected. + +**Writer scope is per database file, not per session.** WAL mode permits exactly one +writer per file. Because these tables are keyed by `session_id`, several sessions may +share a file, and the design's one-writer-per-session rule does not by itself make +writes uncontended. Choose deliberately: + +- *One file per session* — the single-writer claim becomes literally true, and there + is no cross-session contention. Preferred unless something forces otherwise. +- *One file for many sessions* — correct, but all sessions share SQLite's one-writer queue. Use only when that contention is acceptable. + +Atomicity itself needs no special handling. A multi-write transaction is all-or-none +by the file format: WAL frames become visible only when the commit record lands, so a +concurrent reader observes either none of a transaction's writes or all of them. + +Each physical segment of `scanBranch` uses one JOIN; §2.6 combines segment ranges: + +```sql +SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload +FROM branch_entries b +CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id +WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ? +ORDER BY b.entry_seq; +``` + +`CROSS JOIN` is load-bearing: it forces `branch_entries` to be the outer loop. Left +to itself the planner may drive from `entries`, scan the table, and sort through a +temporary b-tree. Assert the plan in a test: + +``` +SEARCH b USING COVERING INDEX ix_be_seq (session_id=? AND branch_id=? AND entry_seq>?) +SEARCH e USING PRIMARY KEY (session_id=? AND id=?) +``` + +Any plan containing `USE TEMP B-TREE FOR ORDER BY` or a scan of `entries` is a +regression. + +`scanBranchStructure` is the same query without the payload column. `getEntries` is a primary-key lookup keyed by `e.id IN (...)`. + +The repository's existing `SessionSearch` surface remains. SQLite replaces its rowid-dependent index with an FTS projection keyed by stored `session_id` and `entry_id`; searchable text is the JSON serialization of the entry, matching the scanning fallback. The transaction that places an entry also inserts its projection after validation. Pending content is not searchable before placement. Fork import populates the same projection, and session deletion removes its rows. Search never depends on `entries.rowid`. + +### Postgres — future fourth backend + +Planned, not normative; named now because its native partitioning is what the identity design (§1.2) and the retention design (Part 6) are shaped for. The logical model is identical. Two temperature zones in one database: + +```text +hot, unpartitioned catalog: partitioned by entry-id range (period bounds): + registers entries + branch_meta usage_ledger + partition inventory branch index rows + session_stats FTS projection + writer leases, sessions +``` + +- `PARTITION BY RANGE (id)` on the uuid primary-key column, with period-boundary UUIDs (zeroed tails) as bounds. The primary key stays `(session_id, id)`; point lookups prune to one partition from the id's own time prefix, and no partition-key column exists. +- One database means **one transaction spans hot registers and partitioned entries**: an acceptance transaction — entry inserts plus several register writes — is a single Postgres transaction, exactly as on SQLite. +- Expiry is `seal period → write per-session aggregates into the inventory → DETACH CONCURRENTLY → DROP`. `DETACH CONCURRENTLY` is not transactional, so expiry is a small recoverable protocol driven by inventory state, not one atomic step; a crash between steps redoes the step the inventory names. Retention semantics — pins, preflight, boundaries — are Part 6. + +## 1.8 Why write-once plus registers + +- **Recovery is a read.** Five register point-lookups per lane, then exact-id dereference (§4.4). No reducer exists to have a bug. +- **Crash states are enumerable.** Between transactions, never inside one. +- **Cleanup is deletion, not collection.** A 30-turn run overwrites one `op.state` register ~30 times and then deletes it. What remains is exactly the conversation, the ledger, and a handful of lane and fact registers — no dead state values, no history rows, nothing to garbage-collect. (JSONL defers *physical* reclamation to snapshot compaction; the logical state is identical.) +- **No repair-by-rewrite.** Recovery appends entries and overwrites only the registers it owns, with the same transitions normal execution would commit; interrupt it and rerun it and you get the same result. +- **Concurrency is trivial.** Readers never see partial state; there is nothing to lock. +- **The one deliberate double-write.** Queued content is serialized twice: into its `pending.entry` register at enqueue and into its entry at placement. Only queued items pay it — assistant and tool settlements, the hot path, write their entries once. In exchange every queue item is one id, cancellation deletes content outright, and no payload ever exists without an owner. + +--- # Part 2 — The conversation tree -- **2.1 Entries** `[REWRITE: jot 8]` — the four complete entry types; no materialization function; assistant entries settled-only; `terminate` on tool results; `fromHook`; self-contained `retainedTail` — carry base rules minus node/value compatibility (moot). -- **2.2 Placement** `[REWRITE: jot 2]` — born-placed (one TX) vs pending-register content (`pending.entry` written at enqueue, complete entry inserted + register deleted at placement, one TX, no third state); reserved-id regimes: plain strings for assistant/tool ids, pending registers for queued content. -- **2.3 Lanes** `[CARRY]` — three registers plus `lane.lastResult`; creation TX; permanence rules. -- **2.4 Facts** `[CARRY+]` — deletion is register deletion (no tombstones); JSON `null` remains a legal `fact.custom` value. -- **2.5 Branch queries and context** `[CARRY]` — scans, cursors, context projection, append-only context invariant, overflow-normalization interaction. Add: branch-scan truncation marker at retention boundaries (jot 5/8). -- **2.6 The branch index** `[CARRY+ jot 9]` — segment machinery carries; add the two partition-purity rules: appends crossing a partition boundary close the segment (new segment, old as base); copy-on-diverge caps at partition boundaries and chains instead of copying; base-in-retired-partition = boundary, lazy truncation. `branch_meta` stays hot. -- **2.7 Forks** `[CARRY+]` — copy retained path/tree; normalize retained roots at retention boundaries; destination gets relevant inventory entries (critique 26). -- **2.8 Session and repository boundary** `[CARRY+]` — `storageVersion` in metadata; codec options; fork coordination; coding-agent v3-format normalization pointer. +## 2.1 Entries + +An **entry** is the complete stored row (§1.1): placement fields and payload together. What `getEntries` and the scans return is exactly what was committed — there is no materialization step and no join. + +```ts +interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; + terminate?: true } +interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; + retainedTail: AgentMessage[]; tokensBefore: number; + details?: JsonValue; usage?: Usage; fromHook: boolean } +interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; + summary: string; details?: JsonValue; + usage?: Usage; fromHook: boolean } +interface CustomEntry extends EntryBase { type: "custom"; customType: string; data?: JsonValue } + +type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; +``` + +Rules: + +- `type` and `customType` are structural fields: branch queries filter on them and the branch index denormalizes them (§2.6). `customType` is set exactly on custom entries; payload fields never drive structure. +- Assistant entries always contain a `SettledAssistantMessage`. Reject `pending` before writing. +- Tool-result entries carry `terminate?: true`. It is orchestration state that `ToolResultMessage` has no field for. +- Every compaction and branch summary carries `fromHook`: `true` for hook output, `false` for generated. +- Every compaction stores a complete `retainedTail` (`[]` when empty). **Context never reads past a compaction.** This is what makes a compaction a self-contained checkpoint rather than a pointer into history. +- A custom entry may carry no `data`. There is no payload-compatibility table to check: an entry either decodes against its type's runtime schema or is corruption. +- Payloads are inline, so two entries never share stored content; there is no deduplication layer. + +## 2.2 Placement + +The tree's central rule: + +> An **entry** is created, complete, when placement happens. Content that is durable *before* placement is current mutable state and waits in a `pending.entry` register; the placement transaction writes the entry and deletes the register. Neither is ever modified after that. + +Three cases, all mechanical: + +**Born placed** — assistant responses, tool results, direct appends to an idle lane. Content and placement arrive together; one transaction: + +``` +TX[ insert e_a4 = { parent: e_q1, type: "message", message: }, + upsert lane.leaf/main = "e_a4" ] +``` + +**Content first, placement later** — queued input (`steer`, `followUp`, `nextRun`) and deferred tree writes. The entry id is minted at enqueue and doubles as the register key; queue state references content by that one id — the old `{ nodeId, valueId }` pair collapses to a single string. Two transactions, possibly far apart: + +``` +t0 TX[ upsert pending.entry/e_q1 = { type: "message", payload: <200KB message> }, + S(next){ ...inbox.steer += "e_q1" } ] + +t1 TX[ insert e_q1 = { parent: e_a3, type: "message", message: }, + delete pending.entry/e_q1, + upsert lane.leaf/main = "e_q1", + S(next){ ...inbox.steer -= "e_q1" } ] +``` + +The register dies in the transaction that places the entry. Crash before `t1`: the item is still queued. Crash after: it is placed and the register is gone. **There is no third state** — until placement or cancellation, exactly one of register and entry exists at every commit boundary, never both and never neither. Cancellation is the other exit: `cancelQueued` deletes the register, and the content is simply gone, never having touched the tree (§3.11). Because the id was minted at enqueue, a late-placed entry lands in the partition of its mint date (§1.2). + +**Id reserved before content exists** — assistant responses and tool results. The reserved id is a plain minted string inside `op.state`; no register and no row exist until settlement inserts the complete entry. Reserving costs nothing. + +These are the **two reservation regimes**: settlement-family ids (responses, tool results, usage rows) are strings in operation state; queued-content ids are `pending.entry` registers. "A reserved id is just a string" is true only of the first family. + +Consequences to rely on: + +- A pending item is **invisible to tree queries** (no entry) but **visible in snapshots**: the owning state lists its id, and the payload is dereferenced from its register. +- "Has this been placed yet?" is answered by the owning queue list and the register's existence — never by the absence of an entry. +- The double write is the model's one deliberate redundancy (§1.8). SQLite and Postgres can implement placement as `INSERT … SELECT` from the register row inside the placement transaction; in JSONL both copies persist as bytes until snapshot compaction (§1.7). Only queued items pay it; settlement never does. + +## 2.3 Lanes + +A configured lane is three registers — plus `lane.lastResult` once its first operation has ended (§3.13). Fresh or normalized-v3 `main` may temporarily lack `lane.config` until first harness attachment: + +``` +lane.leaf/{name} = entry id or null +lane.config/{name} = LaneConfiguration // absent only for unconfigured main +lane.state/{name} = LaneState +``` + +```ts +interface LaneConfiguration { + model: { provider: string; modelId: string }; + thinkingLevel: ThinkingLevel; + activeToolNames: string[]; +} +``` + +- A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that entry), or the lane navigates (leaf jumps to an existing entry). +- `LaneConfiguration` is **total**. A setter overwrites the whole register; it is never a patch and never a tree entry. +- Creating a lane copies no tree content, no history, and no configuration from its anchor: + +``` +TX[ upsert lane.config/{name} = , + upsert lane.leaf/{name} = anchorEntryId, + upsert lane.state/{name} = { currentOperationId: null, pendingNextRun: [] } ] +``` + +- Lanes are never deleted or renamed. Names are permanent application keys. +- `main` exists in every session. +- Two lanes at the same leaf simply diverge on their next append. + +## 2.4 Facts + +Session-scoped, latest-wins, not part of the tree. + +``` +fact.name/"" = string +fact.label/{entryId} = string +fact.custom/{key} = JsonValue +``` + +Setting a fact to `undefined` deletes its register — real deletion, not a tombstone; deleting an unset fact is a no-op (§1.4). JSON `null` is a legitimate custom value, stored directly, and is distinguishable from deletion because the register itself exists or does not. The built-in and custom namespaces never overlap. Fact writes commit immediately and never move a leaf. + +## 2.5 Branch queries and context + +```ts +interface BranchScan { + start?: string; // default: the view's lane leaf + stopAtType?: EntryType; // scan ends after the first match, inclusive + stopAtId?: string; + type?: EntryType; + customType?: string; + order?: "newestFirst" | "oldestFirst"; // default newestFirst + limit?: number; + cursor?: EntryCursor; +} +type EntryCursor = { seq: number }; +``` + +Semantics: take the path from `start` toward the root, order it (default `newestFirst`), stop **inclusively** at the first `stopAt` match, filter by `type`/`customType`, apply the exclusive cursor, then apply `limit`. For `newestFirst`, a cursor retains `seq < cursor.seq`; for `oldestFirst`, it retains `seq > cursor.seq`. A `stopAt` entry is returned only if it also passes the filter. + +**Retention boundaries.** On a backend with retired partitions, a scan that reaches an entry whose `parentId` decodes to a retired period stops there cleanly, as if at a root (§1.2). The stop must be explicit at public surfaces: branch finders report a truncation marker — `truncatedAt: { parentId }`, the partition being the id's own time prefix — never a silently short result, because extension-state lookups walk past compactions by design (§5.3) and must distinguish "never set" from "expired". Storage itself needs no extra channel: the marker derives from the last returned entry's `parentId`. The three shipping backends never truncate. + +**Context projection** — how a provider request is built: + +1. `scanBranch({ start: leaf, order: "newestFirst", stopAtType: "compaction" })`. +2. Reverse to oldest-first. If a compaction terminated the scan, the context is: its `summary`, then its `retainedTail`, then every entry after it. **Nothing earlier is read.** +3. Drop assistant responses whose stop reason is `error`, `aborted`, or `deferred`. Retain genuine output-limit `length`. +4. Run custom entries through `entryProjectors`. An unprojected custom entry never enters context. +5. Run `transform_context`, then `toProviderMessages`. + +There is no rule for omitting an overflow response, and no link anywhere pointing at one. An overflow response is committed with stop reason `error` (§3.7) and is therefore dropped by rule 3 like any other error, and by any downstream `transformMessages` that filters the same way. + +**Append-only context invariant.** Across the requests of one lane, provider context must only grow at the tail. An insertion before the previous request's tail invalidates the provider's KV cache and multiplies cost. This is *why* mid-run writes defer to checkpoints, where they append at the tail. Compaction is the one deliberate cache invalidation, and it trades that for a smaller context. + +## 2.6 The branch index + +Memory and JSONL walk parent pointers in RAM. SQLite — and the future Postgres backend — maintain a private segmented branch cache so a diverging append does not copy an unbounded root prefix. + +`branch_entries` stores the entries physically present in one segment. `branch_meta` stores its tip and optional `{ baseBranchId, baseSeq }`. A segment logically contains its own rows above `baseSeq` plus the referenced base prefix through `baseSeq`. + +Append: + +1. If a branch tip equals the lane leaf, append one row and move that tip. +2. Otherwise resolve a branch that actually covers the leaf, find the newest compaction at or below the leaf through the complete segment chain, copy only rows after that compaction through the leaf, and set the older prefix as the new segment's base. +3. Append the new entry and make it the new segment tip. + +Read newest segment first. If the requested range crosses `baseSeq`, continue through the base chain with the upper bound capped at that boundary. Merge segment results into the requested order before filtering/limiting. + +Two correctness rules are mandatory: + +- The base branch must itself cover the leaf within its logical range; merely containing the leaf in an ancestor is insufficient. +- The newest compaction search must traverse the base chain; checking only the newest physical segment can miss it. + +**Partition purity** — two additional rules on a partitioned backend, vacuous on SQLite: + +- **Append rule.** Appending an entry whose partition differs from the current segment's closes the segment: the old segment becomes the base of a fresh one. Segments are single-partition by construction, so index rows live in the same partition as the entries they index and die with it (§1.7). +- **Diverge rule.** Copy-on-diverge caps at partition boundaries. Never copy older-partition index rows forward into a newer segment; chain a base reference into the older partition's own segments instead — otherwise new partitions accumulate rows referencing droppable ones, and a drop silently gaps retained scans. + +Traversal stepping into a base whose partition is retired is a retention boundary (§2.5): terminate the scan and report it. Truncate the chain lazily on first access; no eager `branch_meta` rebuild happens at drop time. `branch_meta` — tips and base pointers, hot, mutable, globally unique — always stays in the unpartitioned catalog. + +```text +S1 (2027-01): e1…e19 ←base─ S2 (2027-02): e20…e29 ←base─ S3 (2027-03): e30…e42 +drop 2027-01 → a scan via S3→S2 stops after e20 and reports the boundary; S2/S3 untouched +``` + +The cache must preserve: + +- following a segment chain yields the exact root path with no gaps or duplicates — up to a retention boundary, where it stops cleanly; +- all chains containing an entry agree below it; +- runtime reads never fall back to a table scan or parent walk; +- stale branches remain valid cache history; +- only an explicit repair operation rebuilds the cache from entries. + +Tests assert these invariants and the required query plans. No wall-clock threshold is normative. + +## 2.7 Forks + +A fork is a repository operation over one coherent source-session snapshot. It copies selected entries, latest facts, lane leaves, and total configuration; it never copies `op.*`, `pending.entry`, or `lane.lastResult` registers or ledger rows — destination lanes start with a fresh empty `LaneState`. + +```ts +type ForkOptions = + | { scope?: "branch"; entryId?: string; position?: "before" | "at" } + | { scope: "tree" }; +``` + +- Memory and JSONL obtain the snapshot as one job on the source storage queue. SQLite uses one read transaction. +- Branch scope copies one path and creates only destination `main`. Tree scope copies the whole tree and every lane leaf/configuration. +- The destination is idle and its token/cost ledger starts at zero. Entry-local display usage remains on copied entries. +- Facts follow the selected scope: name/custom facts always copy; labels copy only when their target copies unless tree scope copies all targets. +- Any message may be the fork point. Request construction heals orphaned tool calls. +- Copied entries keep their ids, so they keep their partitions. Where the source path crosses a retention boundary, the copy stops there exactly as a scan does (§2.5): the boundary entry becomes a retained root in the destination, keeping its original `parentId`. How a fork destination classifies the dangling references it inherits — including on backends that never retire periods themselves — is defined with the rest of the retired-boundary semantics in Part 6. +- The destination metadata records `parentSessionId`. + +A source with only fresh/unconfigured `main`—new format 4 or read-only normalized v3—may have no configuration. Either fork scope then creates one unconfigured destination `main`, which first harness attachment seeds normally. Every configured format-4 lane copied by a fork keeps its current total configuration. + +## 2.8 Session and repository boundary + +`Storage` is deliberately one-session only. `Session` supplies typed validation, lane-bound views, and typed entry/register decoding. `SessionRepo` owns discovery and storage-instance lifecycle: + +```ts +interface SessionMetadata { + id: string; + createdAt: number; + /** Current storage schema version (Part 7). */ + storageVersion: number; + parentSessionId?: string; + /** Only when a v3 parent path cannot be resolved to an available header id. */ + legacyParentSessionPath?: string; +} + +interface SessionCodecOptions { + /** Built-in provider-message roles are registered by default. */ + customMessageSchemas?: Record; // keyed by custom `role` +} + +interface SessionSearchOptions { text: string; cwd?: string } +interface SessionSearchHit { + metadata: M; entryId: string; timestamp: string; snippet?: string; score?: number; +} +interface SessionSearch { + search(options: SessionSearchOptions): Promise[]>; +} + +interface SessionRepo { + create(options: C): Promise>; + open(metadata: M): Promise>; + list(options?: L): Promise; + delete(metadata: M): Promise; + fork(source: M, options: ForkOptions & C): Promise>; +} + +interface Session extends SessionTree { + readonly metadata: M; + /** Mints UUIDv7 ids; a supplied timestamp mints a follower id (§1.2). */ + readonly idGenerator: { next(timestampMs?: number): string }; + view(lane: string): SessionTree; + + /** Package-internal harness substrate; validates before delegating to Storage. */ + commit(tx: Transaction): Promise; + getEntries(ids: string[]): Promise>; + getRegister(namespace: N, key: string): + Promise | undefined>; + listRegisters(namespace: N): Promise[]>; + + close(): Promise; +} +``` + +Repository constructors accept `SessionCodecOptions`. Every declaration-merged custom `AgentMessage` must have a string `role` and a registered runtime schema; unknown custom roles are rejected before persistence and on decode. A new repository session creates `main` with null leaf and an empty `LaneState`, but no configuration; first harness attachment writes its seed configuration. + +`open()` compares the stored `storageVersion` with the binary's: equal proceeds; older runs chained migrations under the writer lease before returning (Part 7); newer refuses to open. Old coding-agent v3 JSONL sessions open through the same repository and normalize on load (Appendix C — "v3" there names the legacy JSONL session format, not this document). + +Repository implementations resolve `fork(source, ...)` to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read transaction. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session `Storage` contract. # Part 3 — The operation state machine From dea1b248f6e9ab5536ed46420b1755f4db4d4813 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 01:01:03 +0200 Subject: [PATCH 087/284] docs(agent): write harness-v3 part 3 --- packages/agent/docs/harness-v3.md | 576 +++++++++++++++++++++++++++++- 1 file changed, 558 insertions(+), 18 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index e8078882330..c5012700e3e 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -278,7 +278,8 @@ type RegisterNamespace = keyof RegisterValues; interface PendingEntry { type: "message" | "custom"; customType?: string; - payload: JsonValue; // the content that becomes the entry's payload + payload?: JsonValue; // the content that becomes the entry's payload; + // absent = a custom entry with no data } interface DurableFileOperations { @@ -316,7 +317,7 @@ op.* operation-lived; deleted by the terminal transaction (§3.13) pending.entry lives until its content is placed or cancelled ``` -- `op.meta`, `op.tool_args`, and `op.preparation` keys are written exactly once and then deleted at the terminal transaction; only `op.state` is overwritten during the operation. +- `op.meta` and `op.preparation` keys are written exactly once; `op.tool_args` keys are written once per key, keyed by the producing step so batches never collide. All are deleted no later than the terminal transaction; only `op.state` is overwritten during the operation. - Operation-owned `pending.entry` registers still unconsumed at the end (remaining inbox items and abort-drained items) are deleted by the terminal transaction — a consumed item's register dies in its placement transaction; lane-owned ones (`pendingNextRun`) outlive operations and die when consumed or cancelled (§3.11). - `lane.lastResult` is written only by terminal transactions and overwritten by the next one on its lane — one bounded register per lane, forever. Recovery never reads it; it exists so an application that accepted an operation, crashed, and reopened can still learn its outcome (§3.13). - Deleting a fact removes its register. Storing JSON `null` in `fact.custom` is a different, legal state; there are no tombstones. @@ -397,7 +398,7 @@ Every settled provider attempt writes one `UsageRow` — successful, failed, ret - `entryId` names the entry the cost belongs to, when there is one. Structural (summary) attempts that fail before producing an entry, and standalone adjustments, have none. - `adjustment: true` marks a caller-supplied reconciliation (`recordUsage`, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix C). -- Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows and import aggregates mint their ids at commit; nothing reserves them. +- Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, and import aggregates mint their ids at commit; nothing reserves them. - `getStats()` is a maintained projection over the ledger and the entry count. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). There is no ledger scan: totals come from the projection, and individual rows reach the application through the `usage` event at commit time (§5.5). ## 1.7 Backends @@ -843,21 +844,560 @@ Repository implementations resolve `fork(source, ...)` to the source's serialize # Part 3 — The operation state machine -Semantics carry from base Part 3 wholesale; the representation changes (jot 3, 8): - -- **3.1 Operations** `[CARRY+]` — `op.meta` register; `promptEntryIds`. -- **3.2 Operation state** `[REWRITE repr]` — inline `LaneConfiguration`/streamOptions/retryPolicy in generation/deferred/batch/summary contexts; tool args and preparation via deterministic `op.*` register keys; queue items are plain entry-id lists; **no `FinishedState`** in the union. -- **3.3 Lane state and validity** `[CARRY+]` — validation checks registers/entries; no value checks. -- **3.4 Atomic transition rule** `[CARRY]` -- **3.5 The graph** `[CARRY]` — terminal node becomes "terminal TX" rather than a state. -- **3.6 Acceptance** `[REWRITE TX tables]` — pending-capture placement; reservation admission for compact/summarized-nav carries. -- **3.7 Assistant generation** `[REWRITE TX tables]` — classifier table, overflow rules, worked example carry verbatim; transactions re-expressed as entry inserts + register upserts. -- **3.8 Tools** `[REWRITE TX tables]` — `op.tool_args` register at clearance; batch semantics carry. -- **3.9 Summary generation** `[REWRITE TX tables]` — `op.preparation` register; decision/generation machinery carries. -- **3.10 Navigation** `[CARRY+]` — one-TX completion; terminal writes per 3.13. -- **3.11 Inbox, queues, deferred writes** `[REWRITE: jot 2, 9]` — pending registers; `cancelQueued` triage without dispositions: pending → `cancelled`; entry exists → `already_consumed`; else → `not_found`. Abort drains keep pending registers alive until terminal. -- **3.12 The checkpoint procedure** `[CARRY]` -- **3.13 Terminal transactions** `[NEW: jot 3]` — delete `op.meta`/`op.state`/`op.tool_args/*`/`op.preparation/*` and operation-owned pending registers (`inbox.* ∪ control.drained*`, never `pendingNextRun`); upsert `lane.lastResult`; clear `lane.state.currentOperationId`; result computed pre-commit; observation contract (live promise + lastResult until next terminal TX). +## 3.1 Operations + +```ts +interface Operation { + operationId: string; + lane: string; + sourceLeafId: string | null; + startedAt: number; + intent: + | { kind: "run"; promptEntryIds: string[]; + systemPromptOverride?: string; resumeData?: Record } + | { kind: "compaction"; customInstructions?: string } + | { kind: "navigation"; targetId: string | null; summarize: boolean; + label?: string; customInstructions?: string }; +} +``` + +Acceptance data lives in the `op.meta/{operationId}` register: written once at acceptance, never overwritten, and deleted by the terminal transaction (§3.13). `sourceLeafId` is the lane's leaf *before* the operation; entries the operation itself appends come after it. `promptEntryIds` name the caller's normalized prompt entries, born placed in the acceptance transaction (§3.6). + +## 3.2 Operation state — the program counter + +`op.state/{operationId}` holds one total `OperationState` directly. Every transition overwrites the whole register; the terminal transaction deletes it (§3.13). There is no finished member of the union — an ended operation has no state at all, and its outcome lives in `lane.lastResult`. + +```ts +type OperationState = RunState | CompactionState | NavigationState; + +type Control = + | { status: "running" } + | { status: "cancel_requested"; requestedAt: number; + /** Drained queue ids. Their pending.entry registers survive the drain + and are deleted only by the terminal transaction (§3.11, §3.13). */ + drainedSteer: string[]; drainedFollowUp: string[] }; + +interface RunState { + kind: "run"; + control: Control; + /** Captured atomically at acceptance; setters affect later operations. */ + settings: { + compaction: CompactionSettings; + steeringMode: QueueMode; + followUpMode: QueueMode; + toolExecution: "sequential" | "parallel"; + }; + phase: RunPhase; + inbox: Inbox; + /** Newest durable assistant generation/fetch response in this operation. */ + latestAssistantEntryId: string | null; +} + +interface CheckpointPhase { + kind: "checkpoint"; + continuation: Continuation; + /** Durable correlation source for the next generation step. */ + triggerEntryId: string; + /** Threshold compaction is attempted at most once per trigger boundary. */ + thresholdCheckedTriggerEntryId?: string; + /** Generate before draining another queued input after one-at-a-time drain. */ + skipInboxOnce?: boolean; +} + +type RunPhase = + | CheckpointPhase + | { kind: "assistant"; generation: Generation } + | { kind: "tools"; batch: ToolBatch } + | { kind: "compaction"; reason: "threshold" | "overflow"; + structural: StructuralDecision; resumeAfter: CheckpointPhase } + | { kind: "deferred"; deferred: Deferred } + | { kind: "failure_drain"; error: OperationError; provenance: + | { kind: "response"; entryId: string } + | { kind: "structural"; taskId: string } }; + +type Continuation = + | { kind: "need_assistant"; overflowRecoveryUsed: boolean } + | { kind: "may_finish"; includeFinalAssistant: boolean }; + +interface Inbox { + /** Reserved entry ids. Payloads — and, for writes, the entry type and + customType — live in each id's pending.entry register (§1.3, §2.2). */ + steer: string[]; + followUp: string[]; + writes: string[]; +} + +interface OperationError { code: string; message: string; details?: JsonValue } +``` + +The old `QueuedInput { nodeId, valueId }` and `PendingWrite` pairs are gone: a queue item is one entry id, and everything else about it — payload, write type, `customType` — is dereferenced from its `pending.entry` register. + +`latestAssistantEntryId` updates in the same settlement transaction as every assistant generation or deferred-fetch response. It lets finish and resume construct results/events without a branch scan. A tool batch retains its producing turn id while tool work remains active. + +Any transition that appends conversational input or tool results and requires another assistant writes a checkpoint with `need_assistant(false)` and the appended entry as `triggerEntryId`. An unprojected custom write preserves the current checkpoint, including trigger and overflow flag. Entering threshold compaction first copies the checkpoint to `resumeAfter` with `thresholdCheckedTriggerEntryId = triggerEntryId`; decline, empty preparation, success, and crash therefore cannot recheck the same boundary. + +### Generation + +```ts +interface NormalizedRetryPolicy { maxAttempts: number; baseDelayMs: number } + +interface GenerationContext { + stepId: string; + triggerEntryId: string; + /** Inline snapshot of the lane configuration at step start. */ + configuration: LaneConfiguration; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; +} + +type Generation = + | { status: "ready"; context: GenerationContext; nextAttempt: number } + | { status: "effect_pending"; context: GenerationContext; attempt: number; + responseEntryId: string; usageId: string; + intendedOutputLimit: number; contextWindow: number } + | { status: "retry_wait"; context: GenerationContext; nextAttempt: number; + notBefore: number; errorMessage: string }; +``` + +The context snapshots configuration, stream options, and retry policy **inline** — there is no configuration value to point at, and `LaneConfiguration` is small. Recovery can therefore report exactly what is missing without resolving anything (§4.4). For each attempt, `before_request` runs from generation `ready` (an elapsed retry wait first returns to `ready`). Its curated patch is composed with the context's captured base stream options, then `intendedOutputLimit` and `contextWindow` are calculated and persisted in the `effect_pending` intent before dispatch. A pre-intent crash may rerun the hook. Harness-owned `before_payload`/`after_response` callbacks are mounted only after intent and cannot be replaced through stream options. + +### Tool batch + +```ts +interface ToolBatch { + assistantEntryId: string; + /** Producing generation/fetch snapshot; active tool names come from here. */ + configuration: LaneConfiguration; + /** The assistant generation step id; recovered tool events use it as turnId. */ + turnId: string; + calls: ToolCall[]; +} + +type ToolCall = + | { status: "planned"; sourceIndex: number; resultEntryId: string } + | { status: "effect_pending"; sourceIndex: number; resultEntryId: string; + replay: "never" | "safe" } + | { status: "completed"; sourceIndex: number; resultEntryId: string; + terminate: boolean }; +``` + +The source call comes from `assistantEntryId` plus `sourceIndex`; large effective arguments live once in the `op.tool_args/{operationId}:{stepId}:{sourceIndex}` register — the producing generation's `stepId` disambiguates batches across turns — written at clearance (§3.8) and located by that deterministic key — the state carries no per-call argument reference. Persist them unconditionally because `prepareArguments`, not only `before_tool`, may change them. Parallel calls may be effect-pending together; result entries commit in source order. + +### Deferred + +```ts +type Deferred = + | { status: "suspended"; stepId: string; sourceEntryId: string; poll: number; + configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions } + | { status: "effect_pending"; stepId: string; sourceEntryId: string; poll: number; + responseEntryId: string; usageId: string; + configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions }; +``` + +One `resume()` performs at most one `fetchDeferred(handle, { wait: 0 })`. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A pending response must have a completely equal handle and becomes the next source. A mismatched pending handle is normalized to a durable `error` response explaining the mismatch; response, usage, `latestAssistantEntryId`, and response-provenance `failure_drain` commit atomically. + +### Structural work + +```ts +type StructuralDecision = { taskId: string } & ( + | { status: "deciding" } + | { status: "generating"; generation: SummaryGeneration } +); + +interface SummaryContext { + taskId: string; + resultEntryId: string; + kind: "compaction" | "branch_summary"; + configuration: LaneConfiguration; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; + reason?: "manual" | "threshold" | "overflow"; +} + +type SummaryGeneration = + | { status: "ready"; context: SummaryContext; nextAttempt: number } + | { status: "effect_pending"; context: SummaryContext; attempt: number; + /** Current nested request intent; absent between requests. */ + request?: { index: number; usageId: string }; + usageIds: string[] } + | { status: "retry_wait"; context: SummaryContext; nextAttempt: number; + notBefore: number; errorMessage: string }; + +interface CompactionState { + kind: "compaction"; + control: Control; + customInstructions?: string; + structural: StructuralDecision; +} + +type NavigationState = + | { kind: "navigation"; control: Control; targetId: string | null; label?: string; + summarize: false; phase: { kind: "ready_to_commit" } } + | { kind: "navigation"; control: Control; targetId: string; label?: string; + customInstructions?: string; summarize: true; + phase: { kind: "summary"; structural: StructuralDecision } }; +``` + +Structural preparation is built from the reserved source leaf and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once to the `op.preparation/{operationId}:{taskId}` register before the decision hook, in the same transaction as the `deciding` state (§3.9). State carries only `taskId`; the deterministic key locates the register, and hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. + +One structural attempt may make one or two provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageId}`, then performs that provider request through a nested Effects action, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any restored `effect_pending` attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. A durable `generating` decision prevents its decision hook from rerunning. + +## 3.3 Lane state and current-state validity + +```ts +interface LaneState { + currentOperationId: string | null; + /** Reserved entry ids; payloads in pending.entry registers (§2.2). */ + pendingNextRun: string[]; +} +``` + +Restore validates only the current lane and operation registers and the entries/registers they directly name; there is no history to audit and none exists. Required checks: + +- `lane.state/{lane}` holds a `LaneState`; when it names operation O, `op.meta/O` holds an `Operation` for that lane, and `op.state/O` holds an `OperationState` compatible with O's intent kind; +- every entry id the current state names — trigger, latest assistant, batch assistant, deferred source, completed results, prompt entries, the lane leaf — resolves to an existing entry of the expected type; +- reserved response/result/usage ids, if materialized, contain the intended kind and identity; an unmaterialized reserved id resolves to nothing, which is the expected pre-settlement condition, never an error; +- every id in `inbox.*`, `control.drained*`, and `pendingNextRun` has a `pending.entry` register with a valid payload; every effect-pending call has its `op.tool_args` register; every structural decision has its `op.preparation` register; +- tool source indices are complete, ordered, unique, in range, and use unique result ids; completed result entries match their source calls; +- cancellation, navigation source/target, and structural-source combinations satisfy the state discriminants. + +Runtime schemas validate every decoded register value before publication. `lane.lastResult` is validated on its public read path — outcome/error/`runCompletion` combinations must be legal for the operation kind, and a completed run omits its final assistant only with `runCompletion: "terminated_tools"` — but it is never a recovery input (§3.13). These bounded checks reject corrupted/imported state that TypeScript transition functions could not have produced. + +## 3.4 The atomic transition rule + +> Compute the next total state in memory, then atomically commit every entry insert, usage insert, and register write that makes that state true. + +A transaction writing total `LaneState` rereads the latest register value inside the lane mutation line and changes only the fields owned by that transition. In particular, the terminal transaction clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. Conditional transitions identify the state they extend by register `seq` — the `op.state` seq, the `lane.state` seq, and, where a transition snapshots configuration, the expected `lane.config` seq (§4.1) — never by a value id; the CAS token changed, the linearization did not. Every edge below is exactly one `commit()`. + +## 3.5 The graph + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> checkpoint : prompt() accepted + + checkpoint --> assistant : continuation = need_assistant + checkpoint --> compaction : context threshold + checkpoint --> checkpoint : apply write / consume steer / consume follow-up + checkpoint --> terminal : may_finish + empty inbox + + assistant --> assistant : retryable error (retry_wait) + assistant --> tools : toolUse + assistant --> compaction : overflow (first time) + assistant --> deferred : stopReason deferred + assistant --> checkpoint : stop / genuine length + assistant --> failure_drain : terminal error / retries exhausted / 2nd overflow + + tools --> tools : per-call intent + settlement + tools --> checkpoint : batch complete + + compaction --> checkpoint : resumeAfter restored + compaction --> failure_drain : overflow compaction declined or failed + + deferred --> deferred : poll returns pending + deferred --> tools : ready response with calls + deferred --> checkpoint : ready response without calls + deferred --> failure_drain : provider error + + failure_drain --> checkpoint : new user-context input applied + failure_drain --> terminal : inbox drained (failed) + + checkpoint --> terminal : abort reconciled (aborted) + terminal --> [*] +``` + +`terminal` is not a state. It is the terminal transaction (§3.13): after it commits, the operation has no `op.state` register at all. + +Standalone operations: + +``` +compaction: deciding ──hook declines───────────→ terminal TX (declined) + ──hook supplies result────→ terminal TX (completed) + ──hook selects generation─→ generating ──→ terminal TX (completed|failed) + +navigation: ready_to_commit ───────────────────→ terminal TX (completed) + summary.deciding ──→ generating ───→ terminal TX (completed) +``` + +## 3.6 Acceptance + +| From | Trigger | Transaction | +|---|---|---| +| idle lane | `prompt()` after `before_run` | `TX[ insert entries for captured nextRun items (payloads from their pending.entry registers) and the new messages (caller prompt, hook injections) in order, delete the captured pending.entry registers, upsert lane.leaf = newest entry, upsert op.meta/O, S(run{captured settings, checkpoint need_assistant(false), trigger = newest entry, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured ids removed from pendingNextRun}) ]` | +| reserved idle lane | `compact()` with non-empty preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(compaction{deciding, taskId}), L({currentOperationId: O}) ]` | +| idle lane | unsummarized `navigateTree()` after validation | `TX[ upsert op.meta/O, S(navigation{ready_to_commit}), L ]` | +| reserved idle lane | summarized `navigateTree()` with preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(navigation{summary.deciding, taskId}), L ]` | + +Captured `nextRun` items already have their payloads in `pending.entry` registers; acceptance inserts their entries from those payloads, deletes the registers, and removes the ids from `pendingNextRun` — the placement half of the one deliberate double write (§1.8). A late-captured item keeps its enqueue-minted id and lands in that id's partition (§1.2). + +Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. + +Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, or summarize from root), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve). Prompt allocates its operation id before `before_run` so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists. + +**Acceptance must observe `currentOperationId === null`.** Because acceptance is on the lane mutation line, this is validation, not compare-and-swap. + +## 3.7 Assistant generation + +| From | Trigger | Transaction | To | +|---|---|---|---| +| checkpoint `need_assistant` | drive | conditionally snapshot current lane config, stream options, and normalized retry policy inline into the context in `TX[ S(assistant{ready, nextAttempt:1}) ]` | ready | +| assistant `ready` | `before_request` aggregate completes | mint R and U, then `TX[ S(assistant{effect_pending, attempt=nextAttempt, responseEntryId R, usageId U, intendedOutputLimit, contextWindow}) ]` | effect_pending | +| effect_pending | settles with tool calls | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, tools{plan with reserved result ids}) ]` | tools | +| effect_pending | retryable error, attempts remain | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]` | retry_wait | +| effect_pending | first overflow, preparation non-empty | `TX[ insert response entry R **normalized to error**, upsert lane.leaf = R, insert usage U, upsert op.preparation/O:{taskId} = P, S(latestAssistantEntryId=R, compaction{reason:overflow, structural:{deciding, taskId}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]` | compaction | +| effect_pending | first overflow, preparation empty | `TX[ insert normalized response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| effect_pending | `stopReason: "deferred"` | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, deferred{suspended, sourceEntryId R, poll 0, configuration/options copied}) ]` | deferred | +| effect_pending | `stop` or genuine `length` | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | +| effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | +| retry_wait | `notBefore` elapsed | `TX[ S(assistant{ready, nextAttempt:k+1}) ]` | ready | + +**There is never a durable "response without usage" or "response and usage without a decision."** All three land together or none do. `R` and `U` are minted at intent and exist only as strings in the state until settlement inserts the complete rows (§2.2). A settlement that plans tools mints each `resultEntryId` as a follower of `R`, inheriting its 48-bit timestamp (§1.2), so the assistant and its results share a partition by construction. + +### Classification order + +Pure, computed in memory before the settlement transaction. First match wins. + +| Condition | Result | +|---|---| +| `control.status === "cancel_requested"` | normalize stop reason to `aborted`; commit `checkpoint{may_finish, includeFinalAssistant:true}` under cancelled control, then reconcile writes/finish | +| overflow: adapter-reported, or `error` whose message matches the context-limit patterns, or `length` with output below `intendedOutputLimit` | **normalize stop reason to `error`**; compact (first time) or `failure_drain` (second) | +| `deferred` with a valid handle | deferred suspended | +| retryable `error`, attempts remain / otherwise | retry_wait / failure_drain | +| `toolUse`, or an accepted response carrying calls | tools | +| `stop` or genuine output-limit `length` | checkpoint `may_finish` | + +Two normalizations happen at commit, and both are deliberate. A cancelled response commits as `aborted`. An overflow-classified response commits as `error`. In both cases the original stop reason is overwritten and the reason is preserved in human-readable form in `errorMessage`. + +The overflow normalization is what removes every link from this design. Because the committed response is `error`, §2.5 rule 3 drops it from context automatically — no superseded-response id on the compaction, none in the operation state, and no omission rule of its own. The response stays in the tree as durable history, because a provider request happened and was billed. + +**Overflow detection is a heuristic and must be labelled as one.** Three sources, in decreasing reliability: + +1. **Adapter-reported.** A provider adapter that can compute `usage.input + usage.cacheRead > contextWindow` at settlement sets `stopReason: "error"` with a message matching the context-limit patterns. This requires no new stop reason and no change to any adapter's stop-reason mapping, which matters because those mappings typically throw on unknown values. An adapter doing this should also require negligible output, so a substantive answer that merely trips a counter is not discarded. +2. **Error-message matching.** Providers usually return a context-limit failure as an HTTP error, which arrives as `error` with a message. Matching it is string matching, and it is brittle wherever it lives. +3. **`length` below `intendedOutputLimit`.** Harness-side only. An adapter must not apply this rule, because it cannot distinguish an oversized request from a response truncated mid-thinking — and those need opposite treatment, since a genuine truncation must stay in context. + +Overflow is checked before retryable error, so an oversized request compacts rather than retrying unchanged. + +**`aborted` is not a classification input.** It means the harness's own abort signal fired (§4.6), and `abort()` commits `control` before signalling — so a settled `aborted` response always has `control.status === "cancel_requested"` and is caught by the first row. An `aborted` response with `control.status === "running"` is unreachable and is corruption (Part 9). + +An overflow classification never produces a tool plan. A *genuine* `length` that carries tool calls does produce the full plan, executes nothing, and appends one `isError: true` result per call explaining that truncation may have corrupted the arguments — those results then require another assistant turn. + +## 3.8 Tools + +| From | Trigger | Transaction | To | +|---|---|---|---| +| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ upsert op.tool_args/O:{i} = effective args, S(call i = effect_pending, replay) ]` | dispatch | +| call *i* `effect_pending` | effect settled, `after_tool` applied | `TX[ insert result entry, upsert lane.leaf, insert tool usage row (if reported), S(call i = completed, terminate) ]` | tools or checkpoint | +| call *i* `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | `TX[ insert synthetic error result entry, upsert lane.leaf, S(call i = completed, terminate from an intentional block, otherwise false) ]` | tools | +| all calls completed | — | folded into the last settlement, which also deletes the batch's `op.tool_args/{O}:{stepId}:*` registers | checkpoint | + +The batch's completion transition is: + +- **every** completed call set `terminate: true` → `checkpoint{may_finish, includeFinalAssistant: false}` +- otherwise → `checkpoint{need_assistant(overflowRecoveryUsed: false)}` + +`terminate` exists so a tool can end the run without another provider turn. The motivating case is a "submit final result" tool used in place of structured output: the model calls it, the harness commits the result, and the run finishes with those tool results as its final entries — `run_end` then carries no `finalMessage`. Without this, every such run would pay for one more model turn whose only job is to stop. + +Modes: + +- **Sequential** (option, or any called tool declares `executionMode: "sequential"`): clear → intent → execute → finalize → commit, one call at a time. +- **Parallel** (default): clearance and intent commits happen in source order; dispatch does not await earlier calls; effects settle concurrently; phase 3, result-message lifecycle, and result commits are awaited and finalized in source order. + +Blocked and invalid calls skip the intent commit and the effect, but still commit a result at their source position. Their `op.tool_args` register is never written. + +Calls are tracked internally by `sourceIndex`. Hooks, events, and tool context see the provider `toolCallId` and tool name — never the index. + +## 3.9 Summary generation — compaction and navigation summaries + +Both operations generate a summary through the same `deciding → generating → result` machinery, which is why they are specified together. The axes: + +| | compaction | navigation | +|---|---|---| +| **standalone operation** | `lane.compact()` — reason `manual` | `lane.navigateTree(target)` | +| **phase inside a run** | reasons `threshold`, `overflow` | — | + +| reason | who asked | on hook decline | +|---|---|---| +| `manual` | the caller | operation finishes `declined` | +| `threshold` | context-size check at a checkpoint | back to the stored `resumeAfter` | +| `overflow` | a request that did not fit | `failure_drain` | + +"Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and the transition into `deciding` commit together (`upsert op.preparation/O:{taskId}` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never creates `StructuralDecision`: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. + +| From | Trigger | Transaction | +|---|---|---| +| deciding | hook declines | standalone: the terminal transaction (§3.13) with outcome `declined` · threshold: `TX[ S(restore marked resumeAfter) ]` · overflow: `TX[ S(failure_drain{error, provenance:structural taskId}) ]` | +| deciding | hook supplies compaction | standalone: `TX[ insert hook usage row?, insert compaction entry, upsert lane.leaf, terminal writes (§3.13) ]`; in-run: same result-publication writes plus `S(resumeAfter)` | +| deciding | hook supplies navigation summary | use §3.10's final transaction with the hook usage/result | +| deciding | hook selects generation | conditionally snapshot current config/policy inline in `TX[ S(generating{ready}) ]` — **the decision hook will never run again** | +| generating ready / retry elapsed | drive | `TX[ S(effect_pending, attempt k) ]` | +| generating effect_pending | one nested request returns | `TX[ insert usage row under request.usageId, S(effect_pending, request cleared, usageIds += id) ]`; commit another request intent before request two | +| generating effect_pending | retryable attempt outcome | usage is already durable; `TX[ S(retry_wait) ]` | +| generating effect_pending | terminal or attempts exhausted | standalone: the terminal transaction (§3.13) with outcome `failed` · in-run: `TX[ S(failure_drain{provenance:structural taskId}) ]` | +| generating effect_pending | compaction succeeded | standalone: `TX[ insert result entry, upsert lane.leaf, terminal writes (§3.13) ]`; in-run: result-publication writes plus `S(resumeAfter)` | + +Structural provider streams are internal: they emit **no** public assistant-message lifecycle. The existing summary generator is retained, but its one/two request callback uses the nested request intent/effect/usage boundaries from §3.2 and §4.2. Intermediate content is not persisted; a crash before the final transaction makes the whole attempt unknown, and a later numbered attempt starts only under the captured retry policy. Failed-attempt usage stays in the ledger regardless — terminal cleanup deletes registers, never ledger rows (§1.6). + +### Worked example — overflow + +`e_40` is a tool result awaiting an assistant turn. The request does not fit. + +``` +… e_38 ── e_39 ── e_40 phase: assistant, effect_pending + continuation was need_assistant(false) +``` + +**1. Settlement.** Classification says overflow. Preparation is built against the would-be branch; because the known response is normalized to `error`, ordinary projection excludes it. Response and preparation then commit together: + +``` +TX[ insert e_41 = { …assistant response, stopReason: "error", + errorMessage: "context window exceeded: …" }, + upsert lane.leaf/main = "e_41", insert usage u_41, + upsert op.preparation/op_9:t_1 = , + S(compaction{ reason: overflow, + structural: { deciding, taskId: "t_1" }, + resumeAfter: { checkpoint, triggerEntryId: "e_40", + continuation: need_assistant(true) } }) ] + +… e_38 ── e_39 ── e_40 ── e_41 +``` + +**2. Compaction.** The durable preparation was built by the ordinary rules in §2.5. `e_41` is an `error` response, so rule 3 dropped it — from the summary input and from `retainedTail` alike, with no special case: + +``` +… e_40 ── e_41 ── e_42 (compaction) + retainedTail: [e_39, e_40] ← e_41 absent by rule 3 +``` + +The tail ends on `e_40`, a tool result, which is the correct shape for a request that is about to ask for an assistant turn. + +**3. Resume.** `resumeAfter` restores `need_assistant(overflowRecoveryUsed: true)`. Context is now summary + tail + anything after `e_42`, which is small: + +``` +… e_41 ── e_42 ── e_43 the answer to e_40 + ✗ (error, out of context) +``` + +`e_41` remains in the tree forever as durable history — a request was made and billed. If the retry overflows *again*, `overflowRecoveryUsed` is already `true` and the run goes to `failure_drain` rather than compacting in a loop. Consuming new user input appends to the tree and resets the flag to `false`. + +## 3.10 Navigation + +Unsummarized and summarized both finish in **one** transaction — navigation's terminal transaction (§3.13) with its result-publication writes inline: + +``` +TX[ insert hook-reported usage row (only for a hook-supplied summary), + upsert lane.leaf = target, + insert summary entry with its display usage snapshot (when summarize; + parent is the target), + upsert lane.leaf = summary entry (when summarize), + upsert fact.label (when a label is present), + delete the operation's op.* registers, + upsert lane.lastResult = { kind: "navigation", outcome: "completed", leafId }, + L({ currentOperationId: null }) ] +``` + +Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary entry explicitly names the target as parent, and the following register write makes that summary the completed lane leaf. A crash sees either an untouched navigation still at its source, or a fully completed one. **No prepared-summary state and no post-move recovery state exist.** Abort before this transaction ends in an aborted terminal transaction with no entry appended; abort after it means the operation completed. + +## 3.11 Inbox, queues, deferred writes + +Every queued admission mints the item's entry id (§1.2) and writes its payload once into `pending.entry/{id}`; queue lists carry only the id. + +| Public input | Admitted when | Transaction | +|---|---|---| +| `nextRun(msg)` | any state, including idle | `TX[ upsert pending.entry/{id} = payload, L(pendingNextRun += id) ]` — never starts a run | +| `steer(msg)` | active running run | `TX[ upsert pending.entry/{id} = payload, S(inbox.steer += id) ]` | +| `followUp(msg)` | active running run | `TX[ upsert pending.entry/{id} = payload, S(inbox.followUp += id) ]` | +| tree write, run active | including suspended and cancelling | `TX[ upsert pending.entry/{id} = payload, S(inbox.writes += id) ]` — survives abort | +| tree write, lane idle | idle | `TX[ insert entry, upsert lane.leaf ]` | +| tree write, structural op open | — | wait for the operation to end, then re-evaluate | +| `cancelQueued(id)` | item still pending | `TX[ S or L with the id removed, delete pending.entry/{id} ]` | +| checkpoint consumes input | eligible | `TX[ insert entries from the register payloads, delete their pending.entry registers, upsert lane.leaf, S(ids removed, continuation → need_assistant(false), triggerEntryId = newest entry, skipInboxOnce = true) ]` | +| first `abort()` | run active | `TX[ S(control = cancel_requested, requestedAt, drainedSteer, drainedFollowUp, steer/followUp emptied) ]` — drained pending.entry registers are **not** deleted | +| finish | inbox empty, no required continuation | the terminal transaction (§3.13) | + +`cancelQueued` triage, in order: the id is still pending in a queue list → remove it and delete its `pending.entry` register in one transaction; the content is gone, never having touched the tree, and the call returns `cancelled`. An entry under that id exists → `already_consumed`. Neither → `not_found` — previously cancelled, cleared by abort, or never existed. A client retrying a lost cancel treats `not_found` as success. There are no disposition registers, and nothing here is ever a recovery input. + +The first `abort()` moves steer/follow-up ids into `control.drainedSteer`/`control.drainedFollowUp` but deletes none of their `pending.entry` registers: `AbortResult` and a post-crash `SuspendedOperation.aborting` dereference the drained payloads from those registers. They die in the terminal transaction (§3.13), never earlier. Deferred writes stay in `inbox.writes` and are applied during reconciliation. + +Because acceptance, cancellation, consumption, abort, and finish all serialize on the lane mutation line, every race has exactly two possible histories, and **no item can be both pending and applied** in durable state: at every commit boundary a queued id has its register (pending or drained), its entry (consumed), or neither (cancelled) — never both. + +## 3.12 The checkpoint procedure + +Order matters. At each queue drain point, `"all"` consumes every currently eligible item in acceptance order; `"one-at-a-time"` consumes only the oldest and leaves the rest pending. Any projecting drain sets durable `skipInboxOnce`; on that next pass the planner skips steps 1–2, starts generation, and clears the flag in the ready-state transition. Thus a crash cannot turn one-at-a-time into an all-item drain. + +1. Unless `skipInboxOnce`, atomically apply accepted deferred writes. +2. Unless `skipInboxOnce`, atomically consume eligible steering, per the steering mode. +3. Run threshold compaction only when `thresholdCheckedTriggerEntryId !== triggerEntryId`, preserving the marked checkpoint in `resumeAfter`. +4. If the continuation is `need_assistant`, start generation and clear `skipInboxOnce`. +5. Once assistant and tool continuation are exhausted, atomically consume eligible follow-up. +6. If the continuation is `may_finish` and the inbox is empty, invoke `before_run_end`. +7. Conditionally finish — the terminal transaction (§3.13). + +Consumed steer/follow-up and projecting message writes enter `need_assistant(false)`, set `triggerEntryId` to the newest appended entry, and set `skipInboxOnce`. Tool results do the same unless every result terminates. An unprojected custom write is appended and removed from the inbox but preserves the prior continuation, failure provenance, and overflow flag. Under cancelled control, every deferred write is appended and removed without changing phase/continuation or starting work; reconciliation ends in an aborted terminal transaction after writes drain. + +`before_run_end` may return a follow-up. It commits **only** if control is still running and the operation is still at the same finish boundary; otherwise the stale hook result is dropped. The follow-up is born placed — its entry and the `need_assistant` state commit together, with no pending register. + +`failure_drain` applies accepted writes, then eligible steer and follow-up input in the same order. Projecting user-context input atomically enters `checkpoint{need_assistant(false)}` and clears the failure. Unprojected custom writes do not. With no such input, it finishes failed without `before_run_end` or another provider request. + +## 3.13 Terminal transactions + +There is no finished state. An operation ends by ceasing to exist: one **terminal transaction** deletes every register the operation owns, records the outcome in `lane.lastResult`, and clears the lane's `currentOperationId`. After it commits, the operation's only durable footprint is the conversation entries and ledger rows it produced. + +The result is computed in memory, pre-commit, from the final operation state — the same value the caller's promise resolves with. What lands durably is its register form: + +```ts +type LaneLastResult = { + operationId: string; + kind: "run" | "compaction" | "navigation"; + leafId: string | null; + /** Newest settled assistant, when the outcome includes one (runs only). */ + finalAssistantEntryId?: string; +} & ( + | { outcome: "failed"; error: OperationError; runCompletion?: never } + | { outcome: "completed"; error?: never; + runCompletion?: "assistant" | "terminated_tools" } + | { outcome: "declined" | "aborted"; error?: never; runCompletion?: never } +); +``` + +A normal run finish copies `RunState.latestAssistantEntryId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes include the newest settled assistant when non-null and omit the field otherwise. Structural operations omit `runCompletion` and the final assistant. Only terminal transitions construct a `LaneLastResult`. + +Every terminal transaction, for every operation kind and outcome, has one shape: + +``` +TX[ , + delete op.meta/{O}, + delete op.state/{O}, + delete op.tool_args/{O}:* prefix scan; catches keys leaked by a crash + between batch completion and cleanup, + delete op.preparation/{O}:* prefix scan, same reason, + delete pending.entry/{id} for every operation-owned pending id, + upsert lane.lastResult/{lane} = , + L({ currentOperationId: null }) ] +``` + +Operation-owned pending ids are the remaining `inbox.steer ∪ inbox.followUp ∪ inbox.writes` plus `control.drainedSteer ∪ control.drainedFollowUp` — registers that survived an abort drain die here (§3.11). **Never `lane.state.pendingNextRun`**: those registers are lane-owned, outlive operations, and die only when consumed or cancelled. Ledger rows are never deleted (§1.6). The `L` write rereads the latest `LaneState` on the lane mutation line and clears only `currentOperationId`, preserving concurrently accepted `pendingNextRun` (§3.4). + +For the completed run of §0.3's shape — prompt `e_50`, tool call `e_51`/`e_52`, final answer `e_53`: + +``` +TX[ delete op.meta/op_9, + delete op.state/op_9, + delete op.tool_args/op_9:0, + upsert lane.lastResult/main = { operationId: "op_9", kind: "run", + outcome: "completed", leafId: "e_53", + finalAssistantEntryId: "e_53", + runCompletion: "assistant" }, + upsert lane.state/main = { currentOperationId: null, pendingNextRun: [] } ] +``` + +After it, the session holds exactly the conversation entries, the ledger rows, and the lane's registers (`lane.leaf`, `lane.config`, `lane.state`, `lane.lastResult`). The run's ~10 `op.state` revisions, its tool-args register, and any pending payloads existed only as register overwrites and are gone — nothing to collect (§1.8). + +**The observation contract.** A terminal outcome is observable once through the live caller's promise (and the corresponding `run_end`/`compaction_end`/`navigation_end` event), which carries the full in-memory result, and thereafter through `lane.lastResult` until the next terminal transaction on the same lane overwrites it. `lane.lastResult` is written only by terminal transactions — one bounded register per lane, forever. Recovery never reads it: restore treats a lane with `currentOperationId: null` as idle regardless of the register's content. It exists so an application that accepted an operation, lost its process, and reopened can still answer "what happened to `op_9`?" — including outcomes the tree alone cannot reconstruct: a structural failure's error, `declined`, and the `aborted`-versus-`completed` ambiguity of a leaf that moved. + +The invariant this section carries (restated in Part 9): `op.*` registers and operation-owned `pending.entry` registers exist **iff** their operation is open, because the terminal transaction deletes them atomically with clearing `currentOperationId`. There is no partial-cleanup state to observe or repair. # Part 4 — Execution, recovery, abort, close From 28657a2ffa6dbeccba74c166682e7a7ee547f5b4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 01:16:18 +0200 Subject: [PATCH 088/284] docs(agent): write harness-v3 parts 4-5 --- packages/agent/docs/harness-v3.md | 1176 ++++++++++++++++++++++++++++- 1 file changed, 1163 insertions(+), 13 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index c5012700e3e..9a2483476d0 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -302,7 +302,7 @@ type DurableStructuralPreparation = | `lane.lastResult` | lane name | `LaneLastResult` (§3.13) | terminal outcome of the lane's most recent operation | | `op.meta` | operation id | `Operation` (§3.1) | acceptance data; written once, never overwritten | | `op.state` | operation id | `OperationState` (§3.2) | total operation state — **the program counter** | -| `op.tool_args` | `{opId}:{sourceIndex}` | effective arguments | written once at tool clearance (§3.8) | +| `op.tool_args` | `{opId}:{stepId}:{sourceIndex}` | effective arguments | written once at tool clearance (§3.8) | | `op.preparation` | `{opId}:{taskId}` | `DurableStructuralPreparation` | written once before the decision hook (§3.9) | | `pending.entry` | reserved entry id | `PendingEntry` | queued content awaiting placement (§2.2) | | `fact.name` | `""` | string | session name | @@ -1186,7 +1186,7 @@ An overflow classification never produces a tool plan. A *genuine* `length` that | From | Trigger | Transaction | To | |---|---|---|---| -| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ upsert op.tool_args/O:{i} = effective args, S(call i = effect_pending, replay) ]` | dispatch | +| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ upsert op.tool_args/O:{stepId}:{i} = effective args, S(call i = effect_pending, replay) ]` | dispatch | | call *i* `effect_pending` | effect settled, `after_tool` applied | `TX[ insert result entry, upsert lane.leaf, insert tool usage row (if reported), S(call i = completed, terminate) ]` | tools or checkpoint | | call *i* `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | `TX[ insert synthetic error result entry, upsert lane.leaf, S(call i = completed, terminate from an intentional block, otherwise false) ]` | tools | | all calls completed | — | folded into the last settlement, which also deletes the batch's `op.tool_args/{O}:{stepId}:*` registers | checkpoint | @@ -1385,7 +1385,7 @@ For the completed run of §0.3's shape — prompt `e_50`, tool call `e_51`/`e_52 ``` TX[ delete op.meta/op_9, delete op.state/op_9, - delete op.tool_args/op_9:0, + delete op.tool_args/op_9:s_1:0, ← usually already gone at batch completion upsert lane.lastResult/main = { operationId: "op_9", kind: "run", outcome: "completed", leafId: "e_53", finalAssistantEntryId: "e_53", @@ -1401,18 +1401,1168 @@ The invariant this section carries (restated in Part 9): `op.*` registers and op # Part 4 — Execution, recovery, abort, close -- **4.1 The interpreter** `[CARRY+]` — CAS tokens become register `seq` (`operationStateSeq`, `laneStateSeq`, expected `lane.config` seq). -- **4.2 The effects boundary** `[CARRY]` -- **4.3 The lane mutation line** `[CARRY]` -- **4.4 Restore** `[REWRITE: jot 4]` — the five register reads; bounded hydration of named entries/registers; worked crash example; per-backend notes; missing identities. -- **4.5 Crash positions and recovery policy** `[CARRY]` -- **4.6 Abort** `[CARRY+]` — drained pending registers survive until terminal. -- **4.7 Close** `[CARRY]` -- **4.8 Faults** `[CARRY]` +## 4.1 The interpreter + +The runtime plans from total durable state plus a small process-local scheduler. Entries and stable register values named by the state are batch-loaded before planning. The driver also snapshots current settings revision and registry leases (`Models.lease` and active tool definitions) into `RuntimeSnapshot`; this performs no provider request. When a tool batch first becomes current, the driver resolves `toolContext` once, binds the batch's definitions, and retains them in `DriveState.toolBatches` for every sequential/parallel call in that batch. `nextAction` is then pure over those inputs. Pre-intent hook plans retain the exact lease used for lookup, preparation, schema validation, and eventual dispatch. + +```ts +interface CurrentOperation { + operation: Operation; + state: OperationState; + /** Register seqs at load time; conditional commits compare these (§3.4). */ + operationStateSeq: number; + laneState: LaneState; + laneStateSeq: number; + leafId: string | null; + configuration: LaneConfiguration; + configurationSeq: number; +} + +type EffectKey = string; // deterministic from durable step/attempt or assistant/sourceIndex + +/** Process-local leases captured before intent; never persisted or exposed. */ +type RuntimeProviderLease = ModelRequestLease; +interface RuntimeToolLease { tool: AgentTool } +interface RuntimeAssistantLease { + provider: RuntimeProviderLease; + activeTools: AgentTool[]; +} + +interface LiveEffect { plan: EffectPlan; promise: Promise } + +interface DriveState { + deferredPollsRemaining: 0 | 1; + running: Map; + /** One context/tool-definition snapshot per live or restored batch. */ + toolBatches: Map>; + /** Process-local best-effort attempts; reopen may attempt again. */ + deferredCancellations: Set; +} + +type EffectPlan = { telemetryContext: TelemetryContext } & ( + | { kind: "assistant"; key: EffectKey; + generation: Extract; + streamOptions: AgentHarnessStreamOptions; identity: RuntimeAssistantLease } + | { kind: "summary"; key: EffectKey; + generation: Extract; + identity: RuntimeProviderLease } + | { kind: "tool"; key: EffectKey; assistantEntryId: string; + sourceIndex: number; + /** Full op.tool_args register key: {opId}:{stepId}:{sourceIndex} (§3.8). */ + argsKey: string; identity: RuntimeToolLease } + | { kind: "deferred"; key: EffectKey; + deferred: Extract; + streamOptions: AgentHarnessStreamOptions; identity: RuntimeProviderLease } + | { kind: "cancel_deferred"; key: EffectKey; sourceEntryId: string; + handle: DeferredHandle; identity: RuntimeProviderLease } + | { kind: "hook"; key: EffectKey; name: keyof HookMap; event: unknown; + /** Pre-intent hooks carry the exact lease used to prepare their event. */ + identity?: RuntimeProviderLease | RuntimeAssistantLease | RuntimeToolLease } +); + +type SummaryAttemptOutcome = + | { kind: "success"; result: CompactResult | BranchSummaryResult } + | { kind: "retry" | "failure"; error: OperationError }; + +type EffectOutput = + | { kind: "not_started"; key: EffectKey } + | { kind: "assistant" | "deferred"; key: EffectKey; + message: SettledAssistantMessage } + | { kind: "summary"; key: EffectKey; outcome: SummaryAttemptOutcome } + | { kind: "tool_raw"; key: EffectKey; + result: AgentToolResult; isError: boolean } + | { kind: "hook"; key: EffectKey; result: unknown } + | { kind: "cancel_deferred"; key: EffectKey }; + +type SettlementOutput = Exclude | + { kind: "tool"; key: EffectKey; result: AgentToolResult; + isError: boolean; terminate: boolean }; + +interface SettlementResult { + current: CurrentOperation; + /** Immediate live dispatch prepared by a successful pre-intent hook. */ + dispatch?: EffectPlan; + /** Identity resolution failed while durable state was still safely dispatchable. */ + suspend?: OperationResult; + /** Poll intent committed; consume this resume invocation's sole permit. */ + consumeDeferredPoll?: true; +} + +interface RuntimeSnapshot { + settingsRevision: number; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; + providerLeases: ReadonlyMap; + toolLeases: ReadonlyMap; +} + +type PlannerInputs = { + /** Exact process-local plans; never reconstruct a live plan from durable ids. */ + running: ReadonlyMap; + deferredPollsRemaining: 0 | 1; + deferredCancellations: ReadonlySet; + /** Entries plus loaded op.tool_args/op.preparation/pending.entry register + values — written once per key or stable until consumed, so safe as + immutable planner inputs. Keyed by entry id or register key. */ + loaded: ReadonlyMap; + runtime: RuntimeSnapshot; + context?: AgentMessage[]; + now: number; +}; + +type OperationResult = RunOutcome | CompactionOutcome | NavigationOutcome; + +type Action = + | { kind: "transition"; next: OperationState; telemetryContext: TelemetryContext; + /** Required when this transition snapshots current mutable request state. */ + expectedConfigurationSeq?: number; + expectedSettingsRevision?: number } + | { kind: "dispatch"; intent?: OperationState; effect: EffectPlan; + consumeDeferredPoll?: true } + | { kind: "await_effect"; key: EffectKey } + | { kind: "wait"; until: number; telemetryContext: TelemetryContext } + | { kind: "suspend"; result: OperationResult } + | { kind: "done"; result: OperationResult }; + +async function drive(current: CurrentOperation, live: DriveState): Promise { + while (true) { + const inputs = await loadPlannerInputs(current, live); // bounded entry/register reads + const action = nextAction(current.state, inputs); // pure and exhaustive + + switch (action.kind) { + case "transition": { + const committed = await commitTransitionIfCurrent( + current, action.next, action.telemetryContext, + action.expectedConfigurationSeq, action.expectedSettingsRevision); + current = committed ?? await reloadCurrent(current.operation.operationId); + break; + } + + case "dispatch": { + if (action.intent) { + const committed = await commitTransitionIfCurrent( + current, action.intent, action.effect.telemetryContext); + if (!committed) { + current = await reloadCurrent(current.operation.operationId); + break; // a lane mutation won; do not dispatch + } + current = committed; + } + if (action.consumeDeferredPoll) live.deferredPollsRemaining = 0; + if (action.effect.kind === "cancel_deferred") + live.deferredCancellations.add(action.effect.sourceEntryId); + live.running.set(action.effect.key, + { plan: action.effect, promise: fx.run(action.effect) }); + break; // permits source-ordered parallel dispatch + } + + case "await_effect": { + const liveEffect = live.running.get(action.key); + if (!liveEffect) throw new Error("planned effect is not running"); + const { plan } = liveEffect; + const output = await liveEffect.promise; + live.running.delete(action.key); + if (plan.kind === "cancel_deferred") { + current = await reloadCurrent(current.operation.operationId); // no durable write + break; + } + let settlement: SettlementOutput; + if (output.kind === "tool_raw") { + if (plan.kind !== "tool") throw new Error("tool output/plan mismatch"); + settlement = await fx.finalizeTool(plan, output); // source-ordered after_tool + } else { + settlement = output; // not_started settles synthetically without hooks + } + const settled = await commitEffectSettlement( + current, plan, settlement, plan.telemetryContext); + current = settled.current; + if (settled.suspend) return settled.suspend; + if (settled.consumeDeferredPoll) live.deferredPollsRemaining = 0; + if (settled.dispatch) + live.running.set(settled.dispatch.key, + { plan: settled.dispatch, promise: fx.run(settled.dispatch) }); + break; + } + + case "wait": + await fx.sleep( + Math.max(0, action.until - Date.now()), action.telemetryContext); + current = await reloadCurrent(current.operation.operationId); + break; + + case "suspend": + case "done": + return action.result; + } + } +} +``` + +An intent/ordinary transition requires the `op.state` register still to carry its expected `operationStateSeq`; otherwise it returns `undefined` and the loop replans without dispatch. A successful `before_request`/`before_tool` hook settlement uses its retained identities, atomically commits the effect intent (and the effective `op.tool_args` register), and returns the complete process-local dispatch plan; the drive installs that promise immediately. A crash in the remaining process-only gap is conservatively the ordinary unknown-effect case. A transition that creates a generation/summary `ready` state also supplies the `lane.config` register seq and harness-settings revision it read; the settings/lane commit requires both still match, giving setter-first or step-start-first ordering. The resulting context durably captures the inline configuration, normalized retry policy, and base stream options. Immediately before ordinary external execution, `fx.run` enters the lane mutation line once more: cancellation-first returns `not_started`, while start-first registers the live effect/controller so a later abort signals it. This check uses the already captured identity lease and never re-resolves the registry. Thus no effect starts in the gap after intent without belonging to one of the two serialized orders. Settlement reloads latest total state, verifies the same effect key remains pending, merges the output into that state, and applies current cancellation control. Thus steer/write acceptance, abort, and other parallel-tool intents cannot erase a live result or overwrite newer inbox/control state. + +Parallel tool calls dispatch phase two in source order into `DriveState.running`. The planner may dispatch later calls while earlier promises run, but it emits `await_effect` only for the first incomplete source position. That raw result then crosses source-ordered `fx.finalizeTool`/`after_tool` before settlement. A later settled raw promise remains process-local until its turn. After restart `running` is empty, so durable `effect_pending` follows recovery policy rather than being mistaken for a live effect. + +Recovery rules: + +- `not_started` under cancelled control settles assistant/fetch under reserved ids as `aborted`, settles a tool with its planned aborted result without `after_tool`, drops an uncommitted hook decision, discards structural work before finishing aborted, and drops a stale deferred-cancel action without settlement; +- ready generation/summary and cleared tools commit `effect_pending` before `dispatch`; +- restored generation/summary pending with no live key advances under captured retry policy or settles synthetically at the cap; +- restored tools replay only when persisted and current declarations are `safe`, otherwise settle interrupted; +- restored deferred pending normally suspends until an application `resume()` replaces it with one fresh poll intent; cancelled control instead settles the existing reserved response/usage ids synthetically as `aborted` before finishing; +- committing a deferred intent through its `before_request` settlement returns `consumeDeferredPoll:true`; the drive clears the invocation's sole permit before installing dispatch, so a pending response re-suspends rather than polling again; +- retry wait crosses `fx.sleep`, which is visible to manual drive and reloads cancellation afterward; +- structural decision hooks run from `deciding`; their consumer transaction either finishes the structure or records `generating`, so only a pre-commit crash reruns them. + +A fresh operation drive starts with zero deferred permits; `resume()` starts with one. Repairs and non-poll work do not consume it. + +## 4.2 The effects boundary + +Every operation-procedure commit, provider request, tool invocation, hook call, and timer crosses exactly one injected `Effects` (`fx`) method. Procedures receive `fx`, their telemetry context, and a read-only runtime view — never `Session`, `Models`, the tool registry, or the hook runner directly. Ungated lane-surface commits—acceptance, queue/configuration calls, facts, lane creation, and idle writes—use the same lane mutation line and typed `Session` transaction API directly. + +```ts +type SummaryRequestOutput = + | { kind: "response"; message: SettledAssistantMessage } + | { kind: "not_started" }; + +interface Effects { + commitTransition(current: CurrentOperation, next: OperationState, + telemetry: TelemetryContext, + expectedConfigurationSeq?: number, + expectedSettingsRevision?: number): + Promise; + commitEffectSettlement(current: CurrentOperation, plan: EffectPlan, + output: SettlementOutput, telemetry: TelemetryContext): + Promise; + /** Runs after_tool for the raw phase-two result selected in source order. */ + finalizeTool(plan: Extract, + output: Extract): + Promise>; + /** Composite summary plans use this reentrantly for each provider request. */ + runSummaryRequest(plan: { taskId: string; attempt: number; requestIndex: number; + usageId: string; configuration: LaneConfiguration; + messages: AgentMessage[]; identity: RuntimeProviderLease; + telemetryContext: TelemetryContext }): + Promise; + settleSummaryRequest(current: CurrentOperation, + plan: { taskId: string; attempt: number; requestIndex: number; + usageId: string }, + response: SettledAssistantMessage, + telemetry: TelemetryContext): Promise; + /** Revalidates/registers effect start on the lane mutation line before execution. */ + run(plan: EffectPlan): Promise; + sleep(delayMs: number, telemetry: TelemetryContext): Promise; +} +``` + +The commit helpers shown in §4.1 delegate to these methods. Expected provider, tool, structural, and deferred-cancel failures return in-band `EffectOutput` variants; `run` rejects only for close, harness fault, or invariant defects. `cancel_deferred` is the explicit exception to ordinary start/settlement: its start check requires the same open cancelled operation and the process-local source target registered by `abort()` (the durable phase may already have advanced), uses a close-only signal rather than the already-pulled operation signal, and its awaited output bypasses `commitEffectSettlement` with no durable write. Automatic effects execute directly; manual effects gate the same calls. Passive event-listener delivery is observation, not an interpreter effect: it is isolated and telemetry-wrapped after publication but never parked by manual drive. `sleep` resolves early when the harness signal is pulled, after which the loop reloads cancellation control. For split-turn summary work, request-intent `commitTransition`, `runSummaryRequest`, and usage/state `settleSummaryRequest` are three distinct nested gated actions. `runSummaryRequest` performs the same serialized start check as `run`; abort-first returns `not_started`, leaves no usage, and makes the outer summary plan return its own `not_started` settlement, which discards structural work under cancelled control. The outer summary orchestration action is only process-local composition; manual drive and crash tests still stop between each nested boundary. These methods are the complete procedure crash-site catalog; ungated public mutations are the race boundaries in Part 9. + +**The provider signal is harness-owned.** `fx` supplies the `AbortSignal` passed to every provider request. No caller can supply one: `signal` is absent from the options type at every public surface (§5.2), and the harness strips any signal from a `streamOptions` patch before dispatch. Only `abort()` and `close()` can pull it. This is what makes §4.6's guarantee hold. + +**Manual drive.** With `drive: "manual"` the harness parks before each effect and exposes one JSON-safe action at a time: + +```ts +peekAction(): Promise; // stable, side-effect free +executeAction(): Promise; // release exactly one +runToCompletion(): Promise; +``` + +Lane-surface calls—including operation acceptance, `steer`, `abort`, config setters, and tree writes—stay **ungated**, so a test can drive both orders of any race. In manual mode a `before_run` handler parks before acceptance; with no handler, acceptance commits immediately and the first parked action is the run's first procedure transition. The gate is reentrant: nested `fx` calls (notably request hooks inside a stream) park independently, and the driver releases them before their parent continues. Closing while an action is parked rejects it unexecuted; durable state is exactly the committed prefix. + +Enforced by construction and by a test: an operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked. + +## 4.3 The lane mutation line + +Every state-dependent mutation on a lane is linearized: validate, at most one atomic commit, and the in-memory update complete before the next mutation starts. Provider, tool, hook, and retry work never occupies the line. + +What serializes here: operation acceptance, queue enqueue and cancel, queue consumption, deferred-write acceptance and application, abort, lane-configuration setters, finish, lane creation. Harness-global stream/retry/compaction/queue settings use a second mutation line with a monotonically increasing process revision. Operation acceptance and generation/summary starts snapshot settings by taking the settings line before the lane line and conditionally committing both expected tokens; global setters take only the settings line. No code acquires them in the reverse order. + +Consequence: every race between two public calls has exactly **two** possible durable histories, and both must be tested (Part 9). + +## 4.4 Restore + +Recovery is point lookups against registers. No history, no folding, no journal replay, no tree walk. Per lane: + +```ts +async function restore(lane: string): Promise< + { kind: "idle"; lane: string } | { kind: "suspended"; current: CurrentOperation } +> { + const config = await storage.getRegister("lane.config", lane); + const state = await storage.getRegister("lane.state", lane); + const leaf = await storage.getRegister("lane.leaf", lane); + + const opId = state.value.currentOperationId; + const meta = opId ? await storage.getRegister("op.meta", opId) : undefined; + const opState = opId ? await storage.getRegister("op.state", opId) : undefined; + + // Idle lanes are validated too: leaf existence and every pendingNextRun + // id's pending.entry register (§3.3). Only the operation checks are + // conditional on an open operation. + const entryIds = directEntryIds(opState?.value, state.value, leaf.value); + const registerKeys = directRegisterKeys(opState?.value, state.value); + const [entries, registers] = await Promise.all([ + storage.getEntries(entryIds), getRegisters(registerKeys), + ]); + validateCurrent({ config, state, leaf, meta, opState }, entries, registers); // §3.3 + + if (!opId) { + // lane.lastResult is there if the application wants to reconcile a + // pre-crash outcome; restore itself never reads it. + return { kind: "idle", lane }; + } + + return { kind: "suspended", current: { + operation: meta.value, state: opState.value, + operationStateSeq: opState.seq, + laneState: state.value, laneStateSeq: state.seq, + leafId: leaf.value, + configuration: config.value, configurationSeq: config.seq, + } }; +} +``` + +Five register point-lookups: three lane registers, then — only when an operation is open — `op.meta` and `op.state`. `op.state` **is** the program counter: everything the interpreter needs to pick the next action is either in it or reachable from it by exact entry id or deterministic register key. + +**Bounded hydration and validation.** From the loaded state, collect what it names directly and fetch it in one batch: + +- **entries:** `triggerEntryId`, `latestAssistantEntryId`, `batch.assistantEntryId`, deferred `sourceEntryId`, completed `resultEntryId`s, prompt entries, the lane leaf; +- **registers:** `op.tool_args/…` for effect-pending calls, `op.preparation/…` for structural work, `pending.entry/…` for every `inbox.*`, `control.drained*`, and `pendingNextRun` id. + +Then §3.3's bounded validation over exactly that set: every named thing exists and has the right shape; reserved ids that *are* materialized contain what the intent promised; tool call indices are complete and unique. Configuration, stream options, and retry policy need no lookups at all — they are inline in the state itself. + +What restore never does: read register history (none exists), fold anything, scan tables, build provider context, probe for missing planned entries, audit completed operations, or infer state from what is absent. + +Restore already fetched the directly named entries and registers for validation. The driver reuses/caches them and lazily builds only derived provider context or additional branch projections needed by the next action; `nextAction` itself switches on scalars and the supplied loaded map (§4.1). + +### Worked example — crash in the uncertain window + +The process died mid-stream after an assistant intent (§3.7's `effect_pending` row; the §0.3 run). Reopen: + +``` +lane.state/main -> { currentOperationId: "op_9" } +op.meta/op_9 -> { intent: run, sourceLeafId: "e_41" } +op.state/op_9 -> { phase: assistant effect_pending, attempt: 1, + responseEntryId: "e_51", usageId: "u_7", + context: { configuration: { model: {...}, ... }, + retryPolicy: { maxAttempts: 3, ... } } } + +getEntries(["e_50"]) -> exists ✓ the placed prompt +getEntries(["e_51"]) -> absent reserved, unsettled — expected +``` + +The harness restores without starting any effect and reports the operation as suspended. When the application calls `resume()`, the interpreter sees `effect_pending` with no live key (the process-local `running` map died with the process) and applies the §4.5 uncertain-window policy — from the captured state itself: + +- attempt 1 < `maxAttempts` 3 → a fresh attempt 2 under the **captured** configuration and policy, even if the user changed the model yesterday; +- at the cap → synthesize an error response: insert entry `e_51` `{ stopReason: "error", … }`, insert zero usage `u_7`, enter failure drain — using exactly the ids reserved in the intent; +- control was `cancel_requested` → synthesize `aborted` under `e_51` instead, and never retry. + +Same shape for tools (replay only if the captured **and** current declarations say `safe`, else a synthetic interrupted result under the reserved result id) and deferred (wait for the application's next `resume()`; each poll reserves fresh ids). + +### Per backend + +- **Memory:** the maps are the state; nothing to do. +- **JSONL:** replay the file into the entry/register/usage maps — that is *decoding*, not recovery logic (§1.7); a torn final line is discarded whole. After decoding, restore is the same register reads. +- **SQLite** (and future Postgres): literally the point lookups above. + +### Missing identities + +Admission resolves configured identities and returns `Err(MissingIdentities)` before writing when any are absent. Each later assistant, deferred, tool, or whole-summary-attempt preparation snapshots process-local provider/tool leases before its pre-intent hook. That registry/settings-line snapshot is the step-start order: lookup, `prepareArguments`, schema validation, hook event, intent, and dispatch all retain the same lease even if the registry is replaced while the hook runs. Both split-summary requests share the attempt's lease. If resolution fails while state is still safely dispatchable (`ready`, `planned`, or between summary requests), the accepted call resolves `Ok({kind:"suspended", reason:"missing_identities", ...})`; state is unchanged and the operation stays open. A later `resume()` precheck returns `Err(MissingIdentities)` on the same condition. Registering missing pieces does not auto-drive. Because the captured configuration is inline, restore reports exactly what is missing without resolving anything. Restored `effect_pending` has no lease and follows unknown-effect recovery rather than claiming the effect never started. Synthetic settlement, usage repair, queue application, finish, and non-replay reconciliation need no identities. + +## 4.5 Crash positions and recovery policy + +Atomic transactions have no internal prefix, so for any repeat-sensitive effect there are exactly these durable positions: + +| Crash point | What is durable | Recovery | +|---|---|---| +| before the intent commit | the previous state | plan the effect normally, as if nothing happened | +| after intent, before dispatch | `effect_pending`; the effect did not run, or you cannot tell | apply the policy below | +| during or after the effect, before settlement | `effect_pending`; the outcome is unknown | same | +| after the settlement commit | output + usage + next state | continue; never re-settle | +| before / after a queue-application commit | the item is fully pending / the entry exists and its register is gone | apply later / never apply twice | +| before the final structural commit | source leaf intact, generated work uncommitted | recompute per the current state and policy | +| after the final structural commit | move + summary entry + label + usage + terminal cleanup | done | +| after the first abort commit | cancellation and drained ids durable; drained payloads still in their pending registers | start no new ordinary effects; reconcile | +| after the terminal commit | op registers deleted, `lane.lastResult` written, `currentOperationId` null | the lane is idle | + +**The one uncertain interval in the entire system is: intent durable, settlement absent.** Three policies cover it: + +| Restored state | Policy | +|---|---| +| generation `effect_pending` | start a later numbered attempt only if the **captured** retry policy allows. Otherwise persist a synthetic error under the already-reserved response id. If cancellation is durable, persist synthetic `aborted` under that id instead, and never retry. | +| tool `effect_pending` | re-execute the persisted `op.tool_args` arguments only if the stored declaration **and** the current tool declaration both say `safe`. Otherwise append a synthetic `interrupted` error under the reserved result id. | +| deferred `effect_pending` | with running control, wait for the application's next `resume()`, which reserves fresh poll/response/usage ids; with cancelled control, synthetically settle the existing reserved response/usage ids as `aborted`. No cap. | + +## 4.6 Abort + +Abort is not a phase. It is `control`. + +- **First `abort()`**: one commit sets `control = cancel_requested`, records `requestedAt`, moves the exact drained steer and follow-up ids into `control.drained*`, and leaves `phase` untouched. The drained items' `pending.entry` registers are **not** deleted: `AbortResult` and a post-crash `SuspendedOperation.aborting` dereference the exact payloads from them, and they survive until the terminal transaction (§3.11, §3.13). After the commit, the harness pulls the signal and cancels unreleased gated effects. The call resolves once the marker is durable; reconciliation runs in the background (automatic drive) or parks at its next action (manual drive). +- **Later `abort()`** while the operation is open: appends nothing, signals nothing, returns the same drained payloads. After the terminal state: `NoActiveOperation`. +- **Still allowed after cancellation**: settling effects that were already intended, writing their usage, applying accepted deferred writes, committing configuration changes, and completing the cancellation. +- **Forbidden**: starting any new provider request, tool, decision hook, or retry. +- **Post-effect hooks**: abort and a not-yet-started `after_response`/`after_tool` serialize on the effect-start check. Abort-first skips the hook; assistant/fetch settlement uses the raw response then normalizes it to `aborted`, while a live tool keeps its raw result with `terminate:false`. Hook-first lets it finish and uses its transformed value. A hook already running is not forcibly interrupted. +- **Per-output reconciliation**: planned tool calls get an aborted error result; restored started calls get `interrupted`; live started calls keep their finalized or raw result as above; an assistant or fetch settlement after cancellation is stored under the reserved response id with stop reason `aborted` and moves to cancelled checkpoint state. + +**Signal ownership makes `aborted` unambiguous.** Provider implementations must set `stopReason: "aborted"` if and only if the signal they were given was pulled, and the harness owns that signal exclusively (§4.2). Since `abort()` commits `control` before pulling it, a settled `aborted` response always has cancellation already durable. Timeouts, transport failures, malformed streams, and provider-side refusals all settle as `error` and take the ordinary retry path — which is correct, because those should retry and a user abort should not. An `aborted` response with `control.status === "running"` is unreachable; if one exists, the session is corrupt (Part 9). + +On a deferred source, the `abort()` lane job registers the newest persisted handle/lease as a process-local cancellation target and immediately installs `EffectPlan{kind:"cancel_deferred"}` in `DriveState.running`, even when the drive is awaiting a live fetch. It is the one external action permitted to start under cancelled control, remains valid if fetch settlement advances the durable phase, crosses normal manual gating and `pi.ai.request`, calls the leased `cancelDeferred`, converts success/failure to an in-band output, and never writes operation state. Cancellation reconciliation awaits/removes that live plan before terminal finish. Failure is telemetry only and never blocks finish. `deferredCancellations` prevents repetition in one process; crash/reopen during reconciliation may retry. Missing provider identity skips cancellation but not durable reconciliation. + +There is no universal assistant closure. The harness never starts a request or appends an assistant message solely to manufacture one. An abort between steps, during tool work, or while suspended can therefore produce no abort-specific assistant event at all. + +For structural operations the commit point decides the race: a marker committed first discards in-memory generated work and finishes `aborted`; if the structural commit won, the procedure completes that already-committed compaction or navigation and finishes `completed`. + +## 4.7 Close — a controlled crash + +**Close is not abort.** Close writes nothing: no cancellation, no terminal state, no settlement. + +``` +close() + → stop admitting new work + → pull the signal, so in-flight provider requests and cooperative tools stop + → reject parked manual actions and unresolved local promises + → let commits already accepted by storage drain + → close storage, release the writer lease (§1.7) +``` + +A harness-wide admission barrier linearizes close against every operation and surface commit. A commit that acquires admission first is allowed to finish and close waits for it; close that seals admission first prevents the commit from entering storage. A stream cut after sealing settles locally as `aborted`, but its settlement transaction is never admitted. Durable state therefore stops at `effect_pending`, exactly as after process death. + +So close needs no recovery machinery of its own: reopening finds `effect_pending` and applies the §4.5 policy — a later numbered attempt under the captured retry policy, or a synthetic error at the cap. Open operations remain open and resumable. + +This also keeps the aborted-implies-cancelled invariant (Part 9) true. Close pulls the same signal as abort, but the sealed admission barrier prevents that locally aborted response from committing with running control. + +## 4.8 Faults + +A failed storage commit faults the whole harness. A faulted harness stops all effects and rejects pending and future calls with `HarnessFault`; it is never an `Err` result. `faulted: true` appears in snapshots obtained before the fault closes observation. After the cause is fixed, reopening restores each lane from its registers. Close likewise rejects already-accepted local operation promises with `HarnessClosed`; calls not yet accepted return `Err(Closed)`. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from a trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or an `entryProjector`) is an application defect and faults the harness; it never escapes as an undeclared operation error. `AgentTool.prepareArguments` is the deliberate exception handled by the tool pipeline as a synthetic tool error. + +--- # Part 5 — Public surface -- **5.1–5.8** `[CARRY+ deltas from jot 8]` — `RecordUsageResult { usageId }`; usage event carries the ledger row; `lane.lastResult` read path; `cancelQueued` `not_found`; expired-lane condition and truncation markers (only meaningful on partitioned backends); everything else — lane surface, results/errors, harness, SessionTree, snapshots/watch, events, hooks, agent-loop building blocks, telemetry — verbatim with renames. +## 5.1 The lane surface + +Expected rejection returns `Result.err`. Accepted operations return `Result.ok`, including failed, aborted, and suspended outcomes. Storage faults, close during accepted work, and invariant defects reject the promise. + +```ts +interface AgentLane { + readonly name: string; + getLeafId(): Promise; + /** The lane's most recent terminal outcome (§3.13); undefined before the + first terminal transaction. Never consulted by recovery. */ + getLastResult(): Promise; + + prompt(text: string, images?: ImageContent[]): Promise; + prompt(message: AgentMessage | AgentMessage[]): Promise; + skill(name: string, additionalInstructions?: string): Promise; + promptFromTemplate(name: string, args?: string[]): Promise; + compact(options?: { customInstructions?: string }): Promise; + navigateTree(targetId: string | null, options?: NavigateOptions): Promise; + resume(): Promise; + abort(): Promise; + + steer(message: string | AgentMessage, images?: ImageContent[]): Promise; + followUp(message: string | AgentMessage, images?: ImageContent[]): Promise; + nextRun(message: string | AgentMessage, images?: ImageContent[]): Promise; + cancelQueued(entryId: string): Promise; + + recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }): + Promise; + waitForIdle(): Promise; + runWhenIdle(callback: () => void | Promise): Promise; + + peekAction(): Promise; + executeAction(): Promise; + runToCompletion(): Promise; + + /** Undefined when the durable provider/model identity is not registered. */ + getModel(): Promise; + setModel(model: Model): Promise; + getThinkingLevel(): Promise; setThinkingLevel(l: ThinkingLevel): Promise; + getActiveTools(): Promise; setActiveTools(names: string[]): Promise; + + session: SessionTree; + watch(): Promise>; +} + +interface NavigateOptions { summarize?: boolean; label?: string; customInstructions?: string } +interface ActionInfo { kind: string; description: string; details?: JsonValue } +interface WatchHandle { snapshot: T; start(listener: EventListener): void; unsubscribe(): void } +``` + +Skill/template expansion precedes storage. Prompt intent names only normalized caller messages, excluding captured `nextRun` and hook injections. + +`getLastResult()` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads the `lane.lastResult` register for the outcome its promise never delivered (§3.13). On a partitioned backend, a dormant lane whose leaf id decodes to a retired period enters an explicit **expired-lane** condition on next access rather than failing obscurely; its semantics — surfacing, rebase-to-boundary policy — are defined in Part 6. The three shipping backends never produce it. + +`waitForIdle()` registers on the lane mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local operation/admission reservation is held. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. + +`runWhenIdle(callback)` waits by the same rule, then takes a process-local lane admission reservation for the callback. The reservation is released on return or throw; callback rejection propagates. The callback must not invoke a state-mutating method on the same lane, which would deadlock behind its own reservation. Close rejects callbacks not yet started and waits for an already-running callback, which cannot be forcibly interrupted. + +### Results and errors + +```ts +type Result = { ok: true; value: T } | { ok: false; error: E }; +type Tagged> = + Error & { readonly _tag: Tag } & Readonly

; + +type OptionalFinalAssistant = + | { finalEntryId: string; finalMessage: AssistantMessage } + | { finalEntryId?: never; finalMessage?: never }; + +type MissingIdentitySuspension = { + kind: "suspended"; reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; +}; + +type RunOutcome = + | ({ kind: "completed"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) + | { kind: "suspended"; reason: "deferred"; leafId: string; + finalEntryId: string; deferred: DeferredHandle } + | (MissingIdentitySuspension & { leafId: string }); + +type CompactionOutcome = + | { kind: "completed"; leafId: string; entry: CompactionEntry } + | { kind: "declined" | "aborted"; leafId: string } + | { kind: "failed"; leafId: string; error: OperationError } + | (MissingIdentitySuspension & { leafId: string }); + +type NavigationOutcome = + | { kind: "completed"; oldLeafId: string | null; newLeafId: string | null; + summaryEntry?: BranchSummaryEntry } + | { kind: "declined" | "aborted"; leafId: string | null } + | { kind: "failed"; leafId: string | null; error: OperationError } + | (MissingIdentitySuspension & { leafId: string | null }); + +type ResumeOutcome = + | ({ operation: "run"; runId: string } & RunOutcome) + | ({ operation: "compaction"; runId: string } & CompactionOutcome) + | ({ operation: "navigation"; runId: string } & NavigationOutcome); +``` + +A completed run may omit final assistant fields when every finalized tool result terminates. The two fields are always both present or both absent. + +Expected errors use the existing `TaggedError` implementation in `harness/result.ts`: + +| tag | fields beyond `message` | +|---|---| +| `LaneBusy` | `lane`, `operationId`, `operationKind` | +| `MissingIdentities` | `lane`, `tools`, `models` | +| `NoActiveRun`, `NoActiveOperation`, `NothingToResume`, `NothingToCompact` | `lane` | +| `InvalidMessage`, `InvalidNavigation` | `lane`, `reason` | +| `UnknownSkill`, `UnknownTemplate` | `name` | +| `UnknownTarget` | `targetId` | +| `LaneExists`, `InvalidLane` | `lane` (`InvalidLane` also has `reason`) | +| `Closed` | none | + +```ts +type RunResult = Result<{ runId: string } & RunOutcome, + LaneBusy | MissingIdentities | InvalidMessage | UnknownSkill | UnknownTemplate | Closed>; +type CompactionResult = Result<{ runId: string } & CompactionOutcome, + LaneBusy | MissingIdentities | NothingToCompact | Closed>; +type NavigationResult = Result<{ runId: string } & NavigationOutcome, + LaneBusy | MissingIdentities | InvalidNavigation | UnknownTarget | Closed>; +type ResumeResult = Result; +type QueueResult = Result<{ entryId: string }, NoActiveRun | InvalidMessage | Closed>; +type NextRunResult = Result<{ entryId: string }, InvalidMessage | Closed>; +type CancelQueuedResult = Result< + { kind: "cancelled" | "already_consumed" | "not_found" }, Closed>; +type AbortResult = Result<{ runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, + NoActiveOperation | Closed>; +type RecordUsageResult = Result<{ usageId: string }, Closed>; + +class HarnessFault extends Error { + readonly cause: unknown; + constructor(message: string, cause: unknown) { super(message); this.cause = cause; } +} +class HarnessClosed extends Error {} +``` + +`cancelQueued` has no unknown-item error: an id that is neither pending nor materialized returns `not_found` (§3.11) — previously cancelled, cleared by abort, or never existed — and a client retrying a lost cancel treats it as success. `AbortResult`'s steer/follow-up payloads are dereferenced from the drained items' surviving `pending.entry` registers (§4.6). `recordUsage` mints its ledger row id at commit (§1.6) and returns it. + +`runId` is the operation's durable `operationId`; the public name remains for compatibility. `HarnessFault` and `HarnessClosed` reject promises; they are not tagged expected errors and not members of these unions. + +## 5.2 The harness + +```ts +class AgentHarness + implements AgentLane { + /** Initializes an unconfigured main when needed, then restores every lane + without starting provider, tool, hook, or timer effects. One suspension + descriptor per lane with an open operation. */ + static create(options: AgentHarnessOptions): Promise<{ + harness: AgentHarness; + suspended: SuspendedOperation[]; + }>; + + lane(name: string): Promise; // lookup, never creates + createLane(name: string, at: string | null): Promise>; + lanes(): Promise; // always includes "main" + + // Harness-global. Tool implementations are code and cannot persist; active + // names live in each lane's configuration. setTools replaces only the registry. + getTools(): Promise[]>; + setTools(t: AgentHarnessTool[]): Promise; + getResources(): Promise; setResources(r: Resources): Promise; + getStreamOptions(): Promise; + setStreamOptions(o: AgentHarnessStreamOptions): Promise; + getRetryPolicy(): Promise; setRetryPolicy(p: RetryPolicy): Promise; + getCompactionSettings(): Promise; + setCompactionSettings(s: CompactionSettings): Promise; + getSteeringMode(): Promise; setSteeringMode(m: QueueMode): Promise; + getFollowUpMode(): Promise; setFollowUpMode(m: QueueMode): Promise; + + watchSession(): Promise<{ snapshot: SessionSnapshot; + start: (l: EventListener) => void; unsubscribe: () => void }>; + + hooks: Hooks; + events: Events; + + /** Detach cleanly (§4.7). Open operations stay resumable. */ + close(): Promise; +} + +interface LaneInfo { + name: string; + leafId: string | null; + operation: null | { id: string; kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting" }; +} + +interface SuspendedOperation { + lane: string; operationId: string; + kind: "run" | "compaction" | "navigation"; + reason: "crash" | "deferred" | "missing_identities"; + startedAt: number; + prompt?: AgentMessage[]; + deferred?: DeferredHandle; + /** Payloads dereferenced from the drained items' surviving pending.entry + registers (§4.6). */ + aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; + missing: { tools: string[]; models: string[] }; +} + +// QueueMode, RetryPolicy, and CompactionSettings use the source types named in §0.7. +``` + +### Options + +```ts +/** AgentHarnessStreamOptions is the curated source type from §0.7. It excludes + signal and provider lifecycle callbacks, which the harness owns. */ +interface AgentHarnessOptions { + session: Session; + models: Models; + + // Immutable lane seed captured at create(). Initializes main when the session + // is first attached, and every lane later created by this harness. Never a + // fallback for a lane that already has a configuration. + model: Model; + thinkingLevel?: ThinkingLevel; // default "off" + activeToolNames?: string[]; // default: initial tool names + + tools?: AgentHarnessTool[]; + toolContext?: TContext | (() => TContext | Promise); + systemPrompt?: string | ((ctx: TContext) => string | Promise); // per request + resources?: Resources; // skills, prompt templates + + streamOptions?: AgentHarnessStreamOptions; + retry?: RetryPolicy; + compaction?: CompactionSettings; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + toolExecution?: "sequential" | "parallel"; // default parallel + drive?: "automatic" | "manual"; // default automatic + + toProviderMessages?: (m: AgentMessage[]) => Message[] | Promise; + entryProjectors?: Record; + /** Existing typed telemetry contract; defaults to no-op. */ + telemetryContext?: TelemetryContext; +} + +type Resources = AgentHarnessResources; +type EntryProjector = (entry: CustomEntry) => + AgentMessage[] | undefined | Promise; +``` + +`create()` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Before restore, it commits that seed as the first `lane.config` for a fresh or normalized-v3 `main`. Existing lanes use only their current config; the seed never overrides them. A configuration-less lane in a format-4 session is corrupt. + +`createLane(name, at)` atomically writes its registers and the original captured seed, regardless of later changes. Setters replace only their lane's register value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through `setStreamOptions({ deferred: ... })` or initial `streamOptions`; `before_request` may patch the same curated field per attempt. + +Initial, replacement, and hook-patched stream options are normalized to detached JSON-safe values before publication because ready states persist them. Functions, symbols, bigint values, cycles, non-finite numbers, and unsupported prototypes in metadata reject construction/the setter without changing settings; an invalid hook patch is isolated as `handler_error` and ignored without changing operation state. Patch deletion semantics are applied before this validation. + +`systemPrompt`, `toolContext`, `toProviderMessages`, and `entryProjectors` are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. `before_run` receives one preview evaluation of `systemPrompt`. A hook override is fixed in `Operation`; without one, the callback is evaluated again per provider request. + +## 5.3 SessionTree + +```ts +interface SessionTree { + getLeafId(): Promise; + getEntry(id: string): Promise; + getStats(): Promise; + + // Global facts. Latest wins; not branch-scoped. undefined deletes the + // register; JSON null is a legitimate custom value. Custom keys cannot + // collide with name or labels. + getName(): Promise; + setName(name: string | undefined): Promise; + getLabel(targetId: string): Promise; + setLabel(targetId: string, label: string | undefined): Promise; + getCustomFact(key: string): Promise; + setCustomFact(key: string, value: JsonValue | undefined): Promise; + + /** Session-wide, all branches, sequence order. */ + findEntries(query?: EntryQuery): Promise; + findEntry(query?: EntryQuery): Promise; + + /** Branch-scoped: the path from start toward root (§2.5). */ + findEntriesOnBranch(query?: BranchScan): Promise; + findEntryOnBranch(query?: BranchScan): Promise; + + // Writes resolve on durable acceptance; the returned id is the entry id, + // reserved when the write defers. + appendMessage(message: AgentMessage): Promise; + appendCustomEntry(customType: string, data?: JsonValue): Promise; +} + +interface EntryQuery { type?: EntryType; customType?: string; + order?: "asc" | "desc"; limit?: number; cursor?: EntryCursor } +interface SessionStats { messageCount: number; usage: Usage } +``` + +Global queries filter first, then apply the exclusive cursor, then `limit`; default order is `"desc"`. A descending cursor retains `seq < cursor.seq`, and an ascending cursor retains `seq > cursor.seq`. + +Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. On a partitioned backend, such walks can reach a retention boundary; branch finders then surface the §2.5 truncation marker instead of returning a silently short path, so "never set" stays distinguishable from "expired". The marker's exact API shape is defined with the rest of the retired-boundary semantics in Part 6; the three shipping backends never truncate. + +`SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getEntry` return only committed entries: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. + +## 5.4 Snapshots and subscription + +```ts +const { snapshot, start, unsubscribe } = await lane.watch(); +await send(client, { kind: "snapshot", snapshot }); // snapshot on the wire first +start((event) => send(client, event)); // flush buffer in order, then live +``` + +`watch()` atomically snapshots and begins buffering. `start(listener)` flushes in order, then delivers live; each event arrives once, in order, without sequence numbers or registration races. `unsubscribe()` drops the watcher and its buffer. A never-started watcher buffers without bound. + +```ts +interface QueuedItem { entryId: string; message: AgentMessage } + +interface LaneSnapshot { + lane: string; + transcript: Entry[]; // this lane's context window plus its compaction entry + leafId: string | null; + + operation: null | { + id: string; + kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting"; + startedAt: number; + suspended?: SuspendedOperation; + streamingMessage?: AssistantMessage; // message_start until entry commit + runningTools: { toolCallId: string; toolName: string; args: unknown; + partialResult?: AgentToolResult }[]; + retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; + }; + + queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; + pendingWrites: { entryId: string; type: EntryType; customType?: string; + message?: AgentMessage; data?: JsonValue }[]; + faulted: boolean; +} + +interface SessionSnapshot { + lanes: (LaneInfo & { suspended?: SuspendedOperation })[]; + faulted: boolean; +} +``` + +`operation.status` derives from durable state plus a process-local suspension marker: `suspended` for deferred, restored, or missing-identity suspension; `aborting` when `control.status === "cancel_requested"`; otherwise `running`. The missing-identity marker stores the exact `SuspendedOperation`, survives until a successful resume attempt or abort in this process, and is reconstructed as `reason:"crash"` after reopen. It changes snapshots but never durable recovery state. `queues` and `pendingWrites` derive from `inbox` and `pendingNextRun`, with content dereferenced from each id's `pending.entry` register; abort-drained items are exposed only through `AbortResult` and `SuspendedOperation.aborting`, never as still-queued. `streamingMessage` and `runningTools` are process-local extras layered on top. + +Rules: + +- Configuration is **not** in snapshots. Getters return current values; `config_update` events tell a UI when to re-read. One source of truth. +- `streamingMessage` is not part of `transcript`. `message_end` replaces it with the final post-hook value but does not clear it; the matching `entry_added` confirms the append, adds the entry to `transcript`, and clears the draft. +- Direct messages and finalized tool results use the same immediate `message_start` → `message_end` lifecycle and enter `transcript` only on `entry_added`. They never populate `streamingMessage`. +- An `aborting` snapshot reports only state that actually exists. It never synthesizes a streaming assistant message. +- Reconnect means a new `watch()`. Only process death loses stream state; a restored harness shows the suspended operation instead. Every entry in the durable transcript is complete — a lost draft was never an entry. +- A lane watcher receives events whose `lane` matches, plus events with no lane. The harness-global `usage` event is the explicit exception: it carries its originating lane but reaches every watcher, because its totals are session-wide. + +## 5.5 Events + +One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot mutate execution, payloads are isolated from procedure state, and a throw produces `handler_error` plus telemetry without affecting execution. Only hooks intercept. + +Durable-fact events fire **after** commit — `entry_added` means queryable. Multi-write events wait for full success, then follow mutation order. Process-local lifecycle events need not be durable: `message_end` precedes the entry insert. + +```ts +type HarnessEventPayload = + // Run lifecycle + | { type: "run_start"; runId: string } + | { type: "run_resume"; runId: string } + | { type: "run_suspend"; runId: string; reason: "deferred"; + deferred: DeferredHandle } + | { type: "run_suspend"; runId: string; reason: "missing_identities"; + missing: { tools: string[]; models: string[] } } + | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } + | ({ type: "run_end"; runId: string; leafId: string | null } & ( + | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) + | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant))) + | { type: "fault"; code: string; message: string } + | ({ type: "handler_error"; error: string; stack?: string } & + ({ kind: "hook"; hook: string } | { kind: "event"; event: string })) + + // Steps and retries. First-try success emits no retry events. + | { type: "turn_start"; runId: string; turnId: string } + | { type: "turn_end"; runId: string; turnId: string; + message: AssistantMessage; toolResults: ToolResultMessage[] } + | { type: "retry_scheduled"; runId: string; step: string; attempt: number; + maxAttempts: number; delayMs: number; errorMessage: string } + | { type: "retry_start"; runId: string; step: string; attempt: number } + | { type: "retry_end"; runId: string; step: string; attempt: number; + success: boolean; finalError?: string } + + // Messages + | { type: "message_start"; runId?: string; message: AgentMessage } + | { type: "message_update"; runId: string; message: AgentMessage; + event: AssistantMessageEvent } + | { type: "message_end"; runId?: string; message: AgentMessage; entryId?: string } + + // Tools + | { type: "tool_start"; runId: string; turnId: string; toolCallId: string; + toolName: string; args: unknown } + | { type: "tool_update"; runId: string; turnId: string; toolCallId: string; + toolName: string; partialResult: AgentToolResult } + | { type: "tool_end"; runId: string; turnId: string; toolCallId: string; + toolName: string; result: AgentToolResult; isError: boolean; terminate: boolean } + + // Tree, queues, facts + | { type: "entry_added"; entry: Entry } + | { type: "write_pending"; runId: string; entryId: string; entryType: EntryType } + | { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; + nextRun: QueuedItem[] } + | ({ type: "fact_update" } & ( + | { fact: "name"; name: string | undefined } + | { fact: "label"; targetId: string; label: string | undefined } + | { fact: "custom"; key: string; value: JsonValue | undefined })) + + // Configuration + | ({ type: "config_update" } & ( + | { property: "model"; value: { provider: string; modelId: string }; previous: unknown } + | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel } + | { property: "activeTools"; value: string[]; previous: string[] } + | { property: "tools" | "resources" | "streamOptions" | "retryPolicy" + | "compactionSettings" | "steeringMode" | "followUpMode" })) + + // Structural + | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" } + | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & ( + | { outcome: "completed"; entry: CompactionEntry; fromHook: boolean } + | { outcome: "declined" | "aborted" } + | { outcome: "failed"; error: OperationError })) + | { type: "navigation_start"; runId: string; targetId: string | null } + | ({ type: "navigation_end"; runId: string; + oldLeafId: string | null; newLeafId: string | null } & ( + | { outcome: "completed"; summaryEntry?: BranchSummaryEntry } + | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never } + | { outcome: "failed"; error: OperationError; summaryEntry?: never })) + + // Lanes and cost + | { type: "lane_created"; at: string | null } + | { type: "usage"; lane: string; row: UsageRow; totals: Usage }; + +type SpecialEventPayload = Extract; +type LaneEventPayload = Exclude; +type ConfigEventPayload = Extract; +type LaneConfigEventPayload = Extract; +type GlobalConfigEventPayload = Exclude; +type HandlerErrorPayload = Extract; + +type HarnessEvent = + | (LaneEventPayload & { lane: string; recovery?: true }) + | (LaneConfigEventPayload & { lane: string; recovery?: true }) + | (Extract & + { lane?: never; recovery?: never }) + | (Extract & { recovery?: never }) + | (GlobalConfigEventPayload & { lane?: never; recovery?: never }) + | (HandlerErrorPayload & ( + | { lane: string; recovery?: true } + | { lane?: never; recovery?: never } + )); + +type HarnessEventType = HarnessEvent["type"]; +type EventListener = + (event: E) => void | Promise; + +interface Events { + on( + type: T, + listener: EventListener>, + ): () => void; +} +``` + +`lane` is required on run/turn/retry/message/tool, entry/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. `handler_error` follows the failed handler's scope. `usage` is the global-delivery exception: base `lane` is absent, while its payload carries the origin lane and the complete ledger row, including its durable `seq` (§1.6). `recovery: true` appears on process-local lifecycle re-emitted by `resume()`, never on events for already-existing durable entries. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage `row.seq` it has applied, preventing a late older event from regressing totals. + +Ordering for a streamed assistant response, asserted exactly by the conformance tests: + +``` +message_start → message_update* → after_response hook → message_end (final value, +optional reserved id) → atomic response + usage + classified-state commit +→ entry_added → usage +``` + +Only `entry_added` proves durability. Classification is computed before the transaction and becomes durable with it; it is not a separate event. Abort and overflow classification may normalize the committed response after `message_end`, so `entry_added` is authoritative for those two cases. A synthetic settlement performs no provider effect, update, or response hook: `message_start → message_end → atomic commit → entry_added → usage`. + +Nesting: + +``` +run_start + message_start / message_end / entry_added consumed prompt and queue messages + turn_start + message_start / message_update* / message_end assistant stream finished + entry_added response committed + tool_start / tool_update* / tool_end per real call + message_start / message_end tool results, source order + entry_added each result committed + turn_end + compaction_start … entry_added … compaction_end auto, at a checkpoint + turn_start … turn_end until nothing is pending +run_end +``` + +Deferred and recovery brackets are deterministic: + +- initial assistant generation uses `turnId = stepId`; a durable deferred response ends that turn, then emits `run_suspend`; +- every application `resume()` emits `run_resume`; `recovery:true` is present only when this harness restored the operation after process loss, not for same-process deferred resume; +- one deferred poll opens a turn whose durable id is `${stepId}:poll:${poll}`. Pending/error/ready settlement and any ready tool batch complete inside that turn, followed by `turn_end` and then suspend/failure/checkpoint; +- restored unresolved tools re-open their persisted `ToolBatch.turnId` with `recovery:true`, emit only new replay/interruption tool lifecycle, then close that recovery turn. Existing message/entry events are never replayed; +- resumed structural work re-emits its structural start with `recovery:true`; structural streams emit no message lifecycle and their typed result alone emits `entry_added`. + +Deferred polls emit no retry lifecycle. Events may contain sensitive conversation and tool content. Serving layers own authorization and redaction. Event payloads are isolated from mutable procedure state. Telemetry alone is content- and secret-free by default. + +## 5.6 Hooks + +Hooks are awaited interception points. Registration is harness-global; every payload carries `lane`. + +```ts +type BeforeResumePrepared = + | { kind: "run"; prompt: AgentMessage[]; systemPromptOverride?: string } + | { kind: "compaction"; sourceLeafId: string | null; + customInstructions?: string } + | { kind: "navigation"; sourceLeafId: string | null; targetId: string | null; + summarize: boolean; label?: string; customInstructions?: string }; + +interface HookMap { + before_run: { + event: { prompt: AgentMessage[]; systemPrompt: string; resources: Resources }; + result: { messages?: AgentMessage[]; systemPrompt?: string; resumeData?: JsonValue } | undefined; + }; + before_resume: { + event: BeforeResumePrepared & { resumeData?: JsonValue }; + result: void; + }; + before_run_end: { + event: { runId: string; messages: AgentMessage[] }; + result: { followUp?: string } | undefined; + }; + transform_context: { + event: { messages: AgentMessage[] }; + result: { messages: AgentMessage[] } | undefined; + }; + before_request: { + event: { model: Model; + step: "assistant" | "deferred" | "compaction" | "branch_summary"; + attempt: number; streamOptions: AgentHarnessStreamOptions }; + result: { streamOptions?: AgentHarnessStreamOptionsPatch } | undefined; + }; + before_payload: { + event: { model: Model; payload: unknown }; + result: { payload: unknown } | undefined; + }; + after_response: { + event: { status?: number; headers?: Record; + message: SettledAssistantMessage }; + result: { message?: SettledAssistantMessage } | undefined; + }; + before_tool: { + event: { toolCallId: string; toolName: string; args: Record }; + result: { args?: Record; + block?: { reason: string; terminate?: boolean } } | undefined; + }; + after_tool: { + event: { toolCallId: string; toolName: string; args: Record; + content: AgentToolResult["content"]; details?: JsonValue; + isError: boolean; usage?: Usage }; + result: { content?: AgentToolResult["content"]; details?: JsonValue; + isError?: boolean; usage?: Usage; terminate?: boolean } | undefined; + }; + before_compaction: { + event: { reason: "manual" | "threshold" | "overflow"; + preparation: CompactionPreparation; customInstructions?: string }; + result: { decline?: boolean; compaction?: CompactResult } | undefined; + }; + before_navigation: { + event: { targetId: string; preparation: BranchPreparation; + customInstructions?: string }; + result: { decline?: boolean; summary?: BranchSummaryResult } | undefined; + }; +} + +type HookName = keyof HookMap; +type HookInvocation = HookMap[K]["event"] & { + lane: string; + /** Durable operation id, provisional for pre-acceptance before_run. */ + runId: string; +}; +type HookHandler = + (event: HookInvocation) => Promise | HookMap[K]["result"]; + +interface Hooks { + on(name: K, handler: HookHandler, + options?: { id?: string }): () => void; +} +``` + +Uniform semantics: + +- `before_run` and `before_resume` require a stable `id`, unique within each hook name; duplicates reject synchronously. An extension reuses its id across both hooks and across restarts; the runner stores `resumeData` by id and gives each resume handler only its own value. +- Handlers run in registration order, each seeing the prior output. `messages` append; `systemPrompt` replaces. +- A throw emits `handler_error`, skips that handler, and lets the rest continue. **`before_tool` instead fails closed and blocks the tool.** +- Durable hook outputs commit before execution continues. A return alone is not durable; a pre-commit crash may rerun the hook. +- Events expose post-hook values. Passive listeners cannot transform them. + +One `EffectPlan{kind:"hook"}` runs the complete registered pipeline for that hook name and returns its final aggregate; individual handlers are not separate durable/manual actions. The runner still isolates and telemetry-wraps each handler internally. Aggregation is deterministic: + +- `before_run` appends messages and lets the latest defined system prompt replace the prior one; resume data is stored under each handler id. +- context/request/payload/response and `after_tool` transformations run in registration order, each seeing the prior transformed value; option/result patches merge field by field. +- `before_tool` argument replacements chain and are revalidated; the first block is terminal and later handlers do not run. +- `before_compaction`/`before_navigation` stop at the first decline or supplied result; if all handlers return neither, generation is selected. Returning decline plus a result is a handler error and is ignored like a throw. +- `before_run_end` uses the latest defined follow-up. + +| Hook | When | Event | Result | +|---|---|---|---| +| `before_run` | once, before acceptance, outside the mutation line | `{ prompt, systemPrompt, resources }` | `{ messages?, systemPrompt?, resumeData? }` | +| `before_resume` | on `resume()`, before any effect; must be idempotent | `BeforeResumePrepared + { lane, runId, resumeData? }` | `void` | +| `before_run_end` | at a normal finish boundary | `{ runId, messages }` | `{ followUp? }` | +| `transform_context` | per request, `AgentMessage` level, before `toProviderMessages` | `{ messages }` | `{ messages }` | +| `before_request` | per request, provider-neutral options | `{ model, step, attempt, streamOptions }` | `{ streamOptions? }` | +| `before_payload` | per request, provider-specific wire payload | `{ model, payload }` | `{ payload }` | +| `after_response` | per response, after streaming settles, before `message_end` and the commit | `{ status, headers, message }` | `{ message? }` (must keep role) | +| `before_tool` | after validation, before execution | `{ toolCallId, toolName, args }` | `{ args?, block?: { reason: string; terminate?: boolean } }` | +| `after_tool` | after execution, before the result commits; patch semantics | `{ toolCallId, toolName, args, content, details, isError, usage? }` | `{ content?, details?, isError?, usage?, terminate? }` | +| `before_compaction` | in `deciding` | `{ reason, preparation, customInstructions? }` | `{ decline?, compaction? }` | +| `before_navigation` | in `deciding` | `{ targetId, preparation, customInstructions? }` | `{ decline?, summary? }` | + +`before_request` receives `AgentHarnessStreamOptions` and returns `AgentHarnessStreamOptionsPatch`; neither can contain a signal or provider lifecycle callback. `after_response` must preserve the assistant role and may return `aborted` only when the harness signal is already aborted. `before_navigation` runs only for summarized navigation; unsummarized navigation cannot decline. + +Replay across retry and resume: + +| Hook | fresh | retry | resume | +|---|---|---|---| +| `before_run` | once | no | no (persisted in `Operation`) | +| `before_resume` | no | no | yes, idempotent | +| `transform_context`, `before_request`, `before_payload` | per request | yes | yes | +| `after_response` | per response unless abort wins before it starts | per response | same rule | +| `before_tool` | per call | — | not when the call is already `effect_pending` | +| `after_tool` | per executed result unless abort wins before it starts | — | on safe replay only, with the same abort rule | +| `before_compaction`, `before_navigation` | once, until a structural source commits | no | never once `generating` is durable | +| `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | + +`before_run_end` may fire again after a crash at the same boundary. Handlers that must not double-fire keep their own durable marker. This is the exactly-once non-goal (§0.6) surfacing in the hook layer. + +## 5.7 Agent-loop building blocks + +The existing `agent-loop.ts` remains behavior-compatible and is refactored into these exported phases. Existing fields on `AgentTool`, `AgentToolResult`, and provider messages are retained. Add recovery declaration `replay?: "never" | "safe"` to `AgentTool`; omission means `"never"`. `AgentHarnessTool` inherits it. The `AgentEventSink` below is the existing agent-loop sink, not the harness event listener; the harness adapts agent events into §5.5 events. + +```ts +interface StreamAssistantConfig { + model: Model; + thinkingLevel: ThinkingLevel; + systemPrompt?: string; + tools?: AgentTool[]; + transformContext?: (messages: AgentMessage[], signal: AbortSignal) => + Promise; + toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise; + requests: ModelRequestLease; // no registry re-resolution + streamOptions?: AgentHarnessStreamOptions; + /** Harness-owned before_payload adapter; undefined keeps the payload. */ + transformPayload?: (payload: unknown, model: Model) => + unknown | undefined | Promise; + /** Final settled-message transform used by after_response, before message_end. */ + transformResponse?: (message: SettledAssistantMessage, + metadata: { status?: number; headers?: Record }) => + Promise; + telemetryContext: TelemetryContext; + signal: AbortSignal; +} + +function streamAssistant(messages: AgentMessage[], config: StreamAssistantConfig, + emit: AgentEventSink): Promise; +// The implementation converts curated streamOptions to provider options and +// installs harness-owned payload/response callbacks; callers cannot replace them. +// Existing summary helpers gain ModelRequestLease overloads and use the same +// bound request path for every split request. + +type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; + tool: AgentTool; args: Record }; +type ImmediateOutcome = { kind: "immediate"; result: AgentToolResult; + isError: true; terminate: boolean }; +type FinalizedToolCall = { toolCall: AgentToolCall; result: AgentToolResult; + isError: boolean; terminate: boolean }; + +interface ToolCallbacks { + beforeToolCall?(call: AgentToolCall, args: Record): + Promise; + afterToolCall?(call: AgentToolCall, args: Record, + result: AgentToolResult, isError: boolean): + Promise; + executeTool?(call: PreparedToolCall): + Promise<{ result: AgentToolResult; isError: boolean }>; + onToolStart?(call: AgentToolCall, effectiveArgs: Record): Promise; + onToolResult?(call: AgentToolCall, message: ToolResultMessage, + terminate: boolean): Promise; +} + +function prepareToolCall(call: AgentToolCall, tools: AgentTool[], callbacks: ToolCallbacks, + telemetry: TelemetryContext, signal: AbortSignal): + Promise; +function executeToolCall(call: PreparedToolCall, emit: AgentEventSink, + telemetry: TelemetryContext, signal: AbortSignal): + Promise<{ result: AgentToolResult; isError: boolean }>; +function finalizeToolCall(call: PreparedToolCall, + executed: { result: AgentToolResult; isError: boolean }, + callbacks: ToolCallbacks, telemetry: TelemetryContext, + signal: AbortSignal): Promise; +``` + +External output that violates durable JSON/schema contracts is converted before settlement: an invalid provider message becomes a synthetic assistant `error` under the reserved response id; an invalid tool result becomes a synthetic error under its planned result id. Valid reported usage is retained when it can be validated independently, otherwise the synthetic entry reports zero. Invalid hook output is handled like a throwing handler (`before_tool` still fails closed); invalid caller input returns `InvalidMessage` before acceptance. No invalid payload reaches `Storage.commit()`. + +`AgentTool.prepareArguments` is deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. `ToolCallbacks` contains the existing before/after callbacks plus `executeTool`, `onToolStart`, and `onToolResult` durability callbacks described in §3.8. `onToolStart` receives effective arguments after `prepareArguments`, validation, and `before_tool`; `onToolResult` receives the finalized message and terminate decision. Blocked calls may terminate when `before_tool.block.terminate` is true. Replacement arguments are validated again. + +For each live tool batch, the harness resolves `toolContext` exactly once, caches bound `AgentHarnessTool` adapters in `DriveState.toolBatches`, and passes that same context as the fifth execute argument for every call. Safe replay after restart creates one new batch snapshot; context is environmental and never persisted. + +`executeToolBatch` preserves the existing sequential/parallel behavior: source-ordered preparation and dispatch, concurrent effects in parallel mode, source-ordered finalization/results, no effect for blocked/invalid/genuine-length calls, and `terminate: true` only when every finalized outcome terminates. Compatibility wrappers keep existing public loop signatures and events. + +## 5.8 Telemetry + +Use the existing callback-based `TelemetryContext`, no-op/reference implementations, typed schema machinery, and agent-owned schemas. Do not invent a second contract. Context is passed explicitly; no core `AsyncLocalStorage` or global active span. + +Required spans remain: + +```text +pi.harness.run | compaction | navigation +pi.harness.checkpoint | turn | step | tool | hook | sleep | event_handler +pi.session.write +pi.ai.request +``` + +Operation, step, tool, hook, event, and write parents follow the actual interpreter/effect nesting. Sleep spans permit run, compaction, navigation, turn, and checkpoint parents. `stepId`/`taskId` correlate retries and recovery. Every provider request/fetch/cancel uses `pi.ai.request`; each real or safely replayed phase-two tool effect uses one tool span. + +Every storage transaction uses one `pi.session.write`. Its start attributes include `pi.session.item_count` and `pi.session.item_kinds` (`entry`, `usage`, `register`). A calling procedure may supply its lane/operation ids; storage never infers them from payloads. End attributes include first and last committed sequence. Update the existing schema from old single-mutation vocabulary to this transaction shape; no span is emitted for a conditional no-write result. Synthetic settlements and blocked/invalid tools emit no provider/tool-effect span. + +Telemetry attributes may contain declared ids, names, counts, durations, statuses, and usage. They must never contain prompts, completions, tool arguments/results, file contents, provider payloads, headers, handles, or credentials. Events and hooks may contain such content. The existing generated schema document and adapter/runtime conformance tests remain authoritative; implementation slices extend instrumentation only through those schemas. # Part 6 — Retention and partitioning `[NEW: jot 5, 9]` @@ -1438,7 +2588,7 @@ The invariant this section carries (restated in Part 9): `op.*` registers and op # Part 9 — Invariants and tests `[REWRITE: jot 8]` -- The six restated invariants (write-once entries/usage; one home per payload; op registers ⇔ open operation; two reservation regimes; observation contract; prefix-retired = boundary vs prefix-live = corruption). +- The six restated invariants (write-once entries/usage; one home per payload; op registers ⇔ open operation; two reservation regimes; observation contract; prefix-retired = boundary vs prefix-live = corruption), plus the surviving base §7.1 invariants — aborted-implies-cancelled, acceptance observes null operation, reserved-id/content pairing, one open operation per lane, overflow-flag rules, current-state validation on every decode. - Race catalog `[CARRY]`. - Test tiers `[CARRY+]` — Tier B oracle is the instrumented-storage decorator recording `commit(tx)`; backend conformance drops `getLog` equality; add retention-boundary tier exercised via the abstract retired-range set on Memory/JSONL. From ec317e4ede466cf98617a4e664a834c5eb5f47e2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 01:28:09 +0200 Subject: [PATCH 089/284] docs(agent): write harness-v3 parts 6-7 --- packages/agent/docs/harness-v3.md | 247 ++++++++++++++++++++++++++++-- 1 file changed, 230 insertions(+), 17 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 9a2483476d0..6d5166614d6 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -252,7 +252,7 @@ parent missing, id prefix in a retired period → retention boundary — cle parent missing, id prefix in a live period → corruption — loud ``` -Memory, JSONL, and SQLite never retire periods, so the middle case is unreachable there: a missing parent is always corruption. The rules are still core — branch scans and forks must implement the boundary stop (§2.5, §2.7) — but only the future Postgres backend (§1.7) and the conformance suite's abstract retired-range set (Part 9) exercise it. +Memory, JSONL, and SQLite never retire periods themselves, so with an empty retired-range set — the default — the middle case is unreachable there and a missing parent is always corruption. The rules are still core — branch scans and forks must implement the boundary stop (§2.5, §2.7) — but the middle case is exercised only where the retired-range inventory (§6.4) is non-empty: the future Postgres backend (§1.7), sessions truncated by retention compaction or fork import, and the conformance suite's abstract retired-range set (Part 9). ## 1.3 Register namespaces @@ -705,7 +705,7 @@ type EntryCursor = { seq: number }; Semantics: take the path from `start` toward the root, order it (default `newestFirst`), stop **inclusively** at the first `stopAt` match, filter by `type`/`customType`, apply the exclusive cursor, then apply `limit`. For `newestFirst`, a cursor retains `seq < cursor.seq`; for `oldestFirst`, it retains `seq > cursor.seq`. A `stopAt` entry is returned only if it also passes the filter. -**Retention boundaries.** On a backend with retired partitions, a scan that reaches an entry whose `parentId` decodes to a retired period stops there cleanly, as if at a root (§1.2). The stop must be explicit at public surfaces: branch finders report a truncation marker — `truncatedAt: { parentId }`, the partition being the id's own time prefix — never a silently short result, because extension-state lookups walk past compactions by design (§5.3) and must distinguish "never set" from "expired". Storage itself needs no extra channel: the marker derives from the last returned entry's `parentId`. The three shipping backends never truncate. +**Retention boundaries.** On a backend with retired partitions, a scan that reaches an entry whose `parentId` decodes to a retired period stops there cleanly, as if at a root (§1.2). The stop must be explicit at public surfaces: branch finders report a truncation marker — `truncatedAt: { parentId }`, the partition being the id's own time prefix — never a silently short result, because extension-state lookups walk past compactions by design (§5.3) and must distinguish "never set" from "expired". Storage itself needs no extra channel: the marker derives from the last returned entry's `parentId`. The three shipping backends never truncate while their per-session retired-range set is empty, which is the default (§6.4). **Context projection** — how a provider request is built: @@ -1951,6 +1951,7 @@ Expected errors use the existing `TaggedError` implementation in `harness/result | `UnknownSkill`, `UnknownTemplate` | `name` | | `UnknownTarget` | `targetId` | | `LaneExists`, `InvalidLane` | `lane` (`InvalidLane` also has `reason`) | +| `LaneExpired` | `lane`, `leafId` — partitioned-backend expired-lane condition (§6.4) | | `Closed` | none | ```ts @@ -2131,7 +2132,7 @@ interface SessionStats { messageCount: number; usage: Usage } Global queries filter first, then apply the exclusive cursor, then `limit`; default order is `"desc"`. A descending cursor retains `seq < cursor.seq`, and an ascending cursor retains `seq > cursor.seq`. -Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. On a partitioned backend, such walks can reach a retention boundary; branch finders then surface the §2.5 truncation marker instead of returning a silently short path, so "never set" stays distinguishable from "expired". The marker's exact API shape is defined with the rest of the retired-boundary semantics in Part 6; the three shipping backends never truncate. +Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. On a partitioned backend, such walks can reach a retention boundary; branch finders then surface the §2.5 truncation marker instead of returning a silently short path, so "never set" stays distinguishable from "expired". The marker's exact API shape is defined with the rest of the retired-boundary semantics in Part 6; the three shipping backends never truncate while their retired-range set is empty (§6.4). `SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getEntry` return only committed entries: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. @@ -2564,23 +2565,235 @@ Every storage transaction uses one `pi.session.write`. Its start attributes incl Telemetry attributes may contain declared ids, names, counts, durations, statuses, and usage. They must never contain prompts, completions, tool arguments/results, file contents, provider payloads, headers, handles, or credentials. Events and hooks may contain such content. The existing generated schema document and adapter/runtime conformance tests remain authoritative; implementation slices extend instrumentation only through those schemas. -# Part 6 — Retention and partitioning `[NEW: jot 5, 9]` +# Part 6 — Retention and partitioning -- Three lifecycles (operation cleanup / context compaction / retention) and why they never couple. -- Postgres layout, hot catalog vs partitions, expiry protocol (seal → inventory aggregates → detach → drop; recoverable). -- Placement via id minting; follower inheritance; late-placement pins; drop preflight = bounded register scan (pending + open-op reserved ids) + per-lane compaction horizon. -- Retired-boundary semantics for traversal, lanes (expired-lane condition), labels, forks. -- Yes/no-dialog worked example (jot 9). -- Expiry ≠ deletion (`retainedTail` copies forward); compliance deletion = precise rewrite. -- Precise rewrite mechanism (copy-retained-and-swap; JSONL snapshot compaction with a keep-predicate is the same operation). -- Lease constraint: retention daemon does lease-free DDL/inventory only; all per-session repair is lazy, owner-executed. +This part exists for one backend — the planned Postgres deployment (§1.7) — but its rules are core: identity (§1.2), branch segments (§2.6), scans (§2.5), and forks (§2.7) all carry obligations that only make sense against the retention design stated here. Memory, JSONL, and SQLite never retire periods; they meet this part through the retired-range inventory (§6.4), which for them is normally empty. -# Part 7 — Schema evolution `[NEW: jot 6]` +## 6.1 Three lifecycles, three mechanisms -- `storageVersion`, migrate-on-open under the writer lease, chained migrations. -- The settlement kernel (stable minimal fragment of `op.state`); migrate-or-force-settle rule for open operations. -- The three strata: entries/usage stable forever; lane/fact registers migrate mechanically; `op.*`/`pending.*` may churn. -- JSONL lenient replay of superseded shapes; compaction after migration. +```text +operation cleanup register deletion at the terminal TX continuous, invisible (§3.13) +context compaction provider-context only, never deletion an ordinary entry (§2.5) +conversation retention + ├─ partition expiry drop whole retired periods fast, routine TTL (§6.2) + └─ precise rewrite copy-retained-and-swap surgical, administrative (§6.6) +``` + +They never couple. Operation cleanup is orchestration hygiene: it deletes registers, never entries or ledger rows, and finishes inside the terminal transaction. Compaction changes what a provider sees, never what storage holds: a compaction entry is one more append, and everything before it stays queryable. Retention alone removes rows — and it consults orchestration state only through the bounded pin scan in §6.3, never through any per-entry lifecycle marker. There are no such markers to maintain, which is why the first two mechanisms can run forever without creating retention work. + +## 6.2 Physical layout and the expiry protocol + +The §1.7 sketch splits the Postgres database into a hot unpartitioned catalog — registers, `branch_meta`, the partition inventory, stats, leases, sessions — and period-partitioned bulk tables: entries, the usage ledger, branch index rows, the FTS projection. The bulk DDL follows directly from §1.2, because the id *is* the partition key: + +```sql +CREATE TABLE entries ( + session_id text, + id uuid, -- UUIDv7; the time prefix is the partition assignment + parent_id uuid, + seq bigint, + type text, + custom_type text, + timestamp bigint, + payload jsonb, + PRIMARY KEY (session_id, id) +) PARTITION BY RANGE (id); + +-- Bounds are period-boundary UUIDv7s: the boundary timestamp, zeroed tail. +CREATE TABLE entries_2027_01 PARTITION OF entries + FOR VALUES FROM ('') + TO (''); +``` + +Two classic partitioning taxes disappear because the key lives inside the id. First, no partition column invades the schema or the primary key: `PRIMARY KEY (session_id, id)` covers the partition key, and because an id determines its partition, per-partition uniqueness is global uniqueness. Second, no global-index fan-out: `getEntries` prunes to one partition from each id's own prefix instead of probing every partition's index after years of monthly partitions — the hottest read path stays one index visit per id. The ledger, branch index rows, and FTS projection partition the same way and die with their entries; §2.6's partition-purity rules keep segment rows in the partitions of the entries they index, so this holds for the branch index by construction. + +One database also means one transaction spans hot registers and partitioned entries (§1.7): acceptance, settlement, and terminal transactions keep exactly the shapes Part 3 specifies. + +**The expiry protocol.** Dropping period P is not one atomic step, because `DETACH CONCURRENTLY` is not transactional. It is a small recoverable protocol driven by P's inventory row: + +```text +1. preflight §6.3: refuse while any pin or compaction horizon covers P +2. seal mark P frozen in the inventory +3. aggregate fold P's per-session usage totals into the inventory +4. detach DETACH PARTITION CONCURRENTLY +5. drop DROP TABLE; record P's range as retired in the inventory +``` + +A crash between steps redoes the step the inventory names; every step is idempotent. Sealing is safe because a passed preflight implies nothing can write into P again: new ids mint with `now()`, and follower ids mint only inside open operations, which preflight already enumerated. The aggregates exist because ledger rows are about to disappear: `session_stats` stays valid (it is already an aggregate), but rebuildability — the rule that projections can always be recomputed from the three stores — now needs the inventory to stand in for the dropped rows, and per-period accounting survives only there. + +## 6.3 Pins, preflight, and the compaction horizon + +What must block a drop is exactly what retained state still needs. All of it is enumerable from hot registers — nothing requires scanning partitioned data: + +- **Unplaced reservations.** Every `pending.entry` key is a UUIDv7; a queued January message not yet consumed pins January (§1.2). `listRegisters("pending.entry")`, decode the keys, take the minimum. +- **Open-operation reservations.** Reserved response/result/usage ids inside open `op.state` values pin their partitions. Open operations are reachable through `lane.state` registers, so this scan is bounded by lane count. +- **The compaction horizon.** An open operation must be able to rebuild its provider context, and every lane must stay projectable: context reads the newest compaction at or below the leaf plus everything after it (§2.5). So the hard rule: **a drop may never remove a lane's newest compaction.** Partition P is droppable only if every lane's branch has a compaction — or its root — in a retained partition newer than P. The leaf needs its own check: a late-placed entry (§1.2 rule 4) can put a leaf's prefix *behind* the newest compaction's partition, so preflight also scans every `lane.leaf` register — one hot register per lane — and refuses to drop a partition any leaf decodes into. + +Preflight is those three checks. A deployment where they pass drops P with no per-entry bookkeeping, no reference counting, and no scan of P itself. + +**Abandoned pins.** A crashed operation nobody resumes, or a queued item nobody consumes or cancels, pins its partitions forever. Policy must therefore include an administrative **force-expiry** for over-age pins, built from machinery that already exists: force-settling an open operation is §4.5's synthetic settlement — interrupted/aborted results under exactly the reserved ids, inbox drained, terminal cleanup, `lane.lastResult` recording the outcome (Part 7 reuses the same mechanism for upgrades). Stale-queue expiry deletes an abandoned `pending.entry` register through the cancellation path (§3.11); a later `cancelQueued` answers `not_found`, which retrying clients already treat as success. Dormant never-compacted lanes either pin storage or fall under an explicit expired-lane policy (§6.4) — a product decision, not a storage decision (Appendix D). + +### Worked example — the yes/no dialog + +An assistant turn settles in January with one ask-the-user tool call. The result id is minted at settlement as a follower (§1.2), so it carries January's timestamp. The user answers in April; the result entry inserts into the January partition — which the open operation pinned the whole time. + +- While the operation is open: January is undroppable, so nothing is ever lost. The cost is retention lag on one partition. +- If policy force-expires the abandoned operation instead: a synthetic interrupted result lands under the already-reserved January id, beside its assistant. January's pins clear, the partition becomes droppable, and the exchange later disappears **as a unit** — never half of it. That is the follower rule doing its job: at every moment, the assistant and its results are either both retained or both gone. + +## 6.4 Retired-boundary semantics + +**The retired-range inventory.** Classification needs one datum: which id-prefix ranges are retired. It is the union of two sources — the deployment's partition inventory (partitioned backends only, hot catalog, shared because partitions are shared across sessions) and a per-session retired-range set in the catalog or header (all backends, normally empty). Memory, JSONL, and SQLite never run expiry, so their per-session set becomes non-empty in exactly three ways: a JSONL retention compaction records the ranges it pruned (§6.6), a fork import inherits ranges from a truncated source (below), and the conformance suite populates it directly to make boundary states testable on every backend (Part 9). Classification is then uniform everywhere, exactly as §1.2 states: parent present → continue; parent missing with a retired prefix → boundary; parent missing with a live prefix → corruption. + +**Traversal.** Scans stop cleanly at a boundary (§2.5); segment chains truncate lazily on first access, with no eager `branch_meta` rebuild at drop time (§2.6). The public marker whose shape §5.3 defers here is one `SessionTree` method, present on all backends and trivially null wherever the inventory is empty: + +```ts +/** Non-null when the path from start (default: the view's lane leaf) toward + the root ends at a retention boundary rather than a true root (§2.5). The + partition is the parentId's own time prefix. */ +getRetentionBoundary(start?: string): Promise<{ parentId: string } | null>; +``` + +Bulk finders stay `Entry[]`-shaped; a serving layer that pages branch scans attaches `truncatedAt: { parentId }` by reading the last returned entry's `parentId` against the inventory, or by calling this method — the two always agree. What makes the marker sufficient: after `findEntryOnBranch({ customType })` returns `undefined`, one `getRetentionBoundary()` call distinguishes "never set" from "possibly expired". + +**Expired lanes.** Under the default preflight an expired lane cannot exist — the compaction horizon and the leaf scan keep every lane's leaf retained (§6.3). The condition arises only when a deployment adopts a policy that expires dormant never-compacted lanes rather than letting them pin storage. When adopted: a lane whose leaf id decodes to a retired period enters an explicit **expired** condition on first access — detected lazily by the owning harness, never marked by the daemon (§6.7). Reads report the condition; state-mutating calls fail with the expected error `LaneExpired` — with one exemption: `navigateTree` to a retained entry is the rebase operation and is always admitted — after which the lane is ordinary again. Whether a deployment also auto-rebases to the boundary is the open product question (Appendix D). + +**Labels.** A `fact.label` register whose key decodes to a retired period reads as absent. The owning harness may delete it lazily on that access; an eager pre-drop sweep by each owning harness — never the daemon (§6.7) — is a legal optimization (the keys are ids and classify with no lookup) but never required, because a stale label register is harmless. + +**Forks — resolving §2.7.** A fork whose source path crosses a boundary copies exactly what a scan returns: the boundary entry becomes a retained root in the destination, keeping its original `parentId`. The same import copies the source's relevant retired ranges into the destination's per-session set. That single rule closes the question §2.7 deferred: the destination classifies its inherited dangling references by the ordinary §1.2 rules, on every backend — a Memory, JSONL, or SQLite destination never *retires* anything itself, but it can *hold* a session whose inventory says some ranges are gone, and that is all classification needs. Without the inventory copy, the dangling parent would carry a live-looking prefix and the destination would be indistinguishable from corruption. + +## 6.5 What expiry does not do + +`retainedTail` copies old messages verbatim into newer compaction entries; branch summaries derive from old content; the FTS projection still indexes retained compactions. **Partition expiry is cost and TTL retention, not erasure** (§0.6). Content originating in a dropped period can survive indefinitely in derived form. A compliance-grade "erase this" must use the precise rewrite, which can apply a content predicate to everything — including copied-forward tails and summaries. This is a contract statement for the serving layer, not an implementation detail. + +## 6.6 The precise rewrite + +The second mechanism, for everything expiry cannot express: per-branch policies, redacting copied-forward content, pruning abandoned branches, compliance erasure, migrating never-partitioned legacy sessions. + +```text +snapshot → copy the retained set into a fresh store O(retained), online + → keep recording live writes against the old +freeze → seal commit admission briefly +swap → apply the small tail, swap, unlink the old +``` + +Never `DELETE … NOT IN (keep_set)` over years of rows while holding a write freeze — that stop-the-world is what this design exists to avoid. The copy runs against a coherent snapshot exactly as forks do (§2.8), the freeze covers only the tail replay, and the swap is atomic per backend: a rename, a catalog switch. + +On JSONL the operation already exists: snapshot compaction (§1.7) is the same rewrite with a different keep-predicate: + +```text +GC compaction: keep = live state drop dead lines +retention compaction: keep = live state ∩ policy also drop pruned entries and + usage rows; fold pruned usage + into a header aggregate so + getStats() totals survive + (§1.6); record the pruned + ranges in the retired-range + inventory (§6.4) +``` + +One rewrite path, two filters. Partition expiry remains the partitioned-backend fast path; a JSONL session that wants TTL retention pays O(retained) at compaction time, which is fine at JSONL's scale — coding-agent sessions, not seven-year Slack channels. + +## 6.7 Who runs retention + +Sessions are owned by one fenced writer (§1.7); date partitions are shared by hundreds of sessions; the retention daemon owns none of them. So the daemon performs **only lease-free global actions** — preflight reads, inventory updates, and DDL. It never takes a session's writer lease and never writes a session's registers. Every per-session consequence is executed lazily by the owning harness on next access: expired-lane detection, label cleanup, branch-chain truncation, usage-aggregate visibility. That single constraint decides the lazy-versus-eager questions in favor of lazy — an eager design would require the daemon to acquire every affected session's lease, turning routine TTL into a coordination problem with every live harness. + +Force-expiry (§6.3) is the one retention action that must write session state, so it is not the daemon's: it runs through an owning harness — opened administratively if need be — under the ordinary lease, using the ordinary synthetic-settlement machinery. + +What stays open — per-session retention length versus shared date partitions, expired-lane product semantics, the partition of entry-less usage rows, Postgres partition-count operational limits, and measuring the pending-payload double write — is collected in Appendix D. + +# Part 7 — Schema evolution + +## 7.1 The problem + +Full durability means snapshotting in-flight state, and in-flight state has the shape of *today's* state machine. Ship a new version with a different machine and the durable state written by the old one still exists — mid-run, mid-batch, mid-drain. Most durable-execution systems answer this badly or not at all. This design cannot: sessions are long-lived by intent, and Part 6 plans for years of them. + +## 7.2 Why this design shrinks the problem + +Migration cost is proportional to what must be converted. The superseded value/history design would have had to convert — or version-read forever — years of dead operation-state values and history rows. This design deleted all of that (§1.8): + +```text +what exists at upgrade time migration burden +──────────────────────────── ──────────────── +entries, usage rows (years) cannot rewrite — must stay read-compatible +lane/fact registers (a few per lane) trivial: a for-loop at open +op.* registers only for OPEN operations — usually zero +pending.entry registers open-operation inbox items plus + lane-owned queued nextRun items +``` + +Deleting history is what makes migrate-on-open tractable at all: the entire mutable surface is a few dozen current registers. And the fenced single-writer lease (§1.7) means the opening process owns the session exclusively — migration has no concurrency story to solve. + +## 7.3 The mechanism: storage version plus migrate-on-open + +One session-level `storageVersion` lives in the catalog or header (§1.7, §2.8). A version number is preferable to versioned namespace suffixes (`lane.state.v2`): one number to check, chained `v1→v2→v3` migrations, no probing of historical namespace names, and register keys stay stable for point lookups. + +```text +open session: + version == current → proceed + version < current → run migrations in order, each one transaction: + convert lane/fact/pending register values + handle open operations (§7.4) + bump the version + version > current → refuse to open (older binary, newer session) +``` + +Chained migrations run under the writer lease before `open()` returns (§2.8). Each step commits its conversions and version bump atomically, so a crash mid-chain resumes at the recorded version; conversions must be idempotent over already-converted values, which field mappings are by construction. + +JSONL has one wrinkle in each direction. Replay must decode superseded old-shape register lines leniently — as keyed raw JSON, overwrite-by-key only — because pre-migration bytes remain in the file (§1.7). And a migration must trigger snapshot compaction, whose temp-file-and-rename both persists the new header version atomically and retires the old-shape bytes. Between crash and compaction, lenient replay plus idempotent conversion make the intermediate state harmless. + +Legacy coding-agent format 3 predates `storageVersion` entirely; it normalizes through Appendix C on load and receives the current version with its first format-4 write. + +## 7.4 What the version cannot do — and the settlement kernel + +Register conversion is a field mapping. A state-machine shape change is not. If the next version removes `failure_drain`, or restructures the tool-batch lifecycle, an old `op.state` sitting mid-`failure_drain` has no equivalent in the new machine — "convert the record" is simply not a defined operation, and no encoding trick answers "where does this in-flight operation land?" + +The escape hatch already exists. §4.5's crash recovery can force-settle any open operation from a tiny fragment of its state: the reserved ids awaiting settlement, the pending-entry ids, and the control status — synthetic interrupted or aborted results under exactly those reserved ids, inbox drained, terminal cleanup, lane idle. Entries and the ledger are untouched. Freeze that fragment as the **settlement kernel**: a minimal, versioned-never projection of the lane's open-operation state that every future version must keep decodable: + +```ts +interface SettlementKernel { + operationId: string; + kind: "run" | "compaction" | "navigation"; + control: "running" | "cancel_requested"; + reservedEntryIds: string[]; // response/result ids awaiting settlement + reservedUsageIds: string[]; + pendingEntryIds: string[]; // inbox + drained + pendingNextRun refs +} +``` + +The kernel is drawn from `op.state` plus the lane's queue refs. `pendingEntryIds` includes `pendingNextRun` so a migration can locate every `pending.entry` register whose payload shape it may need to convert; force-settlement itself still deletes only the operation-owned subset — inbox and drained items, never `pendingNextRun` (§3.13). + +The upgrade rule then covers every case: + +```text +per open operation at migration time: + semantic migration defined for this transition? → convert op.state + otherwise → force-settle via the kernel: + synthetic "interrupted by upgrade" + under the reserved ids, + terminal cleanup; + lane.lastResult records the outcome +``` + +Worst case, an in-flight run ends "interrupted" — indistinguishable from a crash, which the application already handles through the ordinary reconciliation path (§3.13, §5.1). No session is ever bricked by a state-machine redesign, and no version ever carries old-machine semantics forward. This is the same machinery Part 6 uses for force-expiry: one synthetic-settlement kernel, two administrative callers. + +## 7.5 The three strata, restated as policy + +```text +entries + usage the stability budget goes HERE. Payloads are provider-shaped + messages plus three simple structural types; changes must be + read-compatible forever, because partitions cannot be + rewritten at open time — the precise rewrite (§6.6) exists, + but it is administrative, not an open-time step. Custom + entry payloads are the application's contract. + +lane / fact migrate on open, mechanically. A few registers per lane, +registers cheap forever. + +op.* / pending.* ephemeral by construction. Migrate when convenient, + force-settle when not. This is where the state machine is + allowed to churn freely between versions. +``` + +The design conclusion: the volatile part of the system — orchestration — was made ephemeral, and the durable part — the conversation — was made structurally boring. Schema evolution is exactly as hard as the boring part, which is the best available outcome. # Part 8 — Build order `[REWRITE]` From cd6852a123f2c0cc646a41a2a52f3711a603b822 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 01:39:13 +0200 Subject: [PATCH 090/284] docs(agent): complete harness-v3 parts 8-9 and appendices --- packages/agent/docs/harness-v3.md | 224 ++++++++++++++++++++++++++---- 1 file changed, 200 insertions(+), 24 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 6d5166614d6..a332b242b8e 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -1,6 +1,6 @@ # AgentHarness v3 — implementation specification -**Status:** construction skeleton. When complete, this document supersedes `agent-harness-spec.md` in full, which itself superseded `harness-v2.md`. Until then, `agent-harness-spec.md` remains authoritative. +**Status:** complete, pending final audit. Once that audit passes, this document supersedes `agent-harness-spec.md` in full, which itself superseded `harness-v2.md`. Until then, `agent-harness-spec.md` remains authoritative. **Sources being merged:** @@ -17,13 +17,6 @@ slot → register StoredValue / valueId → gone (no values table) ``` -**Section disposition markers used in this skeleton:** - -- `[CARRY]` — take the base section verbatim, apply global renames. -- `[CARRY+]` — carry, plus the listed deltas. -- `[REWRITE]` — rewrite against the listed jot part / decision. -- `[NEW]` — no base equivalent; write from the listed jot part. - --- # Part 0 — Orientation @@ -229,7 +222,7 @@ interface UsageRow { ## 1.2 Identity and partitions -Every id storage stores — entry ids, usage ids, and every reserved id that will become one — is a **UUIDv7**, minted through the session's id generator (§2.8). A UUIDv7 begins with 48 bits of Unix milliseconds: the first 12 hex characters of the id *are* a timestamp, and that timestamp, truncated to the partition period, *is* the id's partition assignment. There are no partition columns anywhere — not on entries, not on ledger rows, not in any register value. The period length (monthly in every example) is a deployment property of the partitioned backend; Memory, JSONL, and SQLite never partition. +Every id storage stores — entry ids, usage ids, and every reserved id that will become one — is a **UUIDv7**, minted through the session's id generator (§2.8); the sole exception is imported legacy-format ids, preserved verbatim (Appendix C). A UUIDv7 begins with 48 bits of Unix milliseconds: the first 12 hex characters of the id *are* a timestamp, and that timestamp, truncated to the partition period, *is* the id's partition assignment. There are no partition columns anywhere — not on entries, not on ledger rows, not in any register value. The period length (monthly in every example) is a deployment property of the partitioned backend; Memory, JSONL, and SQLite never partition. What the embedded prefix buys: @@ -825,7 +818,7 @@ interface Session extends SessionTr readonly idGenerator: { next(timestampMs?: number): string }; view(lane: string): SessionTree; - /** Package-internal harness substrate; validates before delegating to Storage. */ + /** Package-internal harness storage surface; validates before delegating to Storage. */ commit(tx: Transaction): Promise; getEntries(ids: string[]): Promise>; getRegister(namespace: N, key: string): @@ -1370,9 +1363,10 @@ TX[ , delete op.meta/{O}, delete op.state/{O}, - delete op.tool_args/{O}:* prefix scan; catches keys leaked by a crash - between batch completion and cleanup, - delete op.preparation/{O}:* prefix scan, same reason, + delete op.tool_args/{O}:* defensive prefix scan; batch completion + already deletes these atomically (§3.8), + delete op.preparation/{O}:* prefix scan; in-run compactions leave their + preparation after resume, delete pending.entry/{id} for every operation-owned pending id, upsert lane.lastResult/{lane} = , L({ currentOperationId: null }) ] @@ -2795,19 +2789,201 @@ op.* / pending.* ephemeral by construction. Migrate when convenient, The design conclusion: the volatile part of the system — orchestration — was made ephemeral, and the durable part — the conversation — was made structurally boring. Schema evolution is exactly as hard as the boring part, which is the best available outcome. -# Part 8 — Build order `[REWRITE]` +# Part 8 — Build order + +Build the following vertical slices in order, except SQLite work may proceed after the tree contract stabilizes. Each slice implements the named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. Passing those tests and `npm run check` is its acceptance criterion. + +The current source tree is a work-in-progress implementation of the superseded record-log design. Replace its durable shapes rather than supporting both. Each slice updates or removes incompatible consumers/tests immediately so the repository compiles and `npm run check` passes after every merge; there is no compile-only legacy quarantine. Reuse existing behavior and tests where still valid: compaction preparation/split-turn generation, agent-loop streaming/tool behavior, event buffering, telemetry contracts, repository lifecycle, `BEGIN IMMEDIATE`, and fenced SQLite leases. + +If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it to the user for review. Do not silently improvise a new durable contract inside a slice. + +| # | Slice | Implement | Required focused tests | +|---|---|---|---| +| 1 | **Single-session Storage** | Write-once entries/usage, registers with first-class set/delete, atomic transactions, UUIDv7 id generator with follower minting, runtime entry/register/custom-message schemas, stats projection, per-session retired-range set plumbing (empty default), Memory backend, shared conformance helpers, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, close. | +| 2 | **JSONL v4 and format 3** | Single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | +| 3 | **Tree and repositories** | Entries with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle with the `storageVersion` gate at open, coherent branch/tree forks. | Placement, divergence, filters/cursors/stops, custom entries with and without data, context, fork before first attachment, configured fork snapshots/facts/zero ledger. | +| 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, `Models.lease` and runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), identity leases, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | +| 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance with pending-capture placement, captured request lease/options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | +| 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | +| 7 | **Tools** | Refactor existing loop into three phases, bind `AgentHarnessTool` context, durable complete plans, `op.tool_args/{opId}:{stepId}:{i}` registers with batch-completion deletion, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage. | Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, tool-args register lifecycle including crash-leak prefix cleanup, safe/unsafe replay, ordering, termination, abort-ready states. | +| 8 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pending.entry` registers, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation answering `not_found`, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | +| 9 | **Abort, close, and failure drain** | Orthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers. | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, drained-register survival and terminal deletion, close races, failure revived only by projecting input. | +| 10 | **Deferred provider redemption** | One poll per resume, copied configuration/options inline, leased request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of slice 9 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | +| 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, leased nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | +| 12 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | +| 13 | **Navigation** | Validation, summarized decision/generation, and one final transaction combining move/summary/leaf/label with the terminal writes; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup. | +| 14 | **SQLite** | Rework the current unfinished schema/backend directly to entries/registers/usage-ledger tables, transactions, stats, leases, catalog `storageVersion`, repository operations, segmented branch cache, entry-id-keyed FTS search projection, and explicit repair. No values table, no `slot_history`, no `getLog`, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, placed-only search, forks/search/stats/repair. | +| 15 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, migration registry, settlement-kernel decode and the force-settle path, JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer. | Version gate (equal/older/newer), chained idempotent migrations across crash, kernel force-settle leaving a valid idle lane plus `lastResult`, lenient replay of superseded shapes, compaction retiring old bytes. | +| 16 | **Retention scaffold** | Retired-range inventory on all backends (empty default), boundary classification in scans/segments/forks, `getRetentionBoundary` and truncation markers, fork range inheritance, expired-lane condition with `LaneExpired` and the `navigateTree` rebase exemption, lazy label handling, JSONL retention compaction with the pruned-usage header aggregate, pin-enumeration preflight helpers. Design-complete; the Postgres backend, partition DDL, and the retention daemon are deferred. | Boundary vs corruption discrimination, truncation markers, boundary-crossing forks including inherited ranges on never-retiring destinations, expired-lane surfacing and rebase, retention-compaction keep-predicate and stats aggregate, preflight pin enumeration including the `lane.leaf` scan. | +| 17 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | + +Existing source guidance: + +- `packages/agent/src/harness/session/**` and the old record reducer/tests: slices 1–3. Remove incompatible reducer code as soon as slice 1 replaces its inputs; do not preserve both durable models. +- `packages/agent/src/harness/agent-harness.ts` and new small transition/effects modules: slices 4–13 and 15–17. +- `packages/agent/src/agent-loop.ts`: preserve behavior while slice 7 extracts phases. +- `packages/agent/src/harness/compaction/**`: adapt, do not rewrite gratuitously, in slices 11–13. +- `packages/session-backends/sqlite-node`: slice 14; retain working transaction and lease primitives. +- Existing tests are evidence, not authority. Keep those that assert unchanged behavior and replace those tied to the record-log format. + +# Part 9 — Invariants and tests + +## 9.1 Invariants + +Storage: + +1. Entries and usage rows are **write-once** and share one session-wide id namespace. Writing either kind under any existing id is corruption. +2. Transactions are all-or-none, with consecutive `seq`. `seq` is monotonic session-wide. +3. Registers are the only mutable state. A register delete removes the key; there are no tombstones, and JSON `null` is a legal value only where a namespace's type permits it. +4. **Every payload lives in exactly one place**: an entry, a register, or the ledger. There is no third place data can hide. +5. No read on a hot path may fold history or infer state from an absent value — no history exists to fold — and no query may be a table scan. + +Tree: + +6. An entry's parent chain never changes. Branches share prefixes; nothing is copied. +7. An entry either decodes against its type's runtime schema or is corruption. Only a custom entry may omit payload data. +8. Configuration and orchestration never enter the tree. Deleting every `op.*` and `pending.entry` register must leave a complete, valid conversation and ledger. +9. A lane's leaf moves only by append or navigation. +10. A branch segment chain, followed to its end, yields the full root path — up to a retention boundary, where it stops cleanly (§2.6). +11. A missing parent whose id prefix is in a retired range is a retention boundary; a missing parent with a live prefix is corruption (§1.2, §6.4). + +Operations: + +12. `lane.state/{lane}` confers lane ownership, and `op.state/{operationId}` confers operation-state ownership. An open lane names operation O, `op.meta/O` holds that lane's compatible `Operation`, and `op.state/O` holds an `OperationState` compatible with O's intent kind; state values carry no duplicate owner metadata. +13. `op.*` registers and operation-owned `pending.entry` registers exist **iff** their operation is open: the terminal transaction deletes them atomically with clearing `currentOperationId` (§3.13). Lane-owned `pendingNextRun` registers are never deleted by it. +14. Acceptance must observe `currentOperationId === null`. +15. A reserved id may exist only with the content its intent named. There are exactly two reservation regimes (§2.2): settlement-family ids are strings in `op.state`; queued-content ids are `pending.entry` registers — until placement or cancellation, exactly one of register and entry exists. +16. Only terminal transitions construct a `LaneLastResult`. A terminal outcome is observable once through the live promise and thereafter through `lane.lastResult` until the next terminal transaction on that lane; recovery never reads it. +17. At most one operation is open per lane. Two is corruption. +18. `overflowRecoveryUsed` is `true` only after overflow compaction. A transition that adds projecting conversational input or tool results and requires an assistant writes `false`; an unprojected custom write preserves it. +19. **A committed response with `stopReason: "aborted"` must have `control.status === "cancel_requested"` in the same operation state.** Providers must comply with the harness-owned signal contract; violation is corruption. +20. Current-state validation (§3.3) runs on every decoded latest lane/operation state before execution — idle lanes included (§4.4). `lane.lastResult` never determines an open operation's next action, and the retired-range inventory's sole recovery role is boundary classification of missing entries (invariant 11). + +Everything that used to require a bounded historical validity audit is now either unrepresentable in the types, deleted by the terminal transaction, or covered by one of the above. + +## 9.2 Race catalog + +Each race has exactly two durable histories. Test both, in manual drive, in both orders. + +| Race | Orders | +|---|---| +| `prompt` vs `prompt` on one lane | one accepts, one gets `LaneBusy` | +| `abort` vs response settlement | marker first → normalized `aborted`; response first → stop reason preserved | +| `abort` vs tool result commit | planned result synthesized; or the real result stands | +| `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | +| `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | +| `setModel` vs generation step start | old snapshot used; or new snapshot used | +| `abort` vs structural commit | `aborted` with no entry; or `completed` | +| `nextRun` vs acceptance | captured by this run; or stays for the next | +| manual-compaction reservation vs idle tree write | reservation first → write waits; write first → preparation uses the new leaf | +| deferred write vs abort | write survives abort either way | +| `close` vs parked manual action | action rejected unexecuted; durable state is the committed prefix | +| `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | + +## 9.3 Test tiers -- Rewrite slices 1–3 (storage substrate, JSONL, tree/repos) for the three-store model; carry slices 4–13, 15 with renames; SQLite slice drops values/history machinery; add slices: schema-version/migration scaffold, retention/partition inventory (design-complete, Postgres-deferred). Keep the stop-and-report rule. +**Tier A — state and resume.** For every state in Part 3, construct it durably, close, reopen, and assert the next action. Coverage must include: restore with no branch walk and no configuration dereference; assistant intent with no settlement, below and at the retry cap; settlement followed by each classification branch; every settled stop reason surviving except the two deliberate normalizations; a self-contained deferred step with copied configuration, consecutive polls, repeated equal-handle pending responses, ready and terminal responses, and handle-mismatch normalization into durable failure; every tool state including planned, effect_pending safe and unsafe, and completed; a batch where every call sets `terminate` finishing the run with no further request; genuine-`length` batches proving no execution and one explanatory result per call; every overflow crash position, including that the compacted `retainedTail` omits the normalized-`error` response by the ordinary projection rule; every navigation state with no post-move generation; abort at every position; missing identities on accept and on resume; every terminal transaction proving complete register deletion (including tool-args prefix-scan cleanup of crash-leaked keys), `lane.lastResult` correctness, and preserved `pendingNextRun`; register/entry exclusivity for every queued id at every crash boundary; and every half-completed recovery prefix. -# Part 9 — Invariants and tests `[REWRITE: jot 8]` +For each recovery prefix: close, reopen, resume, and compare against uninterrupted recovery. Invoking recovery twice from the initial prefix is **not** sufficient. -- The six restated invariants (write-once entries/usage; one home per payload; op registers ⇔ open operation; two reservation regimes; observation contract; prefix-retired = boundary vs prefix-live = corruption), plus the surviving base §7.1 invariants — aborted-implies-cancelled, acceptance observes null operation, reserved-id/content pairing, one open operation per lane, overflow-flag rules, current-state validation on every decode. -- Race catalog `[CARRY]`. -- Test tiers `[CARRY+]` — Tier B oracle is the instrumented-storage decorator recording `commit(tx)`; backend conformance drops `getLog` equality; add retention-boundary tier exercised via the abstract retired-range set on Memory/JSONL. +One corruption assertion constructs an `aborted` response with running control directly and requires load rejection. Provider conformance separately proves implementations emit `aborted` only for the supplied signal. -# Appendices +**Tier B — writer conformance.** Run the public harness against the instrumented-storage decorator: a spy wrapping `Storage.commit()` that records every transaction's writes in order. Assert exact write order and content against the Part 3 transaction tables and the §5.5 ordering rules. There is no durable log to compare against; the decorator is the oracle. Faux provider/tool/hook spies interleave their start events with the decorator's commit record, so effect timing is observable. This tier catches the critical regression classes: an effect starting before its intent commit, a response omitted for one stop reason, classification starting before usage is durable, a result id reserved after clearance began, or a terminal transaction leaking a register. -- **A Glossary** `[REWRITE]` — entry, register, usage row, pending entry, partition, retention boundary, settlement kernel. -- **B Changes from agent-harness-spec.md** `[NEW]` — the jot part 8 delta table plus part 9 decisions, each with its reason. -- **C Coding-agent v3-format compatibility** `[CARRY]` — unchanged normalization rules (note: "v3" there names the old JSONL session format, not this document). -- **D Open questions** `[NEW]` — whatever survives: per-session retention length vs shared partitions; expired-lane product semantics; usage rows with no entry (partition of structural-failure usage); Postgres partition count/ops limits; pending-payload write-amplification measurement. +**Tier C — deterministic interleavings.** Every race in §9.2, both orders, manual drive. + +**Cross-cutting:** + +- **Backend conformance.** One suite, three backends, identical results — identical query results, register states, and stats after every scenario, including register set/delete/recreate semantics and torn-transaction handling. Write-order assertions use the instrumented decorator, never a durable log. +- **Retention boundaries.** Exercised via the per-session retired-range set (§6.4) on all three backends: boundary-versus-corruption discrimination, clean scan stops with truncation markers, `getRetentionBoundary`, segment-chain boundary stops, boundary-crossing forks with inherited ranges on never-retiring destinations, expired-lane surfacing with the `navigateTree` rebase exemption, and labels reading as absent. +- **Drive equivalence.** The same scenario in automatic and manual drive must produce byte-identical durable state. +- **Signal ownership.** No public surface accepts a signal; a `before_request` patch carrying one has it stripped. Assert by type and by test. +- **Ledger completeness.** Every settled attempt commits its response and its usage. Failed structural attempts retain their cost. `getStats()` equals the ledger sum after every commit — and, after a JSONL retention compaction, the header aggregate plus surviving rows. A fork starts at zero. +- **Query-plan guards.** `EXPLAIN QUERY PLAN` for `scanBranch` matches §1.7 exactly — no `entries` scan or temporary ordering b-tree. Segment tests assert copied rows are bounded by the newest compaction interval. +- **Transaction discipline.** Assert every SQLite transaction opens with `BEGIN IMMEDIATE`. Add a regression test that reads, lets a second connection commit, then writes — it must succeed, and would fail with `database is locked` under a deferred `BEGIN`. +- **Segment chain soundness.** Build a chain by alternating branch-and-append across several compactions, then assert that a full-to-root scan through the chain returns exactly the entries a flat branch would, with no duplicates and no gaps — and, with a retired range recorded, that the scan stops cleanly at the boundary. Both §2.6 rules — resolve-through-base coverage and the chain-searched newest compaction — fail this test when violated, and fail silently without it. + +--- + +# Appendix A — Glossary + +| Term | Meaning | +|---|---| +| **Entry** | Write-once conversation record: placement and payload in one row. Its id is the public entry id. | +| **Register** | Namespaced mutable cell holding its current typed value directly. Overwrite replaces; delete removes the key. | +| **Usage row** | Append-only cost ledger row. Never modified, never deleted. | +| **Pending entry** | Unplaced content in a `pending.entry` register keyed by its reserved entry id, until placement or cancellation. | +| **Session** | One conversation: tree, facts, ledger, lanes. | +| **Lane** | Named cursor into the tree with its own config, queues, and one operation. | +| **Operation** | One accepted unit of work: run, compaction, or navigation. | +| **Effect** | Anything not pure computation: commit, provider request, tool, hook, timer. | +| **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | +| **Operation state** | The complete state of one operation at one moment — the `op.state` register, the program counter. | +| **Reserved id** | An id minted before its content exists: a string in `op.state` (settlement family) or a `pending.entry` key (queued content). | +| **Follower id** | An id minted with its leader's 48-bit timestamp so a call/result group shares a partition. | +| **Lane mutation line** | Per-lane serialization point where all state-dependent mutations queue. | +| **Control** | Orthogonal cancellation flag: `running` or `cancel_requested`. | +| **Checkpoint** | The state between turns where queues, writes, and finishing are decided. | +| **Continuation** | Durable answer to "does this run still owe an assistant turn?" | +| **Terminal transaction** | The commit that deletes an operation's registers, writes `lane.lastResult`, and clears `currentOperationId`. | +| **Segment** | A branch-index range that references an older branch instead of copying it. | +| **Partition** | The period a row belongs to on a partitioned backend, read from its id's time prefix. | +| **Retention boundary** | A missing parent whose id prefix is in a retired range; a clean traversal stop, not corruption. | +| **Retired-range inventory** | The deployment partition inventory united with a per-session retired-range set; the classification input for boundaries. | +| **Settlement kernel** | The versioned-never fragment of open-operation state sufficient to force-settle it in any future version. | + +# Appendix B — Changes from agent-harness-spec.md + +| Change | Reason | +|---|---| +| Entries replace the value/node split; placement and payload are one row | The differing birth times that motivated the split are covered by two reservation regimes (§2.2); removes the join, `valueId`, value GC, and the old-value-under-new-partition hazard | +| Registers hold values directly, with first-class delete; `slot_history` and `getLog` removed | Recovery reads only current state; durable write history was pure overhead. Tier B's oracle is an instrumented-storage decorator | +| `FinishedState` removed; terminal transactions delete `op.*` and operation-owned pending registers; `lane.lastResult` added | A finished session holds exactly the conversation, the ledger, and lane/fact registers — nothing to collect — while outcomes stay observable after a crash | +| Queue items are single entry ids; `pending.entry` registers hold unplaced payloads | `{ nodeId, valueId }` collapses to one string; cancellation deletes content outright; the one deliberate double write is paid only by queued items | +| Usage is a first-class append-only store (`UsageRow`) | Billing is decoupled from orchestration and survives terminal cleanup and aborts | +| Ids are UUIDv7; the partition is the id's time prefix; follower minting; `PARTITION BY RANGE (id)` | Every reference is self-describing with no partition columns; native pruning; call/result groups stay atomic under expiry | +| Partition-pure branch segments (append and diverge rules) | Index rows live and die with their partition; drops never gap retained scans | +| `queue.disposition` removed; `cancelQueued` triage is `cancelled`/`already_consumed`/`not_found`; `UnknownQueueItem` and `already_cleared` dropped | One immortal register per cancelled item bought only a rarely needed distinction; `not_found` is retry-safe | +| Fact deletion is real register deletion; no tombstones | Delete is a first-class write; JSON `null` stays a legal custom value | +| CAS tokens are register seqs (`operationStateSeq`, `laneStateSeq`, expected `lane.config` seq) | State values no longer exist; the linearization is unchanged, only the token | +| Configuration, stream options, and retry policy are inline in operation-state contexts | No values table to point into; restore reports missing identities without resolving anything | +| `op.tool_args/{opId}:{stepId}:{i}` and `op.preparation/{opId}:{taskId}` registers replace args/preparation value ids | Deterministic keys; deleted at batch completion and by the terminal prefix scan, which also catches crash-leaked keys | +| Abort-drained pending registers survive until the terminal transaction | `AbortResult` and post-crash `SuspendedOperation.aborting` dereference the drained payloads; snapshot queues exclude them | +| `RecordUsageResult { usageId }`; the `usage` event carries the ledger row | Value ids are gone; the row already carries its durable `seq` | +| `entry_added`, `findEntries`, `getEntry`, `appendCustomEntry`, `EntryProjector` renames | jot part 9 nomenclature: one concept, one continuity name | +| `getLastResult()` and the `lane.lastResult` read path | Post-crash outcome reconciliation, including outcomes the tree cannot reconstruct | +| Restore validates idle lanes too (leaf plus `pendingNextRun` registers) | Idle lane state is current state; corruption there must not wait for the next operation to surface | +| `PendingEntry.payload` optional; tool-reported usage ids mint at commit | Custom entries may carry no data; nothing reserves a tool usage id | +| Retention and partitioning specified (Part 6): recoverable expiry protocol, pins/preflight including the `lane.leaf` scan, retired-range inventory, expired lanes with `LaneExpired` and the `navigateTree` rebase exemption, truncation markers, precise rewrite | Long-lived deployments need routine TTL cost control that never touches orchestration correctness, plus a surgical path for what TTL cannot express | +| Schema evolution specified (Part 7): `storageVersion`, migrate-on-open, settlement kernel | In-flight state must never brick a session across state-machine redesigns | +| JSONL snapshot compaction | Register overwrites append in a log-structured file; physical reclamation is a rewrite, shared with retention compaction | + +The interpreter, effects boundary, hooks, events, classifier, abort/close semantics, context projection, race catalog, and format-3 normalization carry from the base spec with mechanical renames; they are restated in full so this specification is self-contained with the named source types. + +# Appendix C — Coding-agent v3-format compatibility + +"v3" in this appendix names the legacy coding-agent JSONL session format, not this document. Old coding-agent v3 JSONL files must open unchanged and restore idle. Normalization on load: + +- `custom_message` becomes a custom agent message. +- `label` and `session_info` become facts (latest by file position wins) and leave the tree. A label targets its nearest retained parent. +- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` nodes disappear. They do **not** initialize or alter `LaneConfiguration`; a normalized `main` uses the immutable options seed. +- Each retained child of a discarded node is reparented to its nearest retained ancestor. +- `main`'s leaf is the final physical node resolved through discarded nodes to its nearest retained ancestor. +- An old compaction resolves its legacy `firstKeptEntryId` field against its own branch and materializes that range as `retainedTail`. Format 4 never exposes or persists that field. +- Existing `details`, `usage`, and `fromHook` are preserved; an absent `fromHook` normalizes to `false`. +- v3 ISO timestamps convert to Unix milliseconds. +- A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`. +- On first format-4 write, append one aggregate adjustment usage row with `details: { source: "v3-import" }`, summing v3 node usage so ledger-derived totals remain unchanged. +- Legacy v3 ids are preserved verbatim and are not UUIDv7s. This is sound on the shipping backends: prefix classification is consulted only against the retired-range inventory, which is empty for imported sessions (§6.4), and §6.6 retention compaction is prohibited on sessions containing legacy ids — they must go through the precise rewrite first. Moving such a session onto a partitioned backend goes through the precise rewrite (§6.6), which is where legacy sessions acquire partitionable ids. + +Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current `storageVersion` (§7.3). A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. + +# Appendix D — Open questions + +1. **Repairing a missing model captured inside an open operation.** Registering the same provider/model identity unblocks it without changing state. Replacing it with a different durable identity needs an explicit repair API and is not silently performed by `setModel`. +2. **Overflow detection remains heuristic.** The normalization specified in §3.7 is authoritative. Preserve the original reason in `errorMessage` for diagnosis. +3. **Per-session retention length versus shared date partitions.** Retention-class table families versus one policy per deployment (§6.7). +4. **Expired-lane product semantics.** The mechanism — `LaneExpired`, the `navigateTree` rebase exemption — is specified (§6.4); whether a deployment auto-rebases to the boundary, exposes an explicit expiry state, or both is a product decision. +5. **Usage rows with no entry.** Failed structural attempts and adjustments partition by mint date like everything else; whether they belong there or in the hot catalog (epoch-of-operation versus hot) is unresolved (§6.7). +6. **Postgres partition count and operational limits.** Period length, partition maintenance at scale, and inventory growth need operational validation before the fourth backend ships. +7. **Pending-payload write amplification.** The deliberate double write (§1.8) is paid only by queued items; measure it for pathological payloads before optimizing (`INSERT … SELECT` placement exists on SQL backends, eager compaction on JSONL). From 2f9e9298a50f0fdec09e00f9b81b9e5b001a3405 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 08:38:04 +0200 Subject: [PATCH 091/284] docs(agent): remove superseded harness design documents --- .../docs/agent-harness-storage-redesign.md | 394 ------ .../agent/docs/harness-v2-state-machine.md | 1205 ----------------- 2 files changed, 1599 deletions(-) delete mode 100644 packages/agent/docs/agent-harness-storage-redesign.md delete mode 100644 packages/agent/docs/harness-v2-state-machine.md diff --git a/packages/agent/docs/agent-harness-storage-redesign.md b/packages/agent/docs/agent-harness-storage-redesign.md deleted file mode 100644 index 79c4547174c..00000000000 --- a/packages/agent/docs/agent-harness-storage-redesign.md +++ /dev/null @@ -1,394 +0,0 @@ -# AgentHarness storage and retention redesign - -**Status:** proposed delta to `agent-harness-spec.md`. This document assumes that specification is known. It describes only the proposed replacement storage model and the questions that remain. Until merged into the main specification, the main specification is authoritative. - -## Motivation - -The current design separates a tree node from its payload value because queued content may become durable long before tree placement. It also writes each lane/operation state revision as an immutable value and moves a slot to the newest revision. - -That produces three avoidable problems: - -1. Completed operations leave operation, state, tool-argument, preparation, fact, and configuration values behind. -2. `slot_history` retains state-transition history that execution never reads. -3. A pending value and its eventual node can land in different retention partitions. Deleting the old value partition then corrupts the newer node. - -Context compaction, operation garbage collection, and conversation retention are separate concerns: - -- **Context compaction** changes what is sent to a provider. -- **Operation cleanup** removes state that stopped being observable when an operation completed. -- **Conversation retention** intentionally removes old user-visible tree history. - -None should implicitly control another. - -## New storage model - -The durable model has three primary categories: - -```text -nodes complete immutable conversation nodes -slots current mutable state and pending node payloads -usage_ledger immutable billing/accounting events -``` - -SQLite keeps auxiliary tables for branch lookup, FTS, stats, partition metadata, sequencing, and writer leases. - -There is no general `stored_values` table, no `valueId`, and no node/value materialization join. - -### Complete nodes - -A stored node is also the application-facing node: - -```ts -interface NodeBase { - id: string; - parentId: string | null; - seq: number; - timestamp: number; - partitionId: string; -} - -interface MessageNode extends NodeBase { - type: "message"; - message: AgentMessage; - terminate?: true; -} - -interface CompactionNode extends NodeBase { - type: "compaction"; - summary: string; - retainedTail: AgentMessage[]; - tokensBefore: number; - details?: JsonValue; - usage?: Usage; - fromHook: boolean; -} - -interface BranchSummaryNode extends NodeBase { - type: "branch_summary"; - fromId: string; - summary: string; - details?: JsonValue; - usage?: Usage; - fromHook: boolean; -} - -interface CustomNode extends NodeBase { - type: "custom"; - customType: string; - data?: JsonValue; -} - -type Node = MessageNode | CompactionNode | BranchSummaryNode | CustomNode; -``` - -`StoredNode`, `StoredValue`, `ValueKind`, `valueId`, and `toNode()` disappear. - -### Typed slots - -A slot is a mutable namespaced key containing its current typed value, not an ID pointing at another state value: - -```ts -interface SlotValues { - "lane.leaf": string | null; - "lane.config": LaneConfiguration; - "lane.state": LaneState; - - "op.meta": Operation; - "op.state": OperationState; - "op.tool_args": Record; - "op.preparation": DurableStructuralPreparation; - - "pending.node": PendingNode; - "queue.disposition": QueueDisposition; - - "fact.name": string; - "fact.label": string; - "fact.custom": JsonValue; -} - -interface Slot { - namespace: N; - key: string; - seq: number; - value: SlotValues[N]; -} -``` - -Large state that stays unchanged across transitions gets its own stable operation slot: - -```text -op.meta/{operationId} -op.state/{operationId} -op.tool_args/{operationId}:{sourceIndex} -op.preparation/{operationId}:{taskId} -``` - -`op.state` contains the total mutable program counter but names deterministic operation slots for large stable data. This avoids repeatedly serializing tool arguments or preparation while keeping all operation-only data out of the conversation tables. - -Conditional transitions compare the expected slot `seq` rather than the ID of an immutable state value. - -Slot deletion is distinct from storing JSON `null`. Completing an operation deletes its operation slots. Deleting a fact deletes its slot; a custom fact may still store JSON `null` as a real value. - -### Pending nodes - -Content accepted before placement lives in a stable slot: - -```ts -interface PendingNode { - type: Node["type"]; - customType?: string; - payload: JsonValue; -} -``` - -Examples are `nextRun`, steer/follow-up input, and deferred tree writes: - -```text -pending.node/{reservedNodeId} -> payload -lane/op queue state -> reservedNodeId -``` - -Placement atomically: - -1. reads the pending slot; -2. creates one complete node with the current parent, sequence, timestamp, partition, and payload; -3. deletes the pending slot; -4. removes the node ID from queue state; -5. updates `lane.leaf`. - -Assistant responses and tool results need no pending slot. Their IDs are reserved in operation state, and settlement creates the complete node directly. - -A cancelled pending node is deleted without ever entering the conversation table. - -### Usage ledger - -Usage is neither mutable state nor necessarily a node. It has a dedicated append-only ledger: - -```sql -usage_ledger( - session_id, - id, - seq, - node_id, - adjustment, - usage_json, - details_json, - partition_id, - PRIMARY KEY (session_id, id) -); -``` - -`session_stats` remains the maintained aggregate. Retention may later collapse old usage rows into an adjustment without affecting totals. - -### No built-in slot history - -Remove: - -- `slot_history`; -- `getLog()` and `LogItem`; -- Memory's transaction log; -- SQLite history-union queries and history-only indexes. - -Execution reads only current slots. Applications that need an operational audit install a telemetry adapter and persist sanitized transaction/slot events externally. - -JSONL may physically retain old slot updates until snapshot compaction, but they are not a queryable history contract and may disappear on any rewrite. - -## Operation lifecycle and cleanup - -Operation-only durable data exists entirely in current slots. Completion atomically: - -1. publishes final nodes and usage; -2. writes the idle `lane.state` value; -3. deletes `op.meta/{operationId}` and `op.state/{operationId}`; -4. deletes all `op.tool_args` and `op.preparation` slots for the operation; -5. deletes any still-pending nodes owned by the operation; -6. clears the lane's current operation. - -There is no durable `FinishedState` after completion. The terminal transaction and idle lane are the durable completion boundary. The live result is computed before commit from data that either remains in nodes/usage or is still in memory. - -This makes operation cleanup explicit and bounded. It needs no historical reduction, value scan, or mark-and-sweep over old state revisions. - -## SQLite shape - -```sql -nodes( - session_id, - id, - parent_id, - seq, - timestamp, - partition_id, - type, - custom_type, - payload_json, - PRIMARY KEY (session_id, id) -) WITHOUT ROWID; - -slots( - session_id, - namespace, - key, - seq, - value_json, - PRIMARY KEY (session_id, namespace, key) -); -``` - -Branch and type indexes continue to use node metadata. Branch scans no longer join through a payload table. Pending-node placement can use `INSERT ... SELECT` from the pending slot followed by slot deletion in one transaction when catalog and nodes share a database. - -## What this enables - -### Trivial operation cleanup - -State updates overwrite current slot values. Stable operation slots are deleted at completion. No immutable administrative values accumulate. - -### Self-contained tree rows - -A retained node always contains its payload. Deleting a node deletes its content; retaining it cannot leave a missing payload dependency. - -### Simpler pending-content lifecycle - -Unplaced content is visibly pending because a slot exists. Placement creates exactly one tree row. Cancellation deletes one slot. - -### Easier partitioning - -Pending data remains in the unpartitioned hot slot store. Partition assignment occurs only when a complete node is placed, so payload and structure cannot land in different partitions. - -The normal partition contents become: - -```text -complete nodes -branch-index rows -FTS rows -associated usage rows -``` - -Routine partition expiry therefore does not need to discover or move values referenced by retained nodes. - -### Separation from context compaction - -Calendar/TTL partitioning can remove old conversation history independently of model-context compaction. Compaction remains a provider-context operation and does not become a storage lifecycle marker. - -## Retention mechanisms - -Two conversation-retention mechanisms remain possible and may coexist: - -1. **Partition expiry.** Assign complete nodes to context-safe time partitions and drop sealed partitions. This is the fast routine TTL path, but it must tolerate retired parents and current slots targeting expired nodes. -2. **Precise session rewrite.** Compute retained nodes from lane-specific policy, copy the retained set into a new store, briefly freeze commit admission, apply the tail, and atomically swap stores. This supports arbitrary branch-aware pruning but costs `O(retained data)` and is an administrative rewrite, not context compaction. - -For SQLite, a rewrite should copy retained rows to a new file rather than run `DELETE ... NOT IN (...)` over all historical rows while writes are frozen. Operation cleanup uses neither mechanism; it happens continuously through slot lifecycle. - -## Partitioning direction - -A likely physical design is: - -```text -hot session catalog - current slots - pending nodes - operation state - facts - partition inventory - aggregate stats - -immutable/sealable partition P - complete nodes - branch/FTS projections - usage rows -``` - -Nodes that must remain together for provider-valid context need the same partition key. At minimum, an assistant node containing tool calls and every corresponding tool-result node share a context-group partition. A simpler policy may assign all nodes from one accepted run to the run's partition epoch rather than each row's wall-clock timestamp. - -A node whose parent belongs to an explicitly retired partition may become a valid retained root. A missing parent in a live partition remains corruption. - -Retention duration does not need to be fixed when the session is created. It may be shortened later; it may be lengthened only for partitions that have not already been deleted. - -## Open questions - -### Branch segments across deleted partitions - -The current `branch_nodes` / `branch_meta` chain can name a base segment in an older partition. It is not yet specified: - -- whether each partition has independent branch projections; -- whether a retained segment may have a retired base; -- where the retired-boundary marker lives; -- how `scanBranch` distinguishes an expected retired base from corruption; -- whether partition deletion requires rebuilding retained branch metadata; -- how stale branches and branches shared by several lanes are represented after deletion. - -This is the largest unresolved interaction with the current branch-chain design. - -### Context-safe partition groups - -The exact grouping rule is unresolved: - -- assistant tool call plus results; -- one provider turn; -- one accepted run; -- another explicit context frame. - -The rule must prevent retained context from starting with an orphan tool result or ending with an unresolved assistant tool call. Long-running operations must not prevent old partitions from sealing forever. - -### Cross-partition parent semantics - -Possible choices are: - -1. retained nodes may point into retired partitions and traversal stops there; -2. create lightweight retention-boundary nodes; -3. rewrite crossing parents before deletion. - -The first is cheapest but changes the current invariant that every parent exists. - -### Physical SQLite partitioning - -Logical `partition_id` rows in one SQLite file preserve easy atomic transactions but do not make deletion `O(1)`. Separate files make deletion cheap but require coordination between the hot slot store and node partition. - -We need a crash-safe protocol for a transaction that both inserts a node into a partition and deletes/updates hot slots. Options include attached-database transactions, a small commit manifest, staging rows, or keeping an active writable partition together with the catalog. - -### Partition sealing with live operations - -If a run captures partition P and crosses a calendar boundary, either: - -- P remains writable until the run finishes; -- the run's unsettled nodes remain staged in the hot store; -- context-group rows are copied/promoted when the group closes; -- the newer partition records a dependency that temporarily pins P. - -The choice affects deletion latency and atomicity. - -### Lane leaves, labels, and navigation targets - -Current slots may name nodes in a partition selected for deletion. Policy must define whether to: - -- expire/rebase the lane; -- pin its target partition; -- retain a lightweight boundary node; -- drop labels; -- return an explicit expired-target result for navigation/bookmarks. - -### Compaction and copied old content - -A newer compaction node may embed `retainedTail` messages from an older period, and summaries contain information derived from old history. Deleting old partitions therefore does not guarantee that all old information disappears. Retention policy must define whether copied and summarized content may outlive its source partition. - -### Usage retention - -Decide whether usage rows: - -- share their node's partition; -- use independent calendar partitions; -- remain forever; -- collapse into aggregate adjustments when old partitions are removed. - -Failed structural attempts have usage but no node, so node-only partition assignment is insufficient. - -### JSONL physical cleanup - -Current-slot semantics remove history logically, but JSONL keeps old bytes until rewrite. Decide when snapshot compaction runs and whether operation completion or retention requires immediate physical removal of sensitive payloads. - -### Telemetry detail and privacy - -If telemetry replaces durable history, define the sanitized mutation event contract. Slot namespaces and target IDs are usually safe; custom fact keys and slot payloads may not be. Durable audit, if required, belongs to the adapter rather than session storage. - -### Pending-slot write amplification - -A delayed payload is written once into `pending.node` and again into `nodes` at placement. This is more physical I/O than the old value/node split, though only one live copy remains and SQLite can move it with `INSERT ... SELECT`. We should measure whether this matters for unusually large queued payloads. diff --git a/packages/agent/docs/harness-v2-state-machine.md b/packages/agent/docs/harness-v2-state-machine.md deleted file mode 100644 index 89234e0e508..00000000000 --- a/packages/agent/docs/harness-v2-state-machine.md +++ /dev/null @@ -1,1205 +0,0 @@ -# AgentHarness v2 explicit-state redesign - -> **Status:** Working handoff document. This is not yet the canonical implementation specification. `harness-v2.md` remains the complete requirements inventory until this design is validated and adopted. -> -> **Purpose:** Preserve the redesign and decisions from the current design session in a form a new session can read quickly. The design deliberately replaces implicit recovery reduction with explicit, total durable operation state. - -## 1. Concise model - -An `AgentHarness` owns one durable session. The session contains: - -1. **Conversation tree** — append-only message, compaction, branch-summary, and custom entries. -2. **Lanes** — permanent names pointing at tree leaves. Each lane has at most one open operation. -3. **Lane configuration** — one total replacement containing model reference, thinking level, and active tool names. -4. **Operational state** — one immutable `OperationRecord` plus append-only total `OperationStateRecord`s. -5. **Usage ledger** — immutable usage records, independent of whether later orchestration succeeds. -6. **Global facts** — latest-wins name, labels, and custom facts. - -Lanes run concurrently. One writer owns the session. Each lane serializes state-dependent decisions on a mutation line. Storage serializes all appends and assigns one session-wide `seq`. - -### Operations - -A lane accepts one of three operation kinds: - -- **run** — prompt, assistant generations, tools, steering/follow-up, deferred writes, and automatic compaction; -- **compaction** — standalone manual compaction; -- **navigation** — move to another tree entry, optionally with a summary. - -An accepted operation has one durable `OperationRecord` and a sequence of total state records. The latest `OperationStateRecord` directly states what the operation is doing and what may happen next. - -### Effects - -An effect is work outside pure state calculation: - -- durable storage mutation; -- provider generation or deferred fetch; -- tool invocation; -- hook invocation; -- timer or retry sleep. - -Before a repeat-sensitive external effect starts, durable state records that it is pending and provisions every settlement ID. After it settles, one atomic transaction writes its durable output, usage, and next total operation state. - -### External-effect non-goal - -External effects cannot generally be both durable and exactly once across process failure. Provider requests, tools, hooks, and provider billing can happen without their settlement becoming durable. Implementations must use idempotency, declared safe replay, reconciliation, or accept uncertainty. The harness makes this uncertainty explicit but cannot eliminate it. - -### Context projection - -Conversation persistence and provider context remain separate. Durable `error`, `aborted`, and `deferred` assistant responses do not project. Genuine output-limit `length` projects. Overflow compaction omits its exact superseded response from summary input and retained tail. Compaction entries remain self-contained context boundaries. - -### Why this is better - -The old design persisted many small orchestration events and reconstructed a hidden program counter from their combinations and from entry absence. One assistant settlement could be durable as response-only, response-plus-usage, response-plus-usage-plus-tool-plan, or several other prefixes. Queue status, failure clearance, and navigation progress were likewise inferred. - -The redesign persists the program counter directly as one total `OperationStateRecord` and commits output, accounting, and the next state atomically. This gives five concrete benefits: - -1. **Direct recovery.** Load one immutable operation record and one latest total state record; do not fold operation history. -2. **Fewer crash states.** A repeat-sensitive effect has only intent absent, intent present with settlement absent, or settlement and next state committed. -3. **Exhaustive transitions.** A pure transition function switches on explicit state and input. Missing cases are visible in types and tables. -4. **Local terminal validity.** Only transitions from eligible states can create a finished state; finish validity is not a historical audit. -5. **Mechanical testing.** Crash before intent, after intent, and after atomic settlement. Public races have two mutation-line orders. - -The trade-off is repeated state data. The first implementation accepts that cost. It must not recover space by reintroducing patches, partial child logs, or historical state collection. - -## 2. Replace implicit reduction with total operation state - -The current design reconstructs orchestration from combinations of records, entry presence, lane pointers, and later transitions. The redesign persists the continuation directly. - -### Operation record - -`OperationRecord` contains immutable acceptance data and is written once: - -```ts -interface OperationRecord { - type: "operation"; - operationId: string; - lane: string; - sourceLeafId: string | null; - startedAt: number; - intent: - | { - kind: "run"; - originalPrompt: AgentMessage[]; - systemPromptOverride?: string; - resumeData?: Record; - } - | { - kind: "compaction"; - customInstructions?: string; - } - | { - kind: "navigation"; - targetId: string | null; - summarize: boolean; - label?: string; - customInstructions?: string; - }; -} -``` - -### Total operation state record - -Every state transition appends one record containing all current mutable orchestration state for that operation: - -```ts -interface OperationStateRecord { - type: "operation_state"; - id: string; - lane: string; - operationId: string; - revision: number; - state: OperationState; -} -``` - -`revision` starts at 1 and increases by exactly one. State records are append-only; the latest revision is authoritative. - -**Total means total.** An `OperationStateRecord` is not a patch and needs no older state record for interpretation. It contains the complete current workflow state, retry state, tool plan and per-call states, pending operation-owned queues and writes, deferred source, and cancellation control. It may reference immutable conversation entries, usage records, and its immutable `OperationRecord` by ID, but no older operational state record supplies missing state. - -The first implementation accepts the storage cost of total state records. Do not introduce delta chains, child-state logs, or patch replay to optimize them. If measurement later shows a problem, optimize physical encoding or compression while preserving the logical total-state contract. - -### Loading current state - -The public storage concept is: - -```ts -interface CurrentOperation { - operation: OperationRecord; - stateRecord: OperationStateRecord; -} - -getCurrentOperation(lane: string): Promise; -``` - -Backends answer through their latest-operation index. They read one immutable `OperationRecord` and exactly one latest total `OperationStateRecord`. They do not scan or fold operation history, inspect entry absence to infer a phase, or collect partial task state from several operational logs. - -Memory keeps the latest state record in a map. JSONL updates its latest-state projection while replaying the file. SQLite keeps a current-operation projection and reads the selected operation/state records by indexed ID. - -### Records retained and replaced - -Retain: - -- total `lane_config` replacements; -- immutable usage and adjustment records; -- immutable `OperationRecord`; -- total `OperationStateRecord`s; -- facts, lane history, and conversation entries. - -The total state record replaces the recovery authority currently spread across: - -```text -abort_requested -step_started -step_attempt -step_failed -branch_summary_prepared -tool_batch_started -tool_started -queue_enqueued -queue_cancelled -write_deferred -operation_finished -``` - -Some implementation may retain compact audit records, but recovery and transition validity must never depend on them. - -### Lane state and inbox records - -`nextRun` belongs to a lane even when no operation is open. Its current state is therefore a separate total lane record: - -```ts -interface LaneStateRecord { - type: "lane_state"; - id: string; - lane: string; - revision: number; - currentOperationId: string | null; - pendingNextRun: QueuedInput[]; -} - -interface QueuedInput { - entryId: string; - message: ProvisionedEntry; -} -``` - -The latest `LaneStateRecord` is total. It is not reconstructed from queue history or entry absence. `getCurrentLaneState(lane)` returns that one record. Run acceptance atomically removes captured next-run items, appends their entries, sets `currentOperationId`, writes the immutable operation record, and writes the first total operation state. - -Run-owned input is contained completely in `ActiveRunState`: - -```ts -interface RunInbox { - steer: QueuedInput[]; - followUp: QueuedInput[]; - writes: PendingWrite[]; -} - -interface PendingWrite { - entryId: string; - entry: ProvisionedEntry; -} -``` - -Queue acceptance writes a new total lane or operation state before resolving. Consumption atomically appends the entry and removes the item from total state. Deferred writes survive abort; steer and follow-up are moved into durable abort control and removed from the active inbox by the first abort transaction. - -Cancellation needs history only for an exact item lookup after it leaves current state. A compact disposition record supplies that without participating in recovery: - -```ts -interface QueueDispositionRecord { - type: "queue_disposition"; - id: string; - lane: string; - entryId: string; - disposition: "cancelled" | "cleared_by_abort"; -} -``` - -`cancelQueued(entryId)` runs on the lane mutation line: - -- pending in lane `nextRun` or run steer/follow-up: atomically remove it and append `cancelled`; -- target entry exists or was captured and appended by run acceptance: `already_consumed`; -- disposition exists: `already_cleared`; -- otherwise: `UnknownQueueItem`. - -Capture and entry append occur in the same run-acceptance transaction, so no captured-but-unappended state exists. Queue modes affect which pending steer/follow-up items a checkpoint consumes, but every consumed set is removed and appended atomically. - -| Public input | Admission | Total-state transition | -|---|---|---| -| `nextRun` | any open harness state | append to `LaneStateRecord.pendingNextRun`; never starts a run | -| `steer` | active running run | append complete item to `ActiveRunState.inbox.steer` | -| `followUp` | active running run | append complete item to `ActiveRunState.inbox.followUp` | -| lane-view tree write during run | active run, including suspension/cancellation | append complete item to `inbox.writes`; survives abort | -| lane-view tree write while idle | idle lane | append entry directly and advance leaf | -| lane-view tree write during compaction/navigation | structural operation open | wait until structural operation ends, then re-evaluate | -| `cancelQueued` | item currently pending | remove from total state and append disposition atomically | -| checkpoint consumes input | eligible pending item | append entry, remove item, and update continuation atomically | -| first abort | running run | move steer/follow-up into durable abort control; writes remain pending | -| finish | inbox empty and no required continuation | final operation state and lane current-operation clear atomically | - -Acceptance, cancellation, application, abort, and finish all run on the same lane mutation line. Thus each race has only caller-A-first or caller-B-first history; no item can be both pending and applied in durable current state. - -## 3. Operation state - -```ts -type OperationState = - | ActiveRunState - | ManualCompactionState - | NavigationOperationState - | FinishedOperationState; -``` - -### Orthogonal cancellation control - -Abort is not a workflow phase. It is control over the current workflow: - -```ts -type OperationControl = - | { status: "running" } - | { - status: "cancel_requested"; - requestedAt: number; - drainedSteer: ProvisionedEntry[]; - drainedFollowUp: ProvisionedEntry[]; - }; -``` - -Every active operation state contains `control`. Normal transition planning checks it. If cancellation won first, no new provider, tool, hook decision, or retry effect starts. Effect settlement, usage, accepted deferred writes, configuration changes, and cancellation completion remain allowed. - -### Run state - -```ts -interface ActiveRunState { - kind: "run"; - control: OperationControl; - phase: RunPhase; - inbox: RunInbox; -} - -type RunPhase = - | { - kind: "checkpoint"; - continuation: CheckpointContinuation; - } - | { - kind: "assistant"; - generation: GenerationState; - } - | { - kind: "tools"; - batch: ToolBatchState; - } - | { - kind: "compaction"; - compaction: SummaryGenerationState; - resumeAfter: CheckpointContinuation; - } - | { - kind: "deferred"; - deferred: DeferredState; - } - | { - kind: "failure_drain"; - error: OperationError; - terminalResponseEntryId: string; - }; - -type CheckpointContinuation = - | { - kind: "need_assistant"; - triggerMessageId: string; - overflowRecoveryUsed: boolean; - } - | { - kind: "may_finish"; - }; -``` - -`continuation` replaces inference such as `needsAssistant()`. A compaction stores the continuation it must resume. Overflow compaction resumes `need_assistant` with the same trigger and `overflowRecoveryUsed: true`; another recoverable overflow for that trigger enters failure drain. Applying a new user-context message atomically changes the continuation to `need_assistant` with that message ID and resets the flag to false. - -### Generation state - -```ts -interface GenerationContext { - stepId: string; - triggerMessageId: string; - configuration: LaneConfiguration; - retryPolicy: RetryPolicy; -} - -type GenerationState = - | { - status: "ready"; - context: GenerationContext; - nextAttempt: number; - } - | { - status: "effect_pending"; - context: GenerationContext; - attempt: number; - responseEntryId: string; - usageRecordId: string; - intendedOutputLimit: number; - contextWindow: number; - } - | { - status: "retry_wait"; - context: GenerationContext; - nextAttempt: number; - notBefore: number; - errorMessage: string; - }; -``` - -`RetryPolicy` applies to generation requests, including generated summaries. It does not impose a retry or polling cap on deferred fetch. - -### Structural decision and summary state - -Manual compaction, auto-compaction, and summarized navigation first enter a decision state. The decision hook may decline, supply a complete result, or select generated work. A crash while the hook is running reruns it; hook-owned side effects follow the external-effect non-goal. Once generated work is selected, the state change is durable and the decision hook does not run again. - -```ts -type StructuralDecisionState = - | { - status: "deciding"; - } - | { - status: "generating"; - generation: SummaryGenerationState; - }; - -interface SummaryGenerationContext { - taskId: string; - resultEntryId: string; - kind: "compaction" | "branch_summary"; - configuration: LaneConfiguration; - retryPolicy: RetryPolicy; - reason?: "manual" | "threshold" | "overflow"; - overflow?: { - supersededResponseEntryId: string; - triggerMessageId: string; - }; -} - -type SummaryGenerationState = - | { - status: "ready"; - context: SummaryGenerationContext; - nextAttempt: number; - } - | { - status: "effect_pending"; - context: SummaryGenerationContext; - attempt: number; - usageRecordIds: string[]; - } - | { - status: "retry_wait"; - context: SummaryGenerationContext; - nextAttempt: number; - notBefore: number; - errorMessage: string; - }; -``` - -One structural attempt may make one or two provider requests. Before its first request, total state becomes `effect_pending`. After each request, reported usage and a new total state containing its usage record ID commit atomically. Intermediate response content need not persist; a crash before the final structural transaction makes the whole attempt uncertain and starts a later numbered attempt only under the captured generation policy. Failed-attempt usage remains in the ledger. - -Hook-supplied compaction and branch-summary entries set `fromHook: true`; generated entries set it false. Hook usage, when present, commits atomically with the structural result. Generated result usage is the sum of successful-attempt request usage records. - -### Tool batch state - -```ts -interface ToolBatchState { - assistantEntryId: string; - triggerMessageId: string; - genuineLength: boolean; - calls: ToolCallState[]; - nextToFinalize: number; -} - -type ToolCallState = - | { - status: "planned"; - sourceIndex: number; - toolCall: AgentToolCall; - resultEntryId: string; - } - | { - status: "effect_pending"; - sourceIndex: number; - toolCall: AgentToolCall; - resultEntryId: string; - effectiveArgs: JsonValue; - replay: "never" | "safe"; - } - | { - status: "completed"; - sourceIndex: number; - toolCall: AgentToolCall; - resultEntryId: string; - terminate: boolean; - }; -``` - -The total operation state record contains the complete batch and every call state. This can duplicate data across state records; correctness and direct recovery take priority. Parallel tool execution remains possible: several calls may be `effect_pending`, while result commits remain source ordered. - -### Deferred state - -```ts -type DeferredState = - | { - status: "suspended"; - stepId: string; - sourceEntryId: string; - poll: number; - configuration: LaneConfiguration; - } - | { - status: "effect_pending"; - stepId: string; - sourceEntryId: string; - poll: number; - responseEntryId: string; - usageRecordId: string; - configuration: LaneConfiguration; - }; -``` - -The original assistant generation that returns `deferred` atomically writes its response/usage and enters `suspended`, copying that generation's total configuration once. The exact source entry supplies provider, model, and complete handle; copied active tool names govern a ready response's tool calls. - -Each `resume()` performs at most one `fetchDeferred(..., { wait: 0 })`. It atomically changes `suspended` to `effect_pending` before polling. The application decides whether and when to call `resume()` again. Deferred polling has no harness retry count, retry cap, or retry sleep. - -Settlement is atomic: - -- another `deferred` response: append response/usage, require complete handle equality, increment `poll`, and suspend on the new response entry; -- ready response: append response/usage and move to tools or the appropriate checkpoint continuation; -- provider error or rejected fetch converted to error: append response/usage and enter failure drain; -- unmarked returned `aborted`: append response/usage and suspend on the unchanged source; the application may resume again; -- durable cancellation: best-effort cancel the newest source handle and settle any already-planned response under its ID as `aborted`. - -If a process dies with a poll `effect_pending`, the remote check may have happened but no settlement is durable. A later application `resume()` provisions a fresh poll and response/usage IDs; no retry cap is applied. Provider behavior such as expiration or cancellation support is outside harness control. - -### Manual compaction state - -```ts -interface ManualCompactionState { - kind: "compaction"; - control: OperationControl; - customInstructions?: string; - structural: StructuralDecisionState; -} -``` - -Admission computes preparation against the source leaf. Empty preparation returns `NothingToCompact` before acceptance. Acceptance atomically writes `OperationRecord`, `ManualCompactionState` in `deciding`, and `LaneStateRecord.currentOperationId`. In `deciding`, `before_compaction` may decline, supply a complete compaction, or select generated work. Decline or hook-supplied completion commits finished state directly. Generated work follows `SummaryGenerationState`; success atomically commits usage, the complete `CompactionEntry`, and finished state. - -### Navigation state - -```ts -type NavigationOperationState = - | { - kind: "navigation"; - control: OperationControl; - targetId: string | null; - label?: string; - customInstructions?: string; - summarize: false; - phase: { kind: "ready_to_commit" }; - } - | { - kind: "navigation"; - control: OperationControl; - targetId: string; - label?: string; - customInstructions?: string; - summarize: true; - phase: { - kind: "summary"; - structural: StructuralDecisionState; - }; - }; -``` - -After target/source validation, acceptance atomically writes `OperationRecord`, the appropriate navigation state, and `LaneStateRecord.currentOperationId`. Unsummarized navigation has no decision hook. Summarized navigation runs `before_navigation`, which may decline, supply a complete summary, or select generated work. All source-tree reads and provider/hook work happen before the final structural transaction. Completion atomically moves the lane, appends the exact summary when required, writes the label when present, writes finished operation state, and clears `LaneStateRecord.currentOperationId`. - -### Terminal state - -```ts -interface FinishedOperationState { - kind: "finished"; - control: OperationControl; - outcome: "completed" | "declined" | "failed" | "aborted"; - leafId: string | null; - error?: OperationError; - finalAssistantEntryId?: string; -} -``` - -Only transition functions may construct terminal state. This makes terminal validity local and exhaustive instead of a separate historical-log audit. - -## 4. Atomic transition rule - -Every durable boundary follows one rule: - -> Compute one next total operation state, then atomically append all conversation, usage, fact, lane, and operation-state mutations that make that state true. - -A transaction either commits all logical mutations or none. - -### Lane configuration - -`lane_config` remains a separate total latest-value record containing model reference, thinking level, and active tool names. `AgentHarnessOptions` supplies an immutable seed used for first attachment of `main` and every later `createLane`; anchors and other lanes never supply configuration. Configured lane creation atomically creates the pointer and first total config. - -`setModel`, `setThinkingLevel`, and `setActiveTools` immediately commit one total replacement on the lane mutation line, including during an operation or cancellation. Starting a generation snapshots the current configuration into operation state in the same lane ordering. Later setters affect only later generations; retries keep the generation's captured value. Tool implementations and contexts remain environmental and are resolved by captured active names immediately before a real invocation. - -### Run acceptance - -`skill()` / `promptFromTemplate()` resource expansion and prompt normalization happen before acceptance. `before_run` is an effect before the lane acceptance transaction; it receives only the normalized caller prompt, not pending next-run items. Its returned messages, optional system-prompt override, and resume data are held until acceptance. A concurrent winner may make the lane busy, in which case the hook output is discarded and no operation is written. - -The acceptance transaction validates idle state and identities, captures all pending next-run input, and atomically writes: - -```text -TX updated LaneStateRecord: - captured nextRun removed - currentOperationId = O - OperationRecord O - captured nextRun entries - caller prompt entries - before_run injected entries - first OperationStateRecord: - run checkpoint - continuation need_assistant(newest user-context entry) - inbox empty -``` - -The operation call resolves only after this transaction. There is no accepted operation with missing initial entries. - -### Checkpoint and finish boundary - -At a run checkpoint, transitions occur in this order: - -1. atomically apply accepted deferred writes; -2. atomically consume eligible steering according to steering mode; -3. run threshold compaction when required, preserving the current continuation; -4. if continuation is `need_assistant`, start generation; -5. after assistant/tool continuation is exhausted, atomically consume eligible follow-up input; -6. when continuation is `may_finish` and inbox is empty, invoke `before_run_end`; -7. conditionally finish. - -A `before_run_end` follow-up is committed only if control is still running and the operation is still at the same finish boundary. Its message entry and `need_assistant` state commit atomically. Abort or another input that wins first drops the stale hook result. - -Finish is one lane mutation transaction: - -```text -TX final OperationStateRecord - LaneStateRecord.currentOperationId = null -``` - -It commits only when total state proves no required work remains. Steer/follow-up acceptance, deferred writes, abort, and finish are serialized, so only input-first or finish-first histories exist. - -### Assistant attempt - -Plan before the effect: - -```text -TX operation state: - phase assistant - generation effect_pending - attempt 1 - response R1 - usage U1 -``` - -After the provider settles, classify in memory and commit settlement plus meaning: - -```text -TX assistant entry R1 - usage U1 - operation state: - phase tools - complete result-ID plan -``` - -or: - -```text -TX assistant entry R1 - usage U1 - operation state: - phase assistant - retry_wait for attempt 2 -``` - -or: - -```text -TX assistant entry R1 - usage U1 - operation state: - phase compaction - exact overflow response link - resumeAfter need_assistant -``` - -There is no durable response-without-usage or accounted-response-without-classification state. - -### Assistant settlement classifier - -Classification is pure and runs before the atomic settlement transaction. Cancellation control takes priority. Without cancellation, evaluate in this order: - -| Durable response | Next state | -|---|---| -| explicit provider context-limit error | overflow handling | -| `stop` with reported input plus cache-read greater than captured context window | overflow handling | -| Xiaomi-compatible zero-output/full-window pressure | overflow handling | -| `length` with output below captured intended output limit | overflow handling | -| `deferred` with valid handle | deferred suspended | -| unmarked `aborted`, attempts remain | generation retry wait | -| unmarked `aborted`, no attempts remain | failure drain | -| retryable `error`, attempts remain | generation retry wait | -| other `error` | failure drain | -| `toolUse` or accepted response with calls | tools with complete result plan | -| `stop` or genuine output-limit `length` | checkpoint `may_finish` | - -`intendedOutputLimit` is the caller's explicit limit or model limit before context clamping. The percentage heuristic is only the existing Xiaomi-compatible signal. Overflow handling never creates a tool plan. - -For overflow, `need_assistant` carries `overflowRecoveryUsed`. If false, enter compaction with the exact superseded response/trigger and resume with the flag true. If already true, enter failure drain. Consuming newer user-context input creates a new trigger and resets the flag. - -A genuine output-limit `length` remains in provider context. If it contains tool calls, create the complete batch plan but execute no call; append one planned `isError: true` result per call explaining that truncation may have left arguments incomplete. Those results require another assistant generation. - -### Tool call - -After clearance and immediately before execution: - -```text -TX operation state: - call i = effect_pending - effective args and replay declaration stored -``` - -After execution/finalization: - -```text -TX tool usage, when present - planned tool-result entry - operation state: - call i = completed - next continuation recorded when batch completes -``` - -If a crash leaves `effect_pending`, replay only when the declaration and implementation are safe; otherwise append the planned interrupted result. - -The complete batch plan exists before tool lookup, argument validation, or `before_tool`. Calls are identified privately by source index; public hooks/events/tool context use provider `toolCallId` and tool name. Tool IDs are required to be response-local unique. - -| Call state/input | Atomic result and next state | -|---|---| -| planned, unknown tool or invalid arguments | planned error result; mark completed | -| planned, `before_tool` blocks or throws | planned blocked error; mark completed | -| planned, control cancelled | planned aborted error; mark completed | -| planned, clearance succeeds | total state becomes `effect_pending`; then dispatch effect | -| live effect settles | run `after_tool`; usage + finalized result + completed state | -| restored effect pending, replay safe now and when started | re-execute persisted args, finalize, usage + result + completed state | -| restored effect pending, replay unsafe | interrupted error + completed state | -| genuine `length` planned call | explanatory error + completed state; no clearance/effect | - -`after_tool` may patch content, details, error status, usage, and `terminate`; the finalized decision is stored in the result entry/state. Hook output must satisfy its contract. A hook crash before the atomic result transaction may rerun after a safe replay. - -Sequential mode clears, starts, executes, finalizes, and commits one call before the next. Parallel mode performs clearance and effect-intent commits in source order, dispatches effects in source order without awaiting earlier ones, allows concurrent settlement, and commits finalized results in source order. Several calls may therefore be durable `effect_pending` while results still form a source-order prefix. - -### Queue or deferred-write application - -Acceptance updates the total operation state with the complete provisioned payload. Application is atomic: - -```text -TX message/custom entry - operation state with item removed - continuation updated when the entry requires an assistant -``` - -A crash cannot consume an item without updating operation state, or update operation state without appending the entry. - -### Structural decision and generation transitions - -| State/input | Atomic result and next state | -|---|---| -| deciding, hook declines | finished `declined` for standalone operation; threshold returns to prior continuation; overflow enters failure drain | -| deciding, hook supplies result | usage + exact typed entry + continuation/finished state | -| deciding, hook selects generation | total state with generated summary `ready`; hook will not rerun | -| generation ready or retry elapsed | total state `effect_pending`; then provider request(s) | -| generation retryable failure | reported usage + retry-wait state | -| generation terminal/exhausted | standalone finished failed or run failure drain | -| generated compaction succeeds | usage + compaction entry + prior continuation/finished state | -| generated branch summary succeeds | usage + move + summary + label + finished state | -| cancellation before structural commit | reported usage still commits; generated result is discarded; finish aborted | - -Structural provider streams are internal and emit no public assistant-message lifecycle. Every provider request still crosses the effect boundary and writes reported usage before another request or structural completion. Hook-provided details remain opaque; generated details may use harness-owned structure. - -### Automatic compaction - -Threshold and overflow compaction are run phases, not nested operations. Threshold compaction preserves the continuation that caused the context check. Hook decline or empty useful preparation returns to that continuation because threshold compaction is proactive. - -Overflow compaction carries the exact superseded response entry and trigger in `SummaryGenerationContext`, omits that response from preparation and `retainedTail`, and resumes `need_assistant` with `overflowRecoveryUsed: true`. Hook decline or empty preparation enters failure drain because the rejected request cannot fit without compaction. - -### Navigation - -Reject before acceptance when: - -- target equals current leaf; -- target is root and a label was requested; -- summary was requested while the source leaf is root; -- non-null target does not exist. - -For summarized navigation, all provider/hook work happens before the structural transaction. Successful completion is one atomic append: - -```text -TX generated usage, when present - lane move to target - exact branch-summary entry - label fact, when present - finished operation state -``` - -The summary entry chains from the moved target because mutations apply in order. A crash sees either an uncommitted navigation at its source or a fully completed navigation. No prepared-summary or post-move recovery state is needed. - -### Manual compaction - -Determine whether useful context exists before operation acceptance. If not, return `NothingToCompact` and write nothing. Successful settlement atomically appends usage, the compaction entry, and finished state. - -### Transition summary - -This is the normative high-level machine. Detailed transition functions must refine, not contradict, it. - -| Current state | Trigger | Durable transaction | Next state | -|---|---|---|---| -| idle lane | accepted prompt | lane state + operation record + initial entries + total operation state | run checkpoint `need_assistant` | -| checkpoint `need_assistant` | drive | generation intent state | assistant effect pending | -| assistant effect pending | settled response | response + usage + classified total state | retry/tools/deferred/compaction/checkpoint/failure | -| assistant retry wait | delay elapsed | next generation intent state | assistant effect pending | -| tools planned | clearance succeeds | call effect-pending total state | tools with started call | -| tool effect pending | finalized result | usage + result + total state | tools or checkpoint | -| checkpoint | accepted steer/follow-up/write | total state containing item | same checkpoint | -| checkpoint | apply/consume item | entry + total state without item | `need_assistant` or prior continuation | -| checkpoint | context threshold | compaction decision state | compaction | -| compaction deciding | hook result | decline/result/generated-source transaction | continuation/generating/failure | -| summary effect pending | provider outcome | usage + retry or final structural transaction | retry/continuation/finished/failure | -| assistant response deferred | settlement | response + usage + copied deferred state | suspended | -| deferred suspended | one `resume()` | poll intent state | deferred effect pending | -| deferred effect pending | poll settles | response + usage + classified state | suspended/tools/checkpoint/failure | -| failure drain | new user-context item applied | entry + total state | checkpoint `need_assistant` | -| finish boundary | no hook follow-up or pending work | final state + lane state clears operation | finished | -| any active state | first abort | cancellation control + drained inbox | same workflow under cancellation | -| cancellation control | reconciliation complete | required results/writes + final state + clear lane operation | finished aborted | - -### Crash table - -Atomic transactions have no internal crash prefix. For each repeat-sensitive effect, only these states exist: - -| Crash point | Durable state | Recovery | -|---|---|---| -| before effect-intent transaction | previous total state | plan the effect normally | -| after effect intent, before dispatch | effect pending; effect did not run or dispatch status was lost | apply the effect's uncertainty policy; cancellation prevents dispatch | -| during/after external effect, before settlement transaction | effect pending; external outcome unknown | generation retries under captured policy, tools replay only when safe, deferred waits for another application resume, cancellation settles as specified | -| after settlement transaction | output, usage, and next total state all durable | continue from next state; never repeat settlement | -| before queue/write application transaction | item remains fully pending in total state | apply later | -| after queue/write application transaction | entry exists and item is absent; continuation updated | continue; never apply twice | -| before final structural transaction | source leaf and generated/hook work remain uncommitted | retry/recompute only according to current state and external-effect policy | -| after final structural transaction | move/entry/label/usage/finished state all durable | operation is complete | -| after first abort transaction | cancellation and drained payloads durable | never start new ordinary effects; reconcile pending workflow | -| after terminal transaction | finished state and lane clear are durable | lane is idle | - -The unavoidable uncertain interval is effect intent durable with settlement absent. Provider, tool, hook, and billing examples all belong to the single external-effect non-goal. - -## 5. Interpreter, abort, and recovery - -### Interpreter - -```ts -async function drive(operation: CurrentOperation): Promise { - while (true) { - const action = nextAction(operation.stateRecord.state); - - switch (action.kind) { - case "transition": - operation = await commitTransition(operation, action); - break; - - case "effect": - operation = await commitEffectIntent(operation, action); - const result = await runEffect(action.effect); - operation = await commitEffectSettlement(operation, result); - break; - - case "wait": - return action.result; - - case "done": - return action.result; - } - } -} -``` - -The exact implementation may avoid an explicit loop, but every path uses the same `nextAction`, intent, and settlement transitions. Manual drive gates these actions. Recovery loads current state and calls the same interpreter. - -### Abort - -Pure synchronous code cannot be interrupted. Abort and normal commits race only on the lane mutation line. The first abort transaction changes `control` to `cancel_requested`, stores the exact drained steer/follow-up payloads, and leaves the workflow state intact. After commit it signals a live cooperative effect and cancels unreleased gated effects. - -Normal work-creating transitions recheck control and write nothing when cancellation already won. Settlement and accounting for already-intended effects remain allowed. Planned tools become aborted; restored started tools become interrupted; live started tools preserve their finalized result. Assistant/fetch settlement after cancellation is stored under its planned response ID with stop reason `aborted`. - -Repeated `abort()` while the operation remains open appends nothing, signals nothing, and returns the same durable drained payloads. Abort after terminal state returns `NoActiveOperation`. - -Effects are required to cooperate with `AbortSignal`. Provider and tool adapters must settle after cancellation rather than run indefinitely. - -### Recovery - -Restore performs indexed reads only: - -```text -latest lane configuration -current `OperationRecord` + latest total `OperationStateRecord` -current lane leaf -independent pending nextRun state -``` - -`getCurrentOperation()` returns one immutable `OperationRecord` and one latest total `OperationStateRecord`. No historical operation or state records are reduced. The state record directly selects the next interpreter action. - -The remaining unavoidable crash state is: - -```text -effect intent is durable -effect settlement is absent -``` - -For generation, a later attempt is allowed only under the captured generation retry policy; when no attempt remains, recovery persists a synthetic error under the already-planned response ID. If durable cancellation won, recovery instead persists synthetic `aborted`. For tools, safe replay or planned interruption applies. Hook and external-effect side effects remain subject to the external-effect non-goal. - -### Missing runtime identities - -Before `prompt()`, `compact()`, or `navigateTree()` accepts work, the lane verifies that its configured model/provider and every active tool name can resolve. Missing identities return `MissingIdentities` and write nothing. The lane remains idle. - -For an already-open operation, `resume()` verifies the identities required by its next effect. Missing identities return `MissingIdentities`, perform no effect, and leave the operation open at the same state record. - -Registering the missing tools/providers/models unblocks execution. An explicit escape hatch is also needed to replace a missing model/provider referenced by existing lane or operation state. Its exact API and whether it rewrites pending generation state remain unresolved. - -### Effects boundary and manual drive - -Every durable transition, provider request/fetch, individual tool invocation, hook invocation, and timer crosses one `Effects` method. Procedures receive no direct Session, Models, tool registry, or hook runner. Pure state calculation, immutable tree/context reads, and ID allocation are not gated effects; after any awaited read, the next effect/commit revalidates current total state and cancellation control. - -Automatic drive executes the interpreter. Manual drive parks before each effect and exposes one JSON-safe action. `peekAction()` is stable and side-effect free; `executeAction()` releases exactly one action; `runToCompletion()` releases nested actions before awaiting parents. Lane-surface operations such as steer, cancellation, configuration setters, and writes remain ungated so tests can exercise both race orders. - -Closing while an action is parked rejects it without execution. The durable state is exactly the prefix of committed total state records and effect intents. - -### Close - -Close is process lifecycle, not operation abort. It writes no cancellation or terminal operation state. It stops public admission, signals cooperative in-flight effects, rejects parked/local operation promises, lets already-admitted lane mutations and Session appends settle, drains storage, and releases only its writer claim. A prior effect intent may remain without settlement; reopening loads that total state and ordinary recovery applies. Durable open operations remain resumable. - -### Hook replay summary - -- `before_run`: before acceptance; output commits in the acceptance transaction; reruns only when no operation was accepted. -- `transform_context`, `before_request`, and `before_payload`: per provider request; ephemeral and may rerun with a repeated request. -- `after_response`: transforms the settled message before `message_end` and atomic settlement; `streamAssistant` still needs an explicit final-message callback to mount it. -- `before_tool`: runs while call is planned; effective args become durable only in effect-pending state; reruns if that state did not commit. -- `after_tool`: runs after a real effect or safe replay; output becomes durable with usage/result/completed state. -- compaction/navigation decision hooks: run in `deciding`; generated-source state prevents rerun; supplied output commits directly with the result. -- `before_run_end`: may rerun at the same finish boundary; its returned follow-up commits conditionally. - -Hook-owned external side effects must be idempotent under the external-effect non-goal. - -## 6. Storage and event boundaries - -The redesign assumes the existing storage contract: - -- one writer per session; -- per-lane mutation serialization; -- one session-wide monotonic `seq`; -- atomic non-empty mutation arrays; -- Memory one queue job, JSONL one physical object/array line, SQLite one transaction; -- all-or-none replay and publication; -- fenced SQLite writer ownership. - -The state-machine design does not replace or weaken these requirements. - -Lifecycle events such as streaming updates remain process ordered. Events that claim a durable commit fire only after the atomic transaction commits. `message_end` still means streaming ended; `entry_added` still means the entry committed. - -Whether durable commit events, especially usage totals, must be published in strict global `seq` order remains unresolved. Strict ordering is more faithful to durable state but may briefly buffer a later lane's commit event until an earlier lane has installed and queued its event. Storage already resolves append promises in commit order, and state installation must contain no `await`, so expected buffering is small; this needs implementation-level validation before becoming a requirement. - -### Storage representation of new state - -All backends expose latest total lane/operation state through indexes or replayed projections: - -- Memory: latest lane state, open operation ID, immutable operation record, and latest operation state maps; -- JSONL: ordinary object for one mutation and one array line for an atomic transition; replay updates latest-state/open-operation projections; a torn final array is discarded wholly; -- SQLite: append-only operation-state rows plus a current lane/open-operation projection, all updated in the same transition transaction. - -A finished transition appends final operation state and clears the lane's current-operation projection atomically. Forks copy conversation, current facts, pointers, and fresh lane configuration, but no operation or usage state. Coding-agent v3 normalization still opens one idle, initially unconfigured `main`; first harness attachment seeds its total lane configuration. - -### Public surface consequences - -Existing lane methods remain: `prompt`, `skill`, `promptFromTemplate`, `compact`, `navigateTree`, `resume`, `abort`, `steer`, `followUp`, `nextRun`, `cancelQueued`, `recordUsage`, configuration setters/getters, lane tree view, `watch`, `waitForIdle`, `runWhenIdle`, manual drive, and `close`. Harness lane management (`lane`, `createLane`, `lanes`) and `watchSession` remain. Expected caller failures use `Result`; storage faults, close, and invariant defects may reject. - -`LaneSnapshot.operation` is derived directly from current total state. It reports running, suspended, or cancelling; streaming drafts and running tools remain process-local additions. `SuspendedOperation.missing` reports identities required by the next effect. Reconnect obtains a new snapshot and non-replayed event stream. - -`message_end` remains stream completion before durable settlement. An atomic settlement publishes `entry_added` for its entry after commit and then usage/operation events in logical mutation order. Internal structural provider streams emit no public assistant message lifecycle. Events and hooks may contain sensitive content; telemetry may not. - -## 7. Decisions from the design session - -This section records decisions made while developing the redesign. A later session must not silently reopen them without a concrete contradiction or failing trace. - -### 7.1 Total state records, not shallow or delta state - -The latest `OperationStateRecord` is complete mutable operational state. Loading an operation reads its immutable `OperationRecord` and exactly one latest state record. Do not split active generation, tool, queue, or deferred state into separate latest-value logs that must be collected and reconciled. Do not use patches or replay delta chains. - -Total state records may consume more storage. Correctness and direct recovery take priority. Measure before optimizing. Permitted future optimizations are physical compression, backend-internal structural sharing, or compact field encoding that still decodes one state record into the complete state. Tool batches are the likely worst case because every call state repeats after transitions. - -### 7.2 One external-effect non-goal - -Provider calls, tool effects, hook-owned side effects, and provider billing are examples of one problem: an external effect may occur before its settlement becomes durable. Exactly-once execution cannot be guaranteed without cooperation from the external system. Enumerate these examples once and require idempotency, safe replay, reconciliation, or accepted uncertainty. Do not create separate orchestration theories for each example. - -### 7.3 Abort - -Abort is orthogonal operation control, not a workflow phase. Pure synchronous code cannot be interrupted. Abort and normal state changes race only at mutation/effect boundaries. The first durable cancellation request wins; later requests return the same drained steer/follow-up payloads without another write, signal, or event. - -Effects must cooperate with `AbortSignal` and settle promptly. New ordinary effects do not start after cancellation. Settlement/accounting for an already-intended effect and accepted writes that survive abort remain allowed. - -### 7.4 Deferred polling - -Deferred polling is application-controlled. Each `resume()` performs at most one `fetchDeferred(..., { wait: 0 })`. The harness persists the response and either continues or remains suspended. The application uses `pollAfterMs` or its own schedule to decide whether to call `resume()` again. - -`RetryPolicy` applies to generation, including generated summaries. It does not impose a deferred-fetch retry count, cap, backoff, or automatic polling loop. Provider expiration, terminal errors, and cancellation support are provider behavior the harness must report but cannot repair. - -### 7.5 Context projection - -Existing projection rules remain. This redesign changes orchestration state, not conversation projection. Durable error, aborted, and deferred assistant responses remain omitted; genuine output-limit `length` remains; exact overflow omission remains linked to compaction; compaction tails remain self-contained. - -### 7.6 Missing runtime identities - -`prompt()`, `compact()`, and `navigateTree()` check the lane's configured model/provider and active tool names before acceptance. If any are missing, return `MissingIdentities`, perform no effect, and write no operation. The lane remains idle until the application registers the missing identities or changes configuration. - -`resume()` checks only identities required by the next effect. Missing identities return `MissingIdentities`, perform no effect, and leave the existing operation open at the same total state record. - -Tools are restored by registering every active tool name mentioned by the relevant configuration. An idle lane can replace a missing model with `setModel(validModel)`. An open operation may contain a captured missing model reference, so an explicit model/provider replacement escape hatch is needed; its API and durable semantics are unresolved. - -### 7.7 Navigation from root - -Reject summarized navigation when `sourceLeafId === null`. There is no source branch to summarize and `BranchSummaryEntry.fromId` is non-null. Reject before hook invocation or durable acceptance. - -### 7.8 Empty manual compaction - -Manual compaction with no useful preparation returns `NothingToCompact` before durable operation acceptance. It does not invoke the decision hook or provider and writes no operation state. - -### 7.9 Navigation decision hook - -The current intended decision is that `before_navigation` applies only to summarized navigation. Unsummarized navigation validates and moves without that decision hook and cannot finish `declined`. Before making this normative, compare against current coding-agent behavior; do not perform that investigation as part of this handoff edit. - -### 7.10 Sensitive events versus telemetry - -Events and hooks may contain prompts, model output, tool arguments/results, deferred handles, and other sensitive application content. The previous goal that events are secret-free is rejected. Serving layers own authorization and optional redaction. Handler errors still need a JSON-safe normalized shape. - -Telemetry remains content- and secret-free by default. It may contain declared identifiers, names, counts, durations, statuses, and usage, but not prompts, completions, tool data, provider payloads, headers, or credentials. - -### 7.11 Storage assumptions - -Single writer, lane mutation serialization, atomic non-empty append arrays, monotonic `seq`, all-or-none replay, and fenced SQLite ownership are existing storage contracts. They are assumed, already enforced, and not problems for the new state model to solve. - -### 7.12 Lane-level next-run state - -`nextRun` exists independently of an operation. `LaneStateRecord` is its total latest-value durable state and also names the current operation ID. Run acceptance atomically removes captured items, appends their entries, opens the operation, and writes its first total operation state record. Neither next-run state nor operation capture is reconstructed from queue history plus entry absence. - -### 7.13 Commit-event ordering - -Strict global `seq` ordering for durable commit events is not yet accepted. Storage resolves append promises in commit order, so a central publication queue could order durable events with little buffering if state installation performs no `await`. However, it may introduce cross-lane head-of-line coupling or require delayed/reordered delivery. Keep process-local lifecycle events in process order. Validate the implementation consequences before strengthening durable-event ordering. - -## 8. Unresolved questions and retained audit findings - -This section preserves the full audit result and its current disposition so a later session can continue without rerunning the entire conversation. Findings marked **addressed by redesign** still require tests and must not be assumed correct merely because the state shape permits a solution. - -### 8.1 Runtime and recovery findings - -#### Inbox omitted from the first redesign draft — addressed - -The first draft mentioned pending input but did not define lane-level next-run state, operation-owned inbox state, acceptance, cancellation, capture, application, abort clearing, or race behavior. Sections 2 and 4 now define total lane state, complete run inbox payloads, disposition records used only for exact historical lookup, and atomic transition rules. - -#### Structural states referenced but undefined — addressed - -The first draft named manual compaction, navigation, and summary-generation states without defining them. Section 3 now defines their state and section 4 defines decision, generation, completion, failure, decline, and cancellation transitions. - -#### Acceptance and finish boundaries omitted — addressed - -The first draft did not model initial prompt/next-run/hook entry commit or `before_run_end` and finish races. Section 4 now defines both atomic boundaries and their lane-mutation ordering. - -#### Tool outcome racing abort — addressed by redesign - -Old failure: `before_tool` produced a blocked result, abort committed before the result append, and normal execution and abort reconciliation could append different content under one planned result ID. - -Required transition rule: settlement enters the lane mutation line and reads current cancellation control. A planned/unstarted call becomes aborted when cancellation won. A live started effect may preserve its finalized result. A restored started effect becomes interrupted. Add an explicit regression for both commit orders. - -#### Terminal state not tied to operation state — addressed by redesign - -Old invalid examples included completed runs with pending steer, completed compaction after structural failure, and declined runs. Only exhaustive transition functions may create `FinishedOperationState`. They must reject terminal state while required work, unresolved effects, operation-owned queues/writes, or incompatible failure provenance remains. - -#### Failure drain plus deferred user write — addressed by redesign - -Old failure: failure drain applied a deferred user message but used only a process-local consumed-queue count to decide whether to restart generation. The atomic write-application transition must remove the write, append its entry, and set `checkpoint.need_assistant` in one transaction. - -#### Crash after failure-drain queue consumption — addressed by redesign - -Old failure: a steer entry committed, the process crashed before a new step started, and recovery no longer knew that terminal failure had been cleared. Queue application now atomically appends the entry and writes `checkpoint.need_assistant`, preserving the restart. - -#### Assistant need lost after compaction — addressed by redesign - -Old failure: the newest own entry became a `CompactionEntry`, so `needsAssistant()` could return false even though overflow or a tool-result tail required another assistant. `resumeAfter: CheckpointContinuation` explicitly preserves `need_assistant`; no tree-entry-role inference determines continuation. - -#### Missing identities — policy partly decided - -Pre-acceptance operation calls return `MissingIdentities` without writing. Resume leaves an existing operation open. The unresolved part is replacing a missing model/provider captured inside an open operation. See section 8.4. - -#### Final-response hook cannot be mounted — unresolved implementation contract - -`StreamAssistantConfig` currently lacks a callback that receives and may replace the settled assistant message before `message_end`. Transport `onResponse` sees HTTP metadata, not the final message. Add an explicit final-response callback and define metadata when transport failure is converted to an in-band error. This remains required regardless of state persistence. - -#### Hook-produced assistant validity — accepted contract risk - -A hook can return duplicate tool IDs, invalid deferred-handle combinations, pending stop reason, or non-JSON values. The decision is not to attempt exhaustive semantic validation. Hooks must obey their typed/runtime contract. Minimal boundary checks needed to prevent storage corruption or impossible core types may still be required; distinguish those from trying to validate model semantics. - -#### Summarized navigation from root — decided - -Reject before acceptance. See section 7.7. - -#### Empty manual compaction — decided - -Return `NothingToCompact` before acceptance. See section 7.8. The race between the pre-acceptance preparation read and another idle-lane mutation still needs a concrete admission algorithm; acceptance must revalidate the source leaf or reserve the lane while preparation is checked. - -#### Unsummarized navigation decline — provisional decision - -Treat `before_navigation` as summary-only and disallow decline for unsummarized navigation, subject to comparison with coding-agent. - -#### Missing generation settlement after crash - -Plain-language definition: generation intent is durable, the provider may have run, but no response transaction exists. Below the captured generation attempt limit, start a new numbered attempt. When no generation attempt remains, persist a synthetic assistant error under the already-planned response ID. If durable abort control won first, persist synthetic `aborted` instead. Avoid unexplained terms such as “unknown effect at cap” and “marker-backed cancellation” in the final specification. - -#### Captured next-run cancellation — addressed by total state - -Old ambiguity: a next-run item was captured by accepted run state but its entry was not yet appended, so cancellation could not distinguish pending from consumed. Acceptance must atomically remove the item from total lane next-run state and append it with the operation. After acceptance, cancellation reports `already_consumed`; there is no intermediate captured-without-entry state. - -### 8.2 Event, hook, and telemetry findings - -#### Usage totals can regress under out-of-order delivery — unresolved - -Example: usage commit seq 11 with totals 20 is delivered before seq 10 with totals 10; a stateless consumer ends at 10. Options are strict session-sequenced durable event publication or requiring consumers to track maximum record `seq`. Strict ordering may buffer or couple lanes; investigate before deciding. See section 7.13. - -#### Secret-free event claim — decided - -Reject the claim for events/hooks; retain it for telemetry only. See section 7.10. - -#### Safe tool replay event lifecycle — unresolved - -A safely replayed tool needs a defined `turnId` and tool event lifecycle. One candidate is deriving `turnId` from the durable assistant generation `stepId` and emitting recovery turn/tool brackets. Another is making recovery tool event fields differ. Decide when the event model is implemented; do not let telemetry invent a separate answer. - -#### Telemetry sleep parents — fix required - -Retry sleeps can occur under turn or checkpoint scopes, while the current schema permits only operation parents. Either add turn/checkpoint as allowed parents or explicitly pass operation-level context to sleeps. Prefer schema parents matching actual call structure. - -#### `compaction_end.fromHook` without a result — fix required - -Declined, pre-source aborted, and early failed compactions have no result provenance. Make structural end events discriminated: completed carries `entry` and `fromHook`; declined/aborted omit both; failed carries error and omits result provenance. - -#### Async callbacks outside Effects — unresolved contract detail - -`systemPrompt`, `toProviderMessages`, and entry projectors may be async and can perform external I/O outside the complete effect boundary. Prefer a contract that these callbacks are deterministic/idempotent computation with no externally visible side effects and may repeat. Effectful interception should use hooks. Also define the system-prompt preview supplied to `before_run` versus per-request evaluation. - -#### “Operations never throw” wording — fix required - -Expected caller errors resolve through `Result`; storage faults, close, and invariant defects may reject. Correct the public API comment accordingly. - -#### Session-ordered durable events — unresolved - -See usage ordering above and section 7.13. Non-durable stream/tool lifecycle must not be reordered merely to match storage `seq`. - -### 8.3 Storage and fork findings - -#### Read-only normalized-v3 fork has no configuration — unresolved - -A normalized-v3 `main` is unconfigured until harness attachment, while current fork rules require copying current configuration. Options: permit an unconfigured destination `main`, require a seed in fork options, or require harness attachment before fork. The earlier recommendation was to allow an unconfigured destination and seed it on first attachment, but this is not yet accepted. - -#### JSONL fork versus active source queue — unresolved - -A coherent fork must order its snapshot with concurrent source appends. The repository currently claims not to retain open storage instances. Possible resolution: accept the open `Session` as fork source and enqueue a private snapshot operation on its mutation queue. Validate against existing repository API before deciding. - -#### JSONL conversion wording — documentation fix - -Clarify that conversion writes a temporary file and atomically renames it over the original path; the final directory and filename do not change. - -#### SQLite lane-move action — schema fix - -The proposed/current `LogItem` distinguishes lane `create` and `move`, but the shown `lane_moves` table lacks an action column. Add it or define an unambiguous derivation. Explicit column is simpler. - -#### Storage efficiency of total state records — measure - -Total state records can repeat large queue payloads and tool batches. Do not weaken semantics preemptively. Add size benchmarks for long runs, large tool batches, and repeated queued writes. If needed, optimize physical backend representation while keeping `getCurrentOperation()` equivalent to one `OperationRecord` plus one total `OperationStateRecord`. - -### 8.4 Public API and identity follow-ups - -#### Missing model/provider replacement escape hatch — unresolved and important - -Idle lane configuration can be repaired with `setModel(validModel)`. An open operation may have captured the missing reference in its total state record; changing only lane configuration must not silently alter already-started generation under the prior contract. Possible APIs include an explicit operation-state repair method or runtime model-reference override registry. The API must be explicit, durable where necessary, and limited to missing identities rather than general in-flight mutation. - -#### Undeclared tool context generic — note for implementation - -`AgentHarnessOptions.toolContext` uses `TContext` while the shown interface is not generic. Make the options/harness/tool types consistently generic or use `unknown`. Do not block state-machine design on this. - -#### Per-entry effective usage query — note - -The design defines ledger-adjusted effective entry cost but exposes only immutable entry snapshots and session totals. Add a query such as `getEntryUsage(entryId)` if consumers need it. Defer until ledger API implementation. - -#### Adjustment `runId` — note - -Public `recordUsage()` cannot supply `runId` although adjustment records permit one. Prefer deriving the current operation ID on the lane mutation line when present rather than allowing arbitrary caller-provided operation IDs. - -#### Wide deferred-fetch return type — note - -The landed `Models.fetchDeferred()` type may return wide `AssistantMessage`. The harness adapter must reject/narrow a final `pending` value before hooks, events, persistence, or state settlement. Do not assign unrelated pi-ai work without checking the landed API. - -#### Temporary `HarnessNotImplemented` — note - -Define it as a scaffold-only promise rejection outside final public `Result` unions. Remove it operation by operation as owning packages land. - -#### Application message schema registry — note - -J6 requires runtime schemas for application-defined `AgentMessage` variants but no registration API is specified. A likely surface is immutable Session/repository open options keyed by the custom discriminator. Resolve with storage schema work. - -### 8.5 Work-package and document follow-ups - -#### R3 versus H0 main initialization — fix plan - -R3 owns restore but final reduction requires an initialized total lane configuration; H0 currently owns fresh/v3 main initialization later. Move one-time main seed initialization into R3 or restructure dependencies so restore never receives an unconfigured lane. “Restore writes nothing” should mean after optional first attachment initialization. - -#### D0 reservation marker — note - -The track prose reserves D0 indirectly but the package lacks the standard immediate reservation marker. Add the marker or document a track-level reservation exception. - -#### SQLite search follow-ups — assign owner - -Search completion, cursor/limit support, and indexed `findEntries` work need an explicit unchecked package owner, likely O4, or must be marked non-normative. - -#### Required reading — add reducer/current-state implementation - -The old required-reading list omits the reducer despite several packages depending on it. If the redesign lands, replace that reading with the new operation-state transition module and keep the old reducer only as pre-convergence history. - -#### Preserve implementation boundaries - -Telemetry fixes, public type cleanup, fork behavior, and work-package ownership are not reasons to reintroduce implicit operation reduction. Track them separately from the state-machine core. - -### 8.6 Validation required before adoption - -Prototype the total-state-record model against these traces before replacing the canonical design: - -1. successful assistant generation; -2. retryable generation and crash with missing settlement; -3. overflow compaction requiring another assistant; -4. blocked tool versus abort in both commit orders; -5. started safe/unsafe tool crash and recovery; -6. terminal failure plus deferred user write; -7. terminal failure plus consumed steer followed by crash; -8. repeated application-driven deferred resumes with pending/ready/error results; -9. manual compaction empty/prepared/generated/hook paths; -10. summarized navigation, abort before final transaction, and atomic completion; -11. repeated abort before effect, during effect, and after finish; -12. missing identities for idle operation calls and resume. - -For every external effect, test crash before intent, after intent, and after atomic settlement. For every public race, test both lane-mutation orders. Compare automatic and manual drive durable state records and outcomes. From 8431bfbead9a9a901ecea860e989e27799cb1d0d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 08:45:04 +0200 Subject: [PATCH 092/284] docs(agent): fix harness-v3 cold-read audit findings --- packages/agent/docs/harness-v3.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index a332b242b8e..8bf9d8df9e9 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -70,7 +70,7 @@ lane.prompt("what changed in auth last week?") What happens, in order: 1. **Acceptance.** The harness validates, runs the `before_run` hook, and commits one transaction: the user-message entry, the operation's `op.meta` register, and its first `op.state` — *"I am at a checkpoint, and I need an assistant response."* -2. **Intent.** It commits a second transaction: *"I am about to make a provider request. The response will be entry `0195c8d1-53a0-7c44-…` and the usage row will be `0195c8d1-53a0-7d18-…`."* Both ids are minted now; nothing has been sent yet. +2. **Intent.** After an internal ready-state commit, it commits the request intent: *"I am about to make a provider request. The response will be entry `0195c8d1-53a0-7c44-…` and the usage row will be `0195c8d1-53a0-7d18-…`."* Both ids are minted now; nothing has been sent yet. 3. **The request.** Streaming happens. This is the only part that is not durable. 4. **Settlement.** One transaction commits the response entry, its usage row, and the next state: *"the response has tool calls; here is the batch plan, with result ids already assigned."* 5. Tool calls follow the same intent → effect → settlement shape, one pair of commits each. @@ -391,8 +391,8 @@ Every settled provider attempt writes one `UsageRow` — successful, failed, ret - `entryId` names the entry the cost belongs to, when there is one. Structural (summary) attempts that fail before producing an entry, and standalone adjustments, have none. - `adjustment: true` marks a caller-supplied reconciliation (`recordUsage`, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix C). -- Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, and import aggregates mint their ids at commit; nothing reserves them. -- `getStats()` is a maintained projection over the ledger and the entry count. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). There is no ledger scan: totals come from the projection, and individual rows reach the application through the `usage` event at commit time (§5.5). +- Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, hook-supplied compaction/navigation usage rows (§3.9, §3.10), and import aggregates mint their ids at commit; nothing reserves them. A wall-clock-minted usage row may therefore land in a later partition than its follower-minted entry; per-period accounting tolerates that skew (Appendix D). +- `getStats()` is a maintained projection over the ledger and the message-entry count — `messageCount` counts `message` entries only, not compactions, summaries, or custom entries. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). There is no ledger scan: totals come from the projection, and individual rows reach the application through the `usage` event at commit time (§5.5). ## 1.7 Backends @@ -684,7 +684,8 @@ Setting a fact to `undefined` deletes its register — real deletion, not a tomb ```ts interface BranchScan { - start?: string; // default: the view's lane leaf + start?: string; // required at the Storage layer; the Session + // tree view defaults it to the view's lane leaf stopAtType?: EntryType; // scan ends after the first match, inclusive stopAtId?: string; type?: EntryType; @@ -927,7 +928,7 @@ The old `QueuedInput { nodeId, valueId }` and `PendingWrite` pairs are gone: a q `latestAssistantEntryId` updates in the same settlement transaction as every assistant generation or deferred-fetch response. It lets finish and resume construct results/events without a branch scan. A tool batch retains its producing turn id while tool work remains active. -Any transition that appends conversational input or tool results and requires another assistant writes a checkpoint with `need_assistant(false)` and the appended entry as `triggerEntryId`. An unprojected custom write preserves the current checkpoint, including trigger and overflow flag. Entering threshold compaction first copies the checkpoint to `resumeAfter` with `thresholdCheckedTriggerEntryId = triggerEntryId`; decline, empty preparation, success, and crash therefore cannot recheck the same boundary. +Any transition that appends conversational input or tool results and requires another assistant writes a checkpoint with `need_assistant(false)` and the appended entry as `triggerEntryId`. A `may_finish` checkpoint sets `triggerEntryId` to the entry that caused the boundary: the settled response for a `stop`/genuine-`length` settlement (§3.7), the newest result entry for an all-terminating tool batch (§3.8) — so threshold dedup (§3.12) and restore validation (§3.3) always name an existing entry. An unprojected custom write preserves the current checkpoint, including trigger and overflow flag. Entering threshold compaction first copies the checkpoint to `resumeAfter` with `thresholdCheckedTriggerEntryId = triggerEntryId`; decline, empty preparation, success, and crash therefore cannot recheck the same boundary. ### Generation @@ -941,6 +942,10 @@ interface GenerationContext { configuration: LaneConfiguration; streamOptions: AgentHarnessStreamOptions; retryPolicy: NormalizedRetryPolicy; + /** Copied from the producing checkpoint's need_assistant continuation so a + settlement classified after crash-restore still knows whether overflow + recovery was already spent (§3.7, §3.9). */ + overflowRecoveryUsed: boolean; } type Generation = @@ -1085,7 +1090,7 @@ stateDiagram-v2 tools --> checkpoint : batch complete compaction --> checkpoint : resumeAfter restored - compaction --> failure_drain : overflow compaction declined or failed + compaction --> failure_drain : overflow declined; threshold/overflow generation failed deferred --> deferred : poll returns pending deferred --> tools : ready response with calls @@ -1109,9 +1114,12 @@ compaction: deciding ──hook declines───────────→ te ──hook selects generation─→ generating ──→ terminal TX (completed|failed) navigation: ready_to_commit ───────────────────→ terminal TX (completed) - summary.deciding ──→ generating ───→ terminal TX (completed) + summary.deciding ──hook declines───→ terminal TX (declined; no move) + ──→ generating ───→ terminal TX (completed|failed) ``` +A declined summarized navigation moves nothing: the leaf stays at the source, and the terminal transaction records outcome `declined`. Abort before any structural commit finishes `aborted`, likewise without a move (§4.6). + ## 3.6 Acceptance | From | Trigger | Transaction | @@ -1125,7 +1133,7 @@ Captured `nextRun` items already have their payloads in `pending.entry` register Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. -Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, or summarize from root), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve). Prompt allocates its operation id before `before_run` so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists. +Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve). Prompt allocates its operation id before `before_run` so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists. **Acceptance must observe `currentOperationId === null`.** Because acceptance is on the lane mutation line, this is validation, not compare-and-swap. @@ -1416,6 +1424,7 @@ type EffectKey = string; // deterministic from durable step/attempt or assistant /** Process-local leases captured before intent; never persisted or exposed. */ type RuntimeProviderLease = ModelRequestLease; +/** The context-bound AgentHarnessTool adapter, exposed as a plain AgentTool. */ interface RuntimeToolLease { tool: AgentTool } interface RuntimeAssistantLease { provider: RuntimeProviderLease; @@ -1428,7 +1437,7 @@ interface DriveState { deferredPollsRemaining: 0 | 1; running: Map; /** One context/tool-definition snapshot per live or restored batch. */ - toolBatches: Map>; + toolBatches: Map>; // key: assistantEntryId /** Process-local best-effort attempts; reopen may attempt again. */ deferredCancellations: Set; } From cf5f35216bc8e13087f16163f5f868b52872e8a5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 09:00:17 +0200 Subject: [PATCH 093/284] docs(agent): record open harness-v3 audit findings --- .../agent/docs/harness-v3-audit-findings.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 packages/agent/docs/harness-v3-audit-findings.md diff --git a/packages/agent/docs/harness-v3-audit-findings.md b/packages/agent/docs/harness-v3-audit-findings.md new file mode 100644 index 00000000000..7c5efcb8c64 --- /dev/null +++ b/packages/agent/docs/harness-v3-audit-findings.md @@ -0,0 +1,30 @@ +# harness-v3.md — open audit findings (second cold read, gpt-5.6-sol) + +Working file: fix each in `harness-v3.md`, check it off, delete this file when empty. +First cold-read round (10 findings) is already fixed (`8431bfbea`). + +## P0 + +1. **Retention contradicts the core storage model.** §0.5 "there is no third place" / inventory "carries no authority"; §1.1 entries/usage "never deleted" — vs §6.2 ledger rows disappearing with inventory standing in, §6.6 authoritative header aggregate. Fix: make retention an explicit scoped exception; define authoritative aggregate storage and the exact `getStats()` formula (live sum + retired aggregates). +2. **SettlementKernel cannot perform its claimed settlement.** §7.4 stores only untyped id arrays; synthetic settlement needs entry kind, parent, tool call id/name, entry↔usage association, and operation-owned vs lane-owned pending separation. Fix: discriminated pending-effect records + separated pending id lists, sufficient to construct valid entries and `LaneLastResult`. +3. **Retention preflight can drop entries open-state validation requires.** §6.3 pins only reserved ids; §3.3/§4.4 need prompt, trigger, latest-assistant, completed-result, source/target ids to resolve. Fix: preflight pins every materialized and reserved id directly referenced by each open operation (op.meta included). + +## P1 + +4. **JSONL snapshot compaction vs sequence continuity.** §1.7 requires persisted seq continuity; compaction leaves gaps. Fix: snapshot header carries a seq high-water mark; validate monotonic snapshot state + consecutive post-snapshot commits only. +5. **Retention-marker APIs contradict each other.** §2.5 finders return `truncatedAt`; §5.3 types return plain entries; §6.4 says `Entry[]` stays; `getRetentionBoundary` missing from the §5.3 interface; paged results can't derive the marker. Fix: expose `getRetentionBoundary` in `SessionTree`, forbid marker inference from paged results, align §2.5 wording. +6. **LaneExpired has no usable public contract.** Absent from §5.1 result unions; getters have no expiry shape; "always admitted" navigation undefined for `summarize:true` (needs the expired source). Fix: add to affected unions; restrict rebase to unsummarized navigation. +7. **Terminal prefix cleanup unsupported.** `Write` deletes exact keys only; `listRegisters` lists whole namespaces. Fix: add prefix listing/deletion to Storage, or retain owned keys durably for exact deletion. +8. **Deferred settlement/recovery lacks a complete transition table.** Old R/U abandonment vs synthetic settlement, poll numbering on replacement, ready-with-tools, pending/error transactions unstated. Fix: full deferred transition table (base spec §3.7 deferred rows + jot part 4 are the sources). +9. **Precise rewrite not implementable.** Redaction/legacy-id migration defined without old→new id remapping for parents, leaves, labels, fromId, ledger entryId, registers, tail writes. Fix: specify the id map and atomic reference transformation, including writes admitted during the copy. +10. **Restore pseudocode can't hydrate what validation requires.** `directEntryIds` lacks `op.meta` (prompt ids), `sourceLeafId`, navigation source/target. Fix: pass `meta.value`; enumerate those ids in hydration + validation. +11. **Register write typing loses namespace/value relationship.** §1.4 `set` takes `value: JsonValue`. Fix: mapped discriminated union keyed by namespace; serialization validation at admission. +12. **Close vs non-Result signatures.** §4.8 `Err(Closed)` for unaccepted calls, but setters return `Promise`, appends `Promise`. Fix: state these reject with `HarnessClosed`. +13. **Invariant 19 impossible as persistent invariant.** aborted-implies-cancelled unverifiable after terminal state deletion / forks. Fix: scope to the committing transaction. +14. **`BranchSummaryEntry.fromId` undefined.** Fix: define as the pre-navigation source leaf; name it in §3.10's publication TX. +15. **Empty prompts have no valid acceptance state.** `[]` prompt + no injections + no captured nextRun → no newest entry. Fix: reject with `InvalidMessage` when acceptance would append nothing. + +## P2 + +16. **"No query may be a table scan" overstated** vs `scanEntries`. Fix: restrict to execution/recovery and branch hot paths. +17. **Provenance: `AgentEventSink`** is in `src/agent-loop.ts`, not `src/types.ts`. Fix list in §0.7. From 9cb7f493e7b4a444ed66fbcb681d9e66c454eb3d Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 11 Aug 2026 09:03:42 +0200 Subject: [PATCH 094/284] docs(coding-agent): document AI_AGENT process marker closes #7747 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/environment-variables.md | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 12e4349d4e8..759c1c1a6b8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. +- Documented the generic `AI_AGENT=pi` process marker and how it differs from `PI_CODING_AGENT=true` ([#7747](https://github.com/earendil-works/pi/issues/7747)). ## [0.84.1] - 2026-08-07 diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 072744ca86b..413bd259b6e 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -3,14 +3,19 @@ Pi uses environment variables in three ways: - Variables such as `PI_OFFLINE` configure the Pi process. -- Pi sets `PI_CODING_AGENT` so child processes can detect that they run inside Pi. +- Pi sets process markers so child processes can identify Pi as the launching agent. - Commands run by the LLM-callable bash tool receive `PI_*` variables describing the current session. Provider API-key variables are documented separately in [Providers](providers.md#environment-variables-or-auth-file). ## Process Marker -The CLI and RPC entry points set `PI_CODING_AGENT=true`. Child processes inherit it and can use it to detect that they run inside Pi. It is not session-specific and is not set automatically when Pi is embedded through the SDK. +The CLI and RPC entry points set two process markers: + +- `AI_AGENT=pi` is a generic marker that lets tooling identify Pi as the agent that launched the process. +- `PI_CODING_AGENT=true` is Pi-specific and lets child processes detect that they run inside Pi. + +Child processes inherit both markers. They are not session-specific and are not set automatically when Pi is embedded through the SDK. ## Bash Tool Session Environment From 75c7fd6623f19a1331d27d6ac0060d8bce890c84 Mon Sep 17 00:00:00 2001 From: muyiyr <940955635@qq.com> Date: Tue, 11 Aug 2026 15:07:12 +0800 Subject: [PATCH 095/284] fix(ai): declare Cloudflare Responses strict tools (#7934) Fixes #7896 --- packages/ai/scripts/generate-models.ts | 5 +- .../ai/test/openai-responses-compat.test.ts | 52 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 62be5ac9b91..744ced66a76 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -732,7 +732,10 @@ function applyOpenAICompletionsCompatMetadata(model: Model): void { } function applyStrictToolCompatMetadata(model: Model): void { - if (model.provider === "openai" && model.api === "openai-responses") { + if ( + (model.provider === "openai" || model.provider === "cloudflare-ai-gateway") && + model.api === "openai-responses" + ) { model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsStrictMode: true }; } else if (model.provider === "anthropic" && model.api === "anthropic-messages") { mergeAnthropicMessagesCompat(model, { supportsStrictTools: true }); diff --git a/packages/ai/test/openai-responses-compat.test.ts b/packages/ai/test/openai-responses-compat.test.ts index cabab8258c5..c98b2ed5562 100644 --- a/packages/ai/test/openai-responses-compat.test.ts +++ b/packages/ai/test/openai-responses-compat.test.ts @@ -9,6 +9,7 @@ type CapturedHeaders = Headers | string[][] | Record; } function getHeader(headers: CapturedHeaders, name: string): string | null { @@ -153,6 +154,57 @@ describe("openai-responses provider defaults", () => { }); }); + it("sets strict mode explicitly for Cloudflare OpenAI Responses tools", async () => { + const model = getModel("cloudflare-ai-gateway", "gpt-5.6-sol"); + let capturedPayload: CapturedResponsesPayload | undefined; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + const stream = streamOpenAIResponses( + model, + { + messages: [{ role: "user", content: "Use a tool.", timestamp: Date.now() }], + tools: [ + { + name: "ordinary", + description: "An ordinary tool", + parameters: Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + }), + }, + { + name: "constrained", + description: "A constrained tool", + parameters: Type.Object({ value: Type.String() }), + constrainedSampling: { type: "json_schema", strict: "prefer" }, + }, + ], + }, + { + apiKey: "test-key", + onPayload: (payload) => { + capturedPayload = payload as CapturedResponsesPayload; + }, + }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + expect(model.compat?.supportsStrictMode).toBe(true); + expect(capturedPayload?.tools).toEqual([ + expect.objectContaining({ name: "ordinary", strict: false }), + expect.objectContaining({ name: "constrained", strict: true }), + ]); + }); + it.each([ "gpt-5.1", "gpt-5.2", From a4453b79bb8d66b5f385b28ae0e33843c947504a Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:07:53 +0200 Subject: [PATCH 096/284] fix: sqlite time to number --- packages/agent/src/harness/session/search.ts | 4 +-- .../src/sqlite/migrations/001_initial.sql | 6 ++--- .../sqlite-node/src/sqlite/repo.ts | 26 +++++-------------- .../sqlite-node/src/sqlite/search-backend.ts | 2 +- .../src/sqlite/storage/branch-entries.ts | 2 +- .../sqlite-node/src/sqlite/storage/entries.ts | 4 +-- .../sqlite-node/src/sqlite/storage/records.ts | 4 +-- .../src/sqlite/storage/sessions.ts | 6 ++--- .../sqlite-node/test/search.test.ts | 2 ++ 9 files changed, 23 insertions(+), 33 deletions(-) diff --git a/packages/agent/src/harness/session/search.ts b/packages/agent/src/harness/session/search.ts index 0bd44c44cc1..d75a606eb99 100644 --- a/packages/agent/src/harness/session/search.ts +++ b/packages/agent/src/harness/session/search.ts @@ -10,7 +10,7 @@ export interface SessionSearchOptions { export interface SessionSearchHit { metadata: TMetadata; entryId: string; - timestamp: string; + timestamp: number; snippet?: string; score?: number; } @@ -53,7 +53,7 @@ class ScanningSessionSearch hits.push({ metadata, entryId: entry.id, - timestamp: new Date(entry.timestamp).toISOString(), + timestamp: entry.timestamp, snippet: payload, }); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql index 706b0b04df9..fc9e277e26a 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql +++ b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, + created_at INTEGER NOT NULL, cwd TEXT NOT NULL, parent_session_id TEXT NULL, metadata TEXT NULL @@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS entries ( id TEXT NOT NULL, parent_id TEXT NULL, type TEXT NOT NULL, - timestamp TEXT NOT NULL, + timestamp INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (session_id, id), UNIQUE (session_id, seq) @@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS records ( run_id TEXT NULL, type TEXT NOT NULL, op_kind TEXT NULL, - timestamp TEXT NOT NULL, + timestamp INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (session_id, id), UNIQUE (session_id, seq) diff --git a/packages/session-backends/sqlite-node/src/sqlite/repo.ts b/packages/session-backends/sqlite-node/src/sqlite/repo.ts index 8135d7e3271..81f86bea964 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/repo.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/repo.ts @@ -175,14 +175,6 @@ function configureSqliteDatabase(db: SqliteDatabase): void { sql`PRAGMA busy_timeout=5000`.exec(db); } -function timestampToText(timestamp: number): string { - return new Date(timestamp).toISOString(); -} - -function timestampFromText(timestamp: string): number { - return Date.parse(timestamp); -} - function entryRowFromCached(row: CachedBranchEntryRow): EntryRow { return { ...row, seq: row.entry_seq, type: row.type as Entry["type"] }; } @@ -198,9 +190,7 @@ function readObjectPayload(row: EntryRow): Record { function decodeEntry(row: EntryRow): Entry { try { const payload = readObjectPayload(row); - const timestamp = timestampFromText(row.timestamp); - if (!Number.isFinite(timestamp)) throw new Error(`Invalid timestamp ${row.timestamp}`); - const base = { id: row.id, seq: row.seq, parentId: row.parent_id, timestamp }; + const base = { id: row.id, seq: row.seq, parentId: row.parent_id, timestamp: row.timestamp }; switch (row.type) { case "message": if (typeof payload.message !== "object" || payload.message === null) throw new Error("Missing message"); @@ -283,14 +273,12 @@ function recordOpKind(record: NewRecord): string | undefined { return record.type === "operation_started" ? record.intent.kind : undefined; } -function decodeRecord(row: { seq: number; timestamp: string; payload: string }): LaneRecord { +function decodeRecord(row: { seq: number; timestamp: number; payload: string }): LaneRecord { try { - const timestamp = timestampFromText(row.timestamp); - if (!Number.isFinite(timestamp)) throw new Error(`Invalid timestamp ${row.timestamp}`); return { ...(JSON.parse(row.payload) as object), seq: row.seq, - timestamp, + timestamp: row.timestamp, } as LaneRecord; } catch (error) { throw new SessionError( @@ -476,7 +464,7 @@ class SqliteSessionStorage implements SessionStorage { id: committed.id, parentId: committed.parentId, type: committed.type, - timestamp: timestampToText(committed.timestamp), + timestamp: committed.timestamp, payload: JSON.stringify(entryPayload(committed)), }); setLaneLeaf(this.db, this.metadata.id, lane, committed.id); @@ -514,7 +502,7 @@ class SqliteSessionStorage implements SessionStorage { runId: recordRunId(record), type: record.type, opKind: recordOpKind(record), - timestamp: timestampToText(committed.timestamp), + timestamp: committed.timestamp, payload: JSON.stringify(record), }); if (record.type === "operation_finished") { @@ -739,7 +727,7 @@ export class SqliteSessionRepository const lease = db.transaction(() => { insertSessionRow(db, { id, - createdAt: timestampToText(createdAt), + createdAt, cwd: options.cwd, parentSessionId: options.parentSessionId, metadata: options.metadata, @@ -870,7 +858,7 @@ export class SqliteSessionRepository lease = db.transaction(() => { insertSessionRow(db, { id, - createdAt: timestampToText(createdAt), + createdAt, cwd: options.cwd, parentSessionId: options.parentSessionId ?? source.id, metadata, diff --git a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts index e22de3340a8..cf6976dfd6c 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts @@ -121,7 +121,7 @@ class SqliteSessionSearch implements SessionSearch { WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL ) WHERE session_search_fts MATCH ${query} AND (${cwd} IS NULL OR s.cwd = ${cwd}) - ORDER BY score`.all(db); + ORDER BY score`.all(db); const path = await this.getDatabasePath(); return rows.map((row) => ({ metadata: decodeSessionMetadata(row, path), diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts index 76016a71cdc..276ca89477a 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts @@ -14,7 +14,7 @@ export interface CachedBranchEntryRow { entry_seq: number; parent_id: string | null; type: Entry["type"]; - timestamp: string; + timestamp: number; payload: string; } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts index e76ef3cf6bb..2c5b9472c90 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts @@ -8,7 +8,7 @@ export interface EntryRow { id: string; parent_id: string | null; type: Entry["type"]; - timestamp: string; + timestamp: number; payload: string; } @@ -17,7 +17,7 @@ export interface NewEntryRow { id: string; parentId: string | null; type: Entry["type"]; - timestamp: string; + timestamp: number; payload: string; } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts index 1dc35722754..7c810c2c8a5 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts @@ -10,7 +10,7 @@ export interface RecordRow { run_id: string | null; type: string; op_kind: string | null; - timestamp: string; + timestamp: number; payload: string; } @@ -21,7 +21,7 @@ export interface NewRecordRow { runId?: string; type: string; opKind?: string; - timestamp: string; + timestamp: number; payload: string; } diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts index f4c54ecf59c..e954c0b38b1 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts @@ -4,7 +4,7 @@ import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts"; export interface SessionRow { id: string; - created_at: string; + created_at: number; metadata: string | null; cwd: string; parent_session_id: string | null; @@ -14,7 +14,7 @@ export interface SessionRow { export interface NewSessionRow { id: string; - createdAt: string; + createdAt: number; cwd: string; parentSessionId?: string; metadata?: Record; @@ -121,7 +121,7 @@ export function decodeSessionMetadata(row: SessionRow, path: string): SqliteSess const name = row.has_session_name === 0 ? undefined : parseSessionName(row.session_name, row.id); return { id: row.id, - createdAt: Date.parse(row.created_at), + createdAt: row.created_at, ...(name === undefined ? {} : { name }), cwd: row.cwd, path, diff --git a/packages/session-backends/sqlite-node/test/search.test.ts b/packages/session-backends/sqlite-node/test/search.test.ts index f9f5cec4ccc..497a5582c1f 100644 --- a/packages/session-backends/sqlite-node/test/search.test.ts +++ b/packages/session-backends/sqlite-node/test/search.test.ts @@ -30,8 +30,10 @@ describe("SQLite FTS5 session search", () => { await expect(search.search({ text: "auth", cwd: root })).resolves.toEqual([ expect.objectContaining({ entryId, + timestamp: expect.any(Number), metadata: expect.objectContaining({ id: "included", + createdAt: expect.any(Number), name: "Canonical name", metadata: { name: "application-owned" }, }), From b647d187932c76d4003728010daeed9c1b496a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=9D=E5=BF=83Yearth?= Date: Tue, 11 Aug 2026 16:49:19 +0800 Subject: [PATCH 097/284] fix(ai): detect DeepSeek base URLs case-insensitively (#7933) --- packages/ai/scripts/generate-models.ts | 4 ++-- packages/ai/src/api/openai-completions.ts | 4 ++-- packages/ai/test/openai-completions-tool-choice.test.ts | 8 +++++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 744ced66a76..95c939c7071 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -625,6 +625,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com"); const isTogetherReasoningOnly = isTogether && TOGETHER_REASONING_ONLY_MODELS.has(model.id); + const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com"); const isNonStandard = isNvidia || @@ -634,7 +635,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open baseUrl.includes("api.x.ai") || isTogether || baseUrl.includes("chutes.ai") || - baseUrl.includes("deepseek.com") || + isDeepSeek || isZai || isMoonshot || provider === "opencode" || @@ -643,7 +644,6 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open isCloudflareAiGateway || isAntLing; - const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const useMaxTokens = baseUrl.includes("chutes.ai") || isDeepSeek || diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 248d7356290..73e96e9402b 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1457,6 +1457,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com"); + const isDeepSeek = provider === "deepseek" || baseUrl.toLowerCase().includes("deepseek.com"); const isNonStandard = isNvidia || @@ -1466,7 +1467,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet baseUrl.includes("api.x.ai") || isTogether || baseUrl.includes("chutes.ai") || - baseUrl.includes("deepseek.com") || + isDeepSeek || isZai || isMoonshot || provider === "opencode" || @@ -1475,7 +1476,6 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet isCloudflareAiGateway || isAntLing; - const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const useMaxTokens = baseUrl.includes("chutes.ai") || isDeepSeek || diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index a2a47b7ca67..00ded1acf54 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1437,11 +1437,17 @@ describe("openai-completions tool_choice", () => { provider: "custom-deepseek", baseUrl: "https://api.deepseek.com", } satisfies Model<"openai-completions">; + const customUppercaseModel = { + ...customModel, + id: "custom-uppercase-deepseek-model", + name: "Custom Uppercase DeepSeek Model", + baseUrl: "https://API.DeepSeek.COM", + } satisfies Model<"openai-completions">; const nativeModels = [ getModel("deepseek", "deepseek-v4-flash")!, getModel("deepseek", "deepseek-v4-pro")!, ] as const; - const cases = [...nativeModels, customModel] as const; + const cases = [...nativeModels, customModel, customUppercaseModel] as const; for (const model of nativeModels) { expect(model.compat?.maxTokensField).toBe("max_tokens"); From 00121ed99939ba614ddda4c5739e49bc027d02d4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 11 Aug 2026 10:59:28 +0200 Subject: [PATCH 098/284] feat(tui): add fullscreen transcript search (#7913) Search rendered primary scroll-view content with configurable navigation and theme-aware highlighting. Preserve manual scrolling and buffer fragmented mouse input during search. --- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/docs/keybindings.md | 4 + packages/coding-agent/docs/themes.md | 10 +- packages/coding-agent/docs/tui.md | 4 +- .../src/modes/interactive/interactive-mode.ts | 3 + .../src/modes/interactive/theme/dark.json | 2 + .../src/modes/interactive/theme/light.json | 2 + .../modes/interactive/theme/theme-schema.json | 10 +- .../src/modes/interactive/theme/theme.ts | 35 ++- .../coding-agent/test/scrollbar-theme.test.ts | 24 +- .../coding-agent/test/test-theme-colors.ts | 3 + packages/tui/CHANGELOG.md | 8 + packages/tui/README.md | 2 +- packages/tui/src/alt-screen-search.ts | 157 ++++++++++++ packages/tui/src/components/scroll-view.ts | 39 ++- packages/tui/src/index.ts | 7 +- packages/tui/src/keybindings.ts | 20 ++ packages/tui/src/stdin-buffer.ts | 10 +- packages/tui/src/terminal.ts | 2 +- packages/tui/src/tui-alt-screen.ts | 237 +++++++++++++++++- packages/tui/test/keybindings.test.ts | 4 + packages/tui/test/stdin-buffer.test.ts | 24 ++ packages/tui/test/tui-alt-screen.test.ts | 100 ++++++++ 23 files changed, 680 insertions(+), 32 deletions(-) create mode 100644 packages/tui/src/alt-screen-search.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 759c1c1a6b8..22fa86f7046 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. ### Changed @@ -11,6 +12,10 @@ - Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. - Documented the generic `AI_AGENT=pi` process marker and how it differs from `PI_CODING_AGENT=true` ([#7747](https://github.com/earendil-works/pi/issues/7747)). +### Fixed + +- Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. + ## [0.84.1] - 2026-08-07 ### New Features diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 97e5365a682..a7f493d9ced 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -107,6 +107,10 @@ This routing remains configurable through the ordinary action bindings. For exam | `tui.altScreen.halfPageDown` | *(none)* | Scroll the transcript down by half a page | | `tui.altScreen.previousPrompt` | `ctrl+shift+up` | Jump to the previous marked message | | `tui.altScreen.nextPrompt` | `ctrl+shift+down` | Jump to the next marked message | +| `tui.altScreen.search` | `ctrl+shift+f` | Search the rendered transcript | +| `tui.altScreen.searchNext` | `enter`, `ctrl+g` | Select the next search match while searching | +| `tui.altScreen.searchPrevious` | `shift+enter`, `ctrl+shift+g` | Select the previous search match while searching | +| `tui.altScreen.searchClose` | `escape` | Close transcript search | | `tui.altScreen.top` | `home` | Scroll to the beginning of the transcript | | `tui.altScreen.bottom` | `end` | Scroll to the transcript end and follow new output | diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 2a00dee3708..f9855f87fae 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -72,6 +72,8 @@ vim ~/.pi/agent/themes/my-theme.json "thinkingText": "secondary", "selectedBg": "#2d2d30", "scrollbarThumb": "#555566", + "searchMatchBg": "#2d2d30", + "searchMatchText": "", "userMessageBg": "#2d2d30", "userMessageText": "", "customMessageBg": "#2d2d30", @@ -141,13 +143,13 @@ vim ~/.pi/agent/themes/my-theme.json - `name` is required, must be unique, and must not contain `/`. - `vars` is optional. Define reusable colors here, then reference them in `colors`. -- `colors` must define all 51 required tokens. `thinkingMax` is optional and falls back to `thinkingXhigh`; `scrollbarThumb` is optional and falls back to `selectedBg`. +- `colors` must define all 51 required tokens. `thinkingMax`, `scrollbarThumb`, and the two search highlight tokens are optional and use the fallbacks listed below. The `$schema` field enables editor auto-completion and validation. ## Color Tokens -Every theme must define all 51 required color tokens. `thinkingMax` and `scrollbarThumb` are optional for compatibility with existing themes; when omitted, they use `thinkingXhigh` and `selectedBg`, respectively. +Every theme must define all 51 required color tokens. The optional tokens preserve compatibility with existing themes: `thinkingMax` falls back to `thinkingXhigh`, `scrollbarThumb` and `searchMatchBg` fall back to `selectedBg`, and `searchMatchText` falls back to `text`. Other search matches use `searchMatchText` on `searchMatchBg` with an underline; the current match reverses that foreground/background pair and uses bold text. ### Core UI (11 colors) @@ -165,12 +167,14 @@ Every theme must define all 51 required color tokens. `thinkingMax` and `scrollb | `text` | Default text (usually `""`) | | `thinkingText` | Thinking block text | -### Backgrounds & Content (11 required, 1 optional) +### Backgrounds & Content (11 required, 3 optional) | Token | Purpose | |-------|---------| | `selectedBg` | Selected line background | | `scrollbarThumb` | Fullscreen scrollbar thumb background; optional, falls back to `selectedBg` | +| `searchMatchBg` | Transcript search match background and current-match text; optional, falls back to `selectedBg` | +| `searchMatchText` | Transcript search match text and current-match background; optional, falls back to `text` | | `userMessageBg` | User message background | | `userMessageText` | User message text | | `customMessageBg` | Extension message background | diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index d9f789296aa..31dbf58b448 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -431,7 +431,7 @@ renderResult(result, options, theme, context) { | Category | Colors | |----------|--------| -| General | `text`, `accent`, `muted`, `dim` | +| General | `text`, `accent`, `muted`, `dim`, `searchMatchText` | | Status | `success`, `error`, `warning` | | Borders | `border`, `borderAccent`, `borderMuted` | | Messages | `userMessageText`, `customMessageText`, `customMessageLabel` | @@ -444,7 +444,7 @@ renderResult(result, options, theme, context) { **Background colors** (`theme.bg(color, text)`): -`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` +`selectedBg`, `searchMatchBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` **For Markdown**, use `getMarkdownTheme()`: diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 943b9edbdab..03d015a5af8 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -343,7 +343,10 @@ interface InteractiveTuiOptions { export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen { const terminal = options.terminal ?? new ProcessTerminal(); if (options.tuiMode === "fullscreen") { + const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text)); return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { + searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)), + searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))), openUrl: openBrowser, onRightClickPaste: options.onRightClickPaste, }); diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 9db9cbd8b54..01d1e02a8b0 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -34,6 +34,8 @@ "selectedBg": "selectedBg", "scrollbarThumb": "selectedBg", + "searchMatchBg": "selectedBg", + "searchMatchText": "text", "userMessageBg": "userMsgBg", "userMessageText": "text", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 74ef3d1c57f..0fde42b8b73 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -33,6 +33,8 @@ "selectedBg": "selectedBg", "scrollbarThumb": "selectedBg", + "searchMatchBg": "selectedBg", + "searchMatchText": "text", "userMessageBg": "userMsgBg", "userMessageText": "text", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 039bdc15f31..5348ab7de7a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -34,7 +34,7 @@ }, "colors": { "type": "object", - "description": "Theme color definitions (thinkingMax and scrollbarThumb are optional and use compatible fallbacks)", + "description": "Theme color definitions (thinkingMax, scrollbarThumb, and search highlight colors are optional and use compatible fallbacks)", "required": [ "accent", "border", @@ -141,6 +141,14 @@ "$ref": "#/$defs/colorValue", "description": "Fullscreen scrollbar thumb background (falls back to selectedBg when omitted)" }, + "searchMatchBg": { + "$ref": "#/$defs/colorValue", + "description": "Transcript search match background and current-match text (falls back to selectedBg when omitted)" + }, + "searchMatchText": { + "$ref": "#/$defs/colorValue", + "description": "Transcript search match text and current-match background (falls back to text when omitted)" + }, "userMessageBg": { "$ref": "#/$defs/colorValue", "description": "User message background" diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index c6bb899d456..6fa0c8c922f 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -45,9 +45,11 @@ const ThemeJsonSchema = Type.Object({ dim: ColorValueSchema, text: ColorValueSchema, thinkingText: ColorValueSchema, - // Backgrounds & Content Text (11 required, 1 optional) + // Backgrounds & Content Text (11 required, 3 optional) selectedBg: ColorValueSchema, scrollbarThumb: Type.Optional(ColorValueSchema), + searchMatchBg: Type.Optional(ColorValueSchema), + searchMatchText: Type.Optional(ColorValueSchema), userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, customMessageBg: ColorValueSchema, @@ -119,6 +121,7 @@ export type ThemeColor = | "dim" | "text" | "thinkingText" + | "searchMatchText" | "userMessageText" | "customMessageText" | "customMessageLabel" @@ -158,12 +161,16 @@ export type ThemeColor = export type ThemeBg = | "selectedBg" | "scrollbarThumb" + | "searchMatchBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; +type OptionalThemeColor = "thinkingMax" | "searchMatchText"; +type OptionalThemeBg = "scrollbarThumb" | "searchMatchBg"; + type ColorMode = "truecolor" | "256color"; // ============================================================================ @@ -321,13 +328,18 @@ function resolveThemeColors>( return resolved as Record; } -function withThemeColorFallbacks( - colors: ThemeJson["colors"], -): ThemeJson["colors"] & { thinkingMax: ColorValue; scrollbarThumb: ColorValue } { +function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & { + thinkingMax: ColorValue; + scrollbarThumb: ColorValue; + searchMatchBg: ColorValue; + searchMatchText: ColorValue; +} { return { ...colors, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh, scrollbarThumb: colors.scrollbarThumb ?? colors.selectedBg, + searchMatchBg: colors.searchMatchBg ?? colors.selectedBg, + searchMatchText: colors.searchMatchText ?? colors.text, }; } @@ -344,9 +356,10 @@ export class Theme { private mode: ColorMode; constructor( - fgColors: Record, - bgColors: Record, string | number> & - Partial>, + fgColors: Record, string | number> & + Partial>, + bgColors: Record, string | number> & + Partial>, mode: ColorMode, options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, ) { @@ -355,7 +368,11 @@ export class Theme { this.sourceInfo = options.sourceInfo; this.mode = mode; this.fgColors = new Map(); - const colors = { ...fgColors, thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh }; + const colors = { + ...fgColors, + thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh, + searchMatchText: fgColors.searchMatchText ?? fgColors.text, + }; for (const [key, value] of Object.entries(colors) as [ThemeColor, string | number][]) { this.fgColors.set(key, fgAnsi(value, mode)); } @@ -363,6 +380,7 @@ export class Theme { const backgrounds = { ...bgColors, scrollbarThumb: bgColors.scrollbarThumb ?? bgColors.selectedBg, + searchMatchBg: bgColors.searchMatchBg ?? bgColors.selectedBg, }; for (const [key, value] of Object.entries(backgrounds) as [ThemeBg, string | number][]) { this.bgColors.set(key, bgAnsi(value, mode)); @@ -615,6 +633,7 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string const bgColorKeys: Set = new Set([ "selectedBg", "scrollbarThumb", + "searchMatchBg", "userMessageBg", "customMessageBg", "toolPendingBg", diff --git a/packages/coding-agent/test/scrollbar-theme.test.ts b/packages/coding-agent/test/scrollbar-theme.test.ts index 01dbdbcca73..f07bb9c8ae5 100644 --- a/packages/coding-agent/test/scrollbar-theme.test.ts +++ b/packages/coding-agent/test/scrollbar-theme.test.ts @@ -27,7 +27,7 @@ afterEach(() => { } }); -describe("scrollbar theme color", () => { +describe("optional fullscreen theme colors", () => { it("falls back to selectedBg when scrollbarThumb is omitted", () => { const themeJson = loadDarkTheme(); themeJson.name = "legacy-scrollbar-theme"; @@ -45,4 +45,26 @@ describe("scrollbar theme color", () => { const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe("\x1b[48;2;18;52;86m"); }); + + it("falls back to existing selection and text colors for search highlights", () => { + const themeJson = loadDarkTheme(); + themeJson.name = "legacy-search-theme"; + delete themeJson.colors.searchMatchBg; + delete themeJson.colors.searchMatchText; + + const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); + expect(loadedTheme.getBgAnsi("searchMatchBg")).toBe(loadedTheme.getBgAnsi("selectedBg")); + expect(loadedTheme.getFgAnsi("searchMatchText")).toBe(loadedTheme.getFgAnsi("text")); + }); + + it("uses explicitly configured search highlight colors", () => { + const themeJson = loadDarkTheme(); + themeJson.name = "custom-search-theme"; + themeJson.colors.searchMatchBg = "#112233"; + themeJson.colors.searchMatchText = "#223344"; + + const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); + expect(loadedTheme.getBgAnsi("searchMatchBg")).toBe("\x1b[48;2;17;34;51m"); + expect(loadedTheme.getFgAnsi("searchMatchText")).toBe("\x1b[38;2;34;51;68m"); + }); }); diff --git a/packages/coding-agent/test/test-theme-colors.ts b/packages/coding-agent/test/test-theme-colors.ts index 70da7b18921..4e7181f3558 100644 --- a/packages/coding-agent/test/test-theme-colors.ts +++ b/packages/coding-agent/test/test-theme-colors.ts @@ -222,6 +222,9 @@ function cmdTheme(themeName: string): void { console.log("\n--- Backgrounds ---"); console.log("userMessageBg:", theme.bg("userMessageBg", " Sample ")); + const searchMatch = theme.bg("searchMatchBg", theme.fg("searchMatchText", " Sample ")); + console.log("searchMatch:", theme.underline(searchMatch)); + console.log("searchCurrentMatch:", theme.bold(theme.inverse(searchMatch))); console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample ")); console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample ")); console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample ")); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1b30480b785..ec81ca0222e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,10 +2,18 @@ ## [Unreleased] +### Added + +- Added incremental primary-scroll-view search to the fullscreen TUI with configurable match styles, `Ctrl+Shift+F`, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. + ### Changed - Reduced alternate-screen per-frame allocation churn roughly 9-18x by painting full-width layout rows as direct line references instead of recompositing every visible row through ANSI/grapheme segmentation on each frame. +### Fixed + +- Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented SGR mouse input leaking into the search query. + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/tui/README.md b/packages/tui/README.md index dfcfc9ea550..58e82937081 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -121,7 +121,7 @@ if (isViewportTUI(tui)) { } ``` -Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. +Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. Press `Ctrl+Shift+F` to search its rendered content, `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G` to move between matches, and `Escape` to close search. `TuiAltScreenOptions.searchMatchStyle` and `searchCurrentMatchStyle` customize match highlighting. Layout geometry is rebuilt for each requested frame. Stateful components are retained, and their existing rendered-line caches remain effective. Calling `render(width)` directly on these layout components produces an unbounded document, which is also used when alt mode restores the main screen. diff --git a/packages/tui/src/alt-screen-search.ts b/packages/tui/src/alt-screen-search.ts new file mode 100644 index 00000000000..98926523f96 --- /dev/null +++ b/packages/tui/src/alt-screen-search.ts @@ -0,0 +1,157 @@ +import { Input } from "./components/input.ts"; +import type { Component, Focusable } from "./tui.ts"; +import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.ts"; + +const segmenter = getGraphemeSegmenter(); + +interface SearchSourceSpan { + row: number; + startCol: number; + endCol: number; +} + +export interface AltScreenSearchSegment { + row: number; + startCol: number; + endCol: number; +} + +export interface AltScreenSearchMatch { + segments: AltScreenSearchSegment[]; +} + +function appendMappedText( + text: string, + span: SearchSourceSpan | undefined, + corpus: { text: string; source: Array }, +): void { + corpus.text += text; + for (let index = 0; index < text.length; index++) corpus.source.push(span); +} + +function buildSearchCorpus(lines: readonly string[]): { + text: string; + source: Array; +} { + const corpus: { text: string; source: Array } = { text: "", source: [] }; + let pendingSeparator = false; + + for (let row = 0; row < lines.length; row++) { + const line = stripTerminalSequences(lines[row] ?? ""); + let column = 0; + for (const grapheme of segmenter.segment(line)) { + const text = grapheme.segment; + const width = visibleWidth(text); + if (/^\s+$/u.test(text)) { + if (corpus.text.length > 0) pendingSeparator = true; + column += width; + continue; + } + if (pendingSeparator) { + appendMappedText(" ", undefined, corpus); + pendingSeparator = false; + } + appendMappedText(text, { row, startCol: column, endCol: column + width }, corpus); + column += width; + } + if (corpus.text.length > 0) pendingSeparator = true; + } + + return corpus; +} + +function normalizeQuery(query: string): string { + return query.replace(/\s+/gu, " ").trim(); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] { + const normalizedQuery = normalizeQuery(query); + if (!normalizedQuery) return []; + + const corpus = buildSearchCorpus(lines); + const expression = new RegExp(escapeRegExp(normalizedQuery), "giu"); + const matches: AltScreenSearchMatch[] = []; + + for (const match of corpus.text.matchAll(expression)) { + const start = match.index; + const end = start + match[0].length; + const segments: AltScreenSearchSegment[] = []; + for (let index = start; index < end; index++) { + const span = corpus.source[index]; + if (!span) continue; + const previous = segments[segments.length - 1]; + if (previous && previous.row === span.row && span.startCol <= previous.endCol) { + previous.endCol = Math.max(previous.endCol, span.endCol); + } else { + segments.push({ ...span }); + } + } + if (segments.length > 0) matches.push({ segments }); + } + + return matches; +} + +export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string { + const first = match.segments[0]; + const last = match.segments[match.segments.length - 1]; + return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : ""; +} + +export class AltScreenSearchComponent implements Component, Focusable { + private readonly input = new Input(); + private readonly onQueryChange: (query: string) => void; + private resultCount = 0; + private resultIndex = -1; + private _focused = false; + + constructor(onQueryChange: (query: string) => void) { + this.onQueryChange = onQueryChange; + } + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.input.focused = value; + } + + setResult(index: number, count: number): void { + this.resultIndex = index; + this.resultCount = count; + } + + handleInput(data: string): void { + const previous = this.input.getValue(); + this.input.handleInput(data); + const query = this.input.getValue(); + if (query !== previous) this.onQueryChange(query); + } + + invalidate(): void { + this.input.invalidate(); + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const label = " Find transcript"; + const query = this.input.getValue(); + const status = !query + ? "" + : this.resultCount === 0 + ? "No matches " + : `${this.resultIndex + 1}/${this.resultCount} `; + const labelWidth = visibleWidth(label); + const statusWidth = visibleWidth(status); + const gap = " ".repeat(Math.max(1, safeWidth - labelWidth - statusWidth)); + const title = truncateToWidth(`${label}${gap}${status}`, safeWidth, ""); + const padding = " ".repeat(Math.max(0, safeWidth - visibleWidth(title))); + return [`\x1b[7m${title}${padding}\x1b[27m`, ...this.input.render(safeWidth)]; + } +} diff --git a/packages/tui/src/components/scroll-view.ts b/packages/tui/src/components/scroll-view.ts index f2c0b5fd7bd..a9a50f020a1 100644 --- a/packages/tui/src/components/scroll-view.ts +++ b/packages/tui/src/components/scroll-view.ts @@ -13,6 +13,11 @@ export interface ScrollViewOptions { scrollbarHideDelayMs?: number; } +export interface ScrollViewScrollToOptions { + /** Keep follow-end disabled even when the target is the current content end. */ + disableFollow?: boolean; +} + export class ScrollView extends Container { private readonly child: Component; private readonly followEnd: boolean; @@ -25,6 +30,7 @@ export class ScrollView extends Container { private contentHeight = 0; private currentViewportHeight = 0; private followingEnd: boolean; + private followSuppressedAtEnd = false; private requestRenderCallback: (() => void) | undefined; private transientScrollbarVisible = false; private scrollbarActive = false; @@ -110,14 +116,24 @@ export class ScrollView extends Container { this.markScrollbarActivity(); } - scrollTo(scrollTop: number): void { + scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void { const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : this.currentScrollTop; const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); const next = Math.max(0, Math.min(maxScrollTop, requested)); - if (next === this.currentScrollTop) return; + const nextFollowSuppressedAtEnd = options.disableFollow === true && next === maxScrollTop; + const nextFollowingEnd = !nextFollowSuppressedAtEnd && this.followEnd && next === maxScrollTop; + if ( + next === this.currentScrollTop && + nextFollowingEnd === this.followingEnd && + nextFollowSuppressedAtEnd === this.followSuppressedAtEnd + ) { + return; + } + const moved = next !== this.currentScrollTop; this.currentScrollTop = next; - this.followingEnd = this.followEnd && next === maxScrollTop; - this.markScrollbarActivity(); + this.followingEnd = nextFollowingEnd; + this.followSuppressedAtEnd = nextFollowSuppressedAtEnd; + if (moved) this.markScrollbarActivity(); this.requestRenderCallback?.(); } @@ -128,12 +144,12 @@ export class ScrollView extends Container { const start = this.followingEnd ? maxScrollTop : this.currentScrollTop; const next = Math.max(0, Math.min(maxScrollTop, start + requested)); const moved = next - start; + const wasFollowingEnd = this.followingEnd; this.currentScrollTop = next; this.followingEnd = this.followEnd && next === maxScrollTop; - if (moved !== 0) { - this.markScrollbarActivity(); - this.requestRenderCallback?.(); - } + this.followSuppressedAtEnd = false; + if (moved !== 0) this.markScrollbarActivity(); + if (moved !== 0 || this.followingEnd !== wasFollowingEnd) this.requestRenderCallback?.(); return requested - moved; } @@ -143,6 +159,7 @@ export class ScrollView extends Container { this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight); this.currentScrollTop = 0; this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight; + this.followSuppressedAtEnd = false; if (changed) { this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -154,6 +171,7 @@ export class ScrollView extends Container { const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd; this.currentScrollTop = next; this.followingEnd = this.followEnd; + this.followSuppressedAtEnd = false; if (changed) { this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -167,7 +185,10 @@ export class ScrollView extends Container { const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); if (this.followingEnd) this.currentScrollTop = maxScrollTop; else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop)); - if (this.followEnd && this.currentScrollTop === maxScrollTop) this.followingEnd = true; + if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false; + if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) { + this.followingEnd = true; + } if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar(); } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 00e9d414f44..0d5a4a1093b 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -18,7 +18,12 @@ export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; -export { ScrollView, type ScrollViewOptions, type ScrollViewScrollbar } from "./components/scroll-view.ts"; +export { + ScrollView, + type ScrollViewOptions, + type ScrollViewScrollbar, + type ScrollViewScrollToOptions, +} from "./components/scroll-view.ts"; export { type SelectItem, SelectList, diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index b1a086d6763..421657bfa19 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -48,6 +48,10 @@ export interface Keybindings { "tui.altScreen.halfPageDown": true; "tui.altScreen.previousPrompt": true; "tui.altScreen.nextPrompt": true; + "tui.altScreen.search": true; + "tui.altScreen.searchNext": true; + "tui.altScreen.searchPrevious": true; + "tui.altScreen.searchClose": true; "tui.altScreen.top": true; "tui.altScreen.bottom": true; } @@ -175,6 +179,22 @@ export const TUI_KEYBINDINGS = { defaultKeys: "ctrl+shift+down", description: "Jump to next semantic prompt", }, + "tui.altScreen.search": { + defaultKeys: "ctrl+shift+f", + description: "Search the primary scroll view", + }, + "tui.altScreen.searchNext": { + defaultKeys: ["enter", "ctrl+g"], + description: "Select the next search match", + }, + "tui.altScreen.searchPrevious": { + defaultKeys: ["shift+enter", "ctrl+shift+g"], + description: "Select the previous search match", + }, + "tui.altScreen.searchClose": { + defaultKeys: "escape", + description: "Close transcript search", + }, "tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" }, "tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" }, } as const satisfies KeybindingDefinitions; diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index a8b0b847892..b6c483fe9dc 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -20,6 +20,7 @@ import { EventEmitter } from "events"; const ESC = "\x1b"; +const LONE_ESCAPE_TIMEOUT_MS = 10; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; @@ -256,8 +257,8 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain export type StdinBufferOptions = { /** - * Maximum time to wait for sequence completion (default: 10ms) - * After this time, the buffer is flushed even if incomplete + * Maximum time to wait for sequence completion (default: 50ms). + * A lone Escape uses at most 10ms to preserve keyboard responsiveness. */ timeout?: number; }; @@ -281,7 +282,7 @@ export class StdinBuffer extends EventEmitter { constructor(options: StdinBufferOptions = {}) { super(); - this.timeoutMs = options.timeout ?? 10; + this.timeoutMs = options.timeout ?? 50; } public process(data: string | Buffer): void { @@ -376,13 +377,14 @@ export class StdinBuffer extends EventEmitter { } if (this.buffer.length > 0) { + const timeoutMs = this.buffer === ESC ? Math.min(this.timeoutMs, LONE_ESCAPE_TIMEOUT_MS) : this.timeoutMs; this.timeout = setTimeout(() => { const flushed = this.flush(); for (const sequence of flushed) { this.emitDataSequence(sequence); } - }, this.timeoutMs); + }, timeoutMs); } } diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 1014b80fcc3..68f8b1a355c 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -183,7 +183,7 @@ export class ProcessTerminal implements Terminal { * to handle the case where the response arrives split across multiple events. */ private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + this.stdinBuffer = new StdinBuffer(); // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index fa7063e4c68..c3e9d6a27bd 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -1,3 +1,9 @@ +import { + AltScreenSearchComponent, + type AltScreenSearchMatch, + findAltScreenSearchMatches, + getAltScreenSearchMatchKey, +} from "./alt-screen-search.ts"; import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts"; import { ScrollView } from "./components/scroll-view.ts"; import { getKeybindings } from "./keybindings.ts"; @@ -26,6 +32,7 @@ import { type Component, CURSOR_MARKER, compositeTuiLine, + type OverlayHandle, TuiBase, type TuiStopOptions, VIEWPORT_TUI, @@ -114,11 +121,34 @@ interface ScrollbarTarget { geometry: ScrollbarGeometry; } +type SearchSelectionMode = "query" | "retain" | "next" | "previous"; + +interface ActiveSearch { + component: AltScreenSearchComponent; + overlay?: OverlayHandle; + query: string; + matches: AltScreenSearchMatch[]; + selectedIndex: number; + selectedKey?: string; + anchorRow: number; + selectionMode: SearchSelectionMode; +} + +interface SearchHighlightRange { + startCol: number; + endCol: number; + current: boolean; +} + export interface TuiAltScreenOptions { /** Number of logical lines moved for each mouse-wheel event. */ wheelScrollLines?: number; /** Capture mouse events for viewport scrolling and application-owned text selection. */ mouse?: boolean; + /** Style a non-current transcript search match. */ + searchMatchStyle?: (text: string) => string; + /** Style the current transcript search match. */ + searchCurrentMatchStyle?: (text: string) => string; /** Open an OSC 8 hyperlink activated with a primary-button click. */ openUrl?: (url: string) => void; /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ @@ -153,10 +183,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private selectionPressActive = false; private scrollbarDrag?: ScrollbarDrag; private scrollbarHover?: ScrollView; + private activeSearch?: ActiveSearch; private pressedUrl?: string; private selectionDragged = false; private readonly wheelScrollLines: number; private readonly mouseEnabled: boolean; + private readonly searchMatchStyle: (text: string) => string; + private readonly searchCurrentMatchStyle: (text: string) => string; private readonly openUrl?: (url: string) => void; private readonly onRightClickPaste?: () => void; @@ -177,6 +210,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flashes = new AltScreenFlashContainer(() => this.requestRender()); this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1)); this.mouseEnabled = options.mouse ?? true; + this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`); + this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); this.openUrl = options.openUrl; this.onRightClickPaste = options.onRightClickPaste; this.addInputListener((data) => this.handleViewportInput(data)); @@ -250,6 +285,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } protected override beforeTerminalStop(_options: TuiStopOptions): void { + this.closeSearch(); this.stopSelectionAutoScroll(); this.selectionPressActive = false; this.stopScrollbarHover(); @@ -377,6 +413,113 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } } + private openSearch(): void { + if (this.activeSearch) { + this.activeSearch.overlay?.focus(); + return; + } + const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query)); + const search: ActiveSearch = { + component, + query: "", + matches: [], + selectedIndex: -1, + anchorRow: this.getPrimaryScrollView().scrollTop, + selectionMode: "query", + }; + this.activeSearch = search; + search.overlay = this.showOverlay(component, { + anchor: "top-right", + width: "40%", + minWidth: 24, + margin: 1, + }); + } + + private closeSearch(): void { + const search = this.activeSearch; + if (!search) return; + this.activeSearch = undefined; + search.overlay?.hide(); + this.requestRender(); + } + + private updateSearchQuery(query: string): void { + const search = this.activeSearch; + if (!search || query === search.query) return; + const selected = search.matches[search.selectedIndex]; + search.anchorRow = selected?.segments[0]?.row ?? this.getPrimaryScrollView().scrollTop; + search.query = query; + search.selectionMode = "query"; + search.component.setResult(-1, 0); + this.requestRender(); + } + + private navigateSearch(direction: -1 | 1): void { + const search = this.activeSearch; + if (!search?.query) return; + search.selectionMode = direction < 0 ? "previous" : "next"; + this.requestRender(); + } + + private refreshSearch(layout: LayoutFrame): boolean { + const search = this.activeSearch; + if (!search) return false; + const scrollView = layout.primaryScrollView ?? this.implicitScrollView; + const box = getScrollViewBox(layout, scrollView); + const lines = box?.scrollContentLines; + if (!lines || !search.query.trim()) { + search.matches = []; + search.selectedIndex = -1; + search.selectedKey = undefined; + search.selectionMode = "retain"; + search.component.setResult(-1, 0); + return false; + } + + const shouldRevealSelection = search.selectionMode !== "retain"; + const matches = findAltScreenSearchMatches(lines, search.query); + const exactIndex = search.selectedKey + ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey) + : -1; + let selectedIndex = -1; + if (matches.length > 0) { + if (search.selectionMode === "query") { + selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow); + if (selectedIndex < 0) selectedIndex = 0; + } else if (search.selectionMode === "next") { + const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1); + selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length; + } else if (search.selectionMode === "previous") { + const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1); + selectedIndex = baseIndex < 0 ? matches.length - 1 : (baseIndex - 1 + matches.length) % matches.length; + } else { + selectedIndex = + exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1); + } + } + + search.matches = matches; + search.selectedIndex = selectedIndex; + search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]!) : undefined; + search.selectionMode = "retain"; + search.component.setResult(selectedIndex, matches.length); + if (!shouldRevealSelection) return false; + + const selected = matches[selectedIndex]; + const firstSegment = selected?.segments[0]; + const lastSegment = selected?.segments[selected.segments.length - 1]; + if (!box || !firstSegment || !lastSegment || scrollView.viewportHeight <= 0) return false; + const before = scrollView.scrollTop; + const visibleBottom = before + scrollView.viewportHeight - 1; + let target = before; + if (firstSegment.row < before || lastSegment.row > visibleBottom) { + target = firstSegment.row - Math.floor(scrollView.viewportHeight / 3); + } + scrollView.scrollTo(target, { disableFollow: true }); + return scrollView.scrollTop !== before; + } + /** Show a transient message in the alternate-screen flash stack. */ flash(message: string, durationMs?: number): void { this.flashes.flash(message, durationMs); @@ -420,6 +563,24 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const keybindings = getKeybindings(); const isRelease = isKeyRelease(data); + if (keybindings.matches(data, "tui.altScreen.search")) { + if (!isRelease) this.openSearch(); + return { consume: true }; + } + if (this.activeSearch?.overlay?.isFocused()) { + if (keybindings.matches(data, "tui.altScreen.searchNext")) { + if (!isRelease) this.navigateSearch(1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.searchPrevious")) { + if (!isRelease) this.navigateSearch(-1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.searchClose")) { + if (!isRelease) this.closeSearch(); + return { consume: true }; + } + } if (keybindings.matches(data, "tui.altScreen.pageUp")) { if (!isRelease) { this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); @@ -896,6 +1057,76 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flash("Copied!"); } + private applySearchTextHighlight(text: string, current: boolean): string { + const style = current ? this.searchCurrentMatchStyle : this.searchMatchStyle; + let result = ""; + let plainStart = 0; + let index = 0; + while (index < text.length) { + const ansi = extractAnsiCode(text, index); + if (!ansi) { + index += 1; + continue; + } + if (index > plainStart) result += style(text.slice(plainStart, index)); + result += ansi.code; + index += ansi.length; + plainStart = index; + } + if (plainStart < text.length) result += style(text.slice(plainStart)); + return result; + } + + private applySearchHighlights(screen: string[], layout: LayoutFrame): string[] { + const search = this.activeSearch; + if (!search || search.selectedIndex < 0 || search.matches.length === 0) return screen; + const scrollView = layout.primaryScrollView ?? this.implicitScrollView; + const box = getScrollViewBox(layout, scrollView); + if (!box) return screen; + + const rangesByRow = new Map(); + const scrollbarColumn = getScrollbarGeometry(box)?.column; + const minRow = Math.max(0, box.rect.y, box.clip.y); + const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height); + const minColumn = Math.max(0, box.rect.x, box.clip.x); + const maxColumn = Math.min( + this.terminal.columns, + box.rect.x + box.rect.width, + box.clip.x + box.clip.width, + scrollbarColumn ?? Number.POSITIVE_INFINITY, + ); + for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) { + for (const segment of search.matches[matchIndex]!.segments) { + const row = box.rect.y + segment.row - scrollView.scrollTop; + if (row < minRow || row >= maxRow) continue; + const startCol = Math.max(minColumn, box.rect.x + segment.startCol); + const endCol = Math.min(maxColumn, box.rect.x + segment.endCol); + if (endCol <= startCol) continue; + const ranges = rangesByRow.get(row) ?? []; + ranges.push({ startCol, endCol, current: matchIndex === search.selectedIndex }); + rangesByRow.set(row, ranges); + } + } + + const result = [...screen]; + for (const [row, ranges] of rangesByRow) { + let line = result[row] ?? ""; + if (isImageLine(line)) continue; + const lineWidth = visibleWidth(line); + for (const range of ranges.sort((a, b) => b.startCol - a.startCol)) { + const startCol = Math.min(range.startCol, lineWidth); + const endCol = Math.min(range.endCol, lineWidth); + if (endCol <= startCol) continue; + const before = sliceByColumn(line, 0, startCol, true); + const highlighted = sliceByColumn(line, startCol, endCol - startCol, true); + const after = sliceByColumn(line, endCol, Math.max(0, lineWidth - endCol), true); + line = `${before}${this.applySearchTextHighlight(highlighted, range.current)}${after}`; + } + result[row] = line; + } + return result; + } + private applySelectionHighlight(text: string): string { let result = "\x1b[7m"; let index = 0; @@ -985,8 +1216,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const width = Math.max(1, this.terminal.columns); const height = Math.max(1, this.terminal.rows); const root = this.layoutRoot ?? this.implicitScrollView; - const nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + if (this.refreshSearch(nextLayout)) { + nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + } let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + screen = this.applySearchHighlights(screen, nextLayout); screen = this.compositeOverlays(screen, width, height); if (screen.length > height) screen = screen.slice(screen.length - height); screen = this.applySelection(screen, nextLayout); diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index c79a024aa0d..29e4bf6a1cc 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -36,6 +36,10 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.search"), ["ctrl+shift+f"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchNext"), ["enter", "ctrl+g"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]); }); diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index e72c149665a..7c4997f0064 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -133,6 +133,19 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); + + it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { + const delayedBuffer = new StdinBuffer(); + const delayedSequences: string[] = []; + delayedBuffer.on("data", (sequence) => delayedSequences.push(sequence)); + + delayedBuffer.process("\x1b["); + await wait(20); + assert.deepStrictEqual(delayedSequences, []); + delayedBuffer.process("<65;48;39M"); + assert.deepStrictEqual(delayedSequences, ["\x1b[<65;48;39M"]); + delayedBuffer.destroy(); + }); }); describe("Mixed Content", () => { @@ -314,6 +327,17 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b"]); }); + it("flushes a lone escape promptly with the longer default sequence timeout", async () => { + const defaultBuffer = new StdinBuffer(); + const defaultSequences: string[] = []; + defaultBuffer.on("data", (sequence) => defaultSequences.push(sequence)); + + defaultBuffer.process("\x1b"); + await wait(20); + assert.deepStrictEqual(defaultSequences, ["\x1b"]); + defaultBuffer.destroy(); + }); + it("should handle lone escape character with explicit flush", () => { processInput("\x1b"); assert.deepStrictEqual(emittedSequences, []); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index d3d9df32f83..6612cd00621 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import { findAltScreenSearchMatches } from "../src/alt-screen-search.ts"; import { HStack } from "../src/components/h-stack.ts"; import { Image } from "../src/components/image.ts"; import { ScrollView } from "../src/components/scroll-view.ts"; @@ -379,6 +380,105 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("searches normalized rendered transcript text across rows", () => { + assert.deepStrictEqual(findAltScreenSearchMatches(["alpha QUICK", "brown fox"], "quick brown"), [ + { + segments: [ + { row: 0, startCol: 6, endCol: 11 }, + { row: 1, startCol: 0, endCol: 5 }, + ], + }, + ]); + }); + + it("uses configured styles for current and non-current search matches", async () => { + const terminal = new RecordingTerminal(60, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + searchMatchStyle: (text) => `\x1b[41m${text}\x1b[49m`, + searchCurrentMatchStyle: (text) => `\x1b[42m${text}\x1b[49m`, + }); + tui.addChild(new Text("needle first\nmiddle\nneedle second\nend", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[42mneedle\x1b[49m")), + ); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[41mneedle\x1b[49m")), + ); + tui.stop(); + }); + + it("searches the transcript with Ctrl+Shift+F and restores editor focus on close", async () => { + const terminal = new RecordingTerminal(60, 8); + const tui = new TuiAltScreen(terminal); + const transcriptText = new Text( + Array.from({ length: 12 }, (_, index) => { + if (index === 4) return "line 5 needle one"; + if (index === 9) return "line 10 needle two"; + return `line ${index + 1}`; + }).join("\n"), + 0, + 0, + ); + const transcript = new ScrollView(transcriptText, { follow: "end", primary: true }); + const editorInputs: string[] = []; + const editor = { + focused: false, + render: () => ["editor"], + invalidate: () => {}, + handleInput: (data: string) => editorInputs.push(data), + }; + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: editor, basis: 1, shrink: 0 }, + ]), + ); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isFollowingEnd, false); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); + assert.deepStrictEqual(editorInputs, []); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[1;7mneedle\x1b[22;27m")), + ); + + for (let index = 0; index < 6; index++) terminal.sendInput("\x1b[<64;1;4M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 0); + assert.ok(terminal.getViewport().some((line) => line.includes("> needle"))); + + terminal.sendInput("\x07"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("1/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 5 needle one"))); + + terminal.sendInput("\x1b[103;6u"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); + + terminal.sendInput("\x1b"); + terminal.sendInput("x"); + await terminal.waitForRender(); + assert.ok(!terminal.getViewport().some((line) => line.includes("Find transcript"))); + assert.deepStrictEqual(editorInputs, ["x"]); + + tui.stop(); + }); + it("scrolls the transcript by half a page with custom bindings", async () => { const originalKeybindings = getKeybindings(); const terminal = new VirtualTerminal(20, 10); From 24047f5dfb222ef7d26b554a0e576e5efa844024 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 11:01:05 +0200 Subject: [PATCH 099/284] docs(agent): strip partitioning to informative section, finish audit fixes --- .../agent/docs/harness-v3-audit-findings.md | 30 -- packages/agent/docs/harness-v3.md | 442 ++++++------------ 2 files changed, 139 insertions(+), 333 deletions(-) delete mode 100644 packages/agent/docs/harness-v3-audit-findings.md diff --git a/packages/agent/docs/harness-v3-audit-findings.md b/packages/agent/docs/harness-v3-audit-findings.md deleted file mode 100644 index 7c5efcb8c64..00000000000 --- a/packages/agent/docs/harness-v3-audit-findings.md +++ /dev/null @@ -1,30 +0,0 @@ -# harness-v3.md — open audit findings (second cold read, gpt-5.6-sol) - -Working file: fix each in `harness-v3.md`, check it off, delete this file when empty. -First cold-read round (10 findings) is already fixed (`8431bfbea`). - -## P0 - -1. **Retention contradicts the core storage model.** §0.5 "there is no third place" / inventory "carries no authority"; §1.1 entries/usage "never deleted" — vs §6.2 ledger rows disappearing with inventory standing in, §6.6 authoritative header aggregate. Fix: make retention an explicit scoped exception; define authoritative aggregate storage and the exact `getStats()` formula (live sum + retired aggregates). -2. **SettlementKernel cannot perform its claimed settlement.** §7.4 stores only untyped id arrays; synthetic settlement needs entry kind, parent, tool call id/name, entry↔usage association, and operation-owned vs lane-owned pending separation. Fix: discriminated pending-effect records + separated pending id lists, sufficient to construct valid entries and `LaneLastResult`. -3. **Retention preflight can drop entries open-state validation requires.** §6.3 pins only reserved ids; §3.3/§4.4 need prompt, trigger, latest-assistant, completed-result, source/target ids to resolve. Fix: preflight pins every materialized and reserved id directly referenced by each open operation (op.meta included). - -## P1 - -4. **JSONL snapshot compaction vs sequence continuity.** §1.7 requires persisted seq continuity; compaction leaves gaps. Fix: snapshot header carries a seq high-water mark; validate monotonic snapshot state + consecutive post-snapshot commits only. -5. **Retention-marker APIs contradict each other.** §2.5 finders return `truncatedAt`; §5.3 types return plain entries; §6.4 says `Entry[]` stays; `getRetentionBoundary` missing from the §5.3 interface; paged results can't derive the marker. Fix: expose `getRetentionBoundary` in `SessionTree`, forbid marker inference from paged results, align §2.5 wording. -6. **LaneExpired has no usable public contract.** Absent from §5.1 result unions; getters have no expiry shape; "always admitted" navigation undefined for `summarize:true` (needs the expired source). Fix: add to affected unions; restrict rebase to unsummarized navigation. -7. **Terminal prefix cleanup unsupported.** `Write` deletes exact keys only; `listRegisters` lists whole namespaces. Fix: add prefix listing/deletion to Storage, or retain owned keys durably for exact deletion. -8. **Deferred settlement/recovery lacks a complete transition table.** Old R/U abandonment vs synthetic settlement, poll numbering on replacement, ready-with-tools, pending/error transactions unstated. Fix: full deferred transition table (base spec §3.7 deferred rows + jot part 4 are the sources). -9. **Precise rewrite not implementable.** Redaction/legacy-id migration defined without old→new id remapping for parents, leaves, labels, fromId, ledger entryId, registers, tail writes. Fix: specify the id map and atomic reference transformation, including writes admitted during the copy. -10. **Restore pseudocode can't hydrate what validation requires.** `directEntryIds` lacks `op.meta` (prompt ids), `sourceLeafId`, navigation source/target. Fix: pass `meta.value`; enumerate those ids in hydration + validation. -11. **Register write typing loses namespace/value relationship.** §1.4 `set` takes `value: JsonValue`. Fix: mapped discriminated union keyed by namespace; serialization validation at admission. -12. **Close vs non-Result signatures.** §4.8 `Err(Closed)` for unaccepted calls, but setters return `Promise`, appends `Promise`. Fix: state these reject with `HarnessClosed`. -13. **Invariant 19 impossible as persistent invariant.** aborted-implies-cancelled unverifiable after terminal state deletion / forks. Fix: scope to the committing transaction. -14. **`BranchSummaryEntry.fromId` undefined.** Fix: define as the pre-navigation source leaf; name it in §3.10's publication TX. -15. **Empty prompts have no valid acceptance state.** `[]` prompt + no injections + no captured nextRun → no newest entry. Fix: reject with `InvalidMessage` when acceptance would append nothing. - -## P2 - -16. **"No query may be a table scan" overstated** vs `scanEntries`. Fix: restrict to execution/recovery and branch hot paths. -17. **Provenance: `AgentEventSink`** is in `src/agent-loop.ts`, not `src/types.ts`. Fix list in §0.7. diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 8bf9d8df9e9..290d839e9c6 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -23,40 +23,34 @@ StoredValue / valueId → gone (no values table) ## 0.1 What this is -A durable runtime for agent conversations. You hand it a prompt; it talks to a language model, runs tools, and produces a response. The difference from an ordinary agent loop is that **the process can die at any instant** — mid-stream, between a tool call and its result, halfway through a summary — and a new process picks up exactly where the old one stopped, without repeating durable work and without losing anything that had committed. +A durable runtime for agent conversations. It persists conversation and operation state so interrupted work can resume without repeating settled effects. -It is a library, not a server. One process owns one session at a time. +## 0.2 System model -## 0.2 Three concepts +### Session -### Session — the conversation +A session groups related work and has four parts: -A session is one conversation, stored as a **tree** rather than a list. +- **Entry tree.** An entry is a message, compaction, branch summary, or application-defined custom entry. Entries are immutable. Each branch is a conversational thread; the shared tree enables branching, compaction, forking, and parallel work while preserving history. -``` -a ── b ── c ── d - └── e ── f -``` - -A tree, because three features need history that does not move: branching (explore an approach, back out, keep the record), compaction (replace a long prefix with a summary while the original stays queryable), and forking (copy a prefix into a new session). Entries are appended and never modified or deleted. - -A session also holds **facts** (session name, entry labels, application key-value state — latest write wins, not part of the tree) and a **usage ledger** (every token and cost event, append-only). + ```text + a ── b ── c ── d + └── e ── f + ``` -### Lane — a cursor into the conversation +- **Facts.** Mutable, namespaced key-value state. Built-ins include the session name and entry labels; applications may store custom facts. +- **Lanes.** Named cursors into the tree. Every session has `main`. A lane owns its leaf, model configuration, queues, and at most one operation. Additional lanes support Slack threads, subagents, and other parallel work over shared history. +- **Usage ledger.** Append-only token and cost events for the session. -A lane is a **name plus a leaf**: the entry that new work extends. Every session has `main`. Applications create more. +### Harness and operations -A lane owns its leaf, its configuration (model, thinking level, active tools), its queues, and at most one operation in flight. Lanes run in parallel and share nothing except the tree beneath them. +The session layer manages durable data and exposes typed tree views. The harness drives lanes: it accepts prompts, runs model and tool steps, manages queues, compacts or navigates the tree, and resumes interrupted work. It also owns harness-wide registries of available tools and prompt resources, hooks that intercept and transform execution, passive events that report activity and durable changes, and runtime configuration. -Why lanes exist: a Slack channel is one session, and each thread is a lane. Threads share the channel's history but take turns independently. Two lanes can sit on the same entry and diverge on their next append — the tree handles that, and no coordination is needed. +An **operation** is one accepted unit of lane work: a run, compaction, or navigation. Its immutable metadata records its identity, intent, and starting point; its total current state records its phase, control, queues, and recovery data. Each durable transition replaces the current state. Completion removes the operation state and records the lane's result. -**Lane vs fork:** a lane *shares* history; a fork *copies* it for isolation. Use a lane for a thread in a shared conversation, a fork for a subagent, an export, or a what-if. +### Storage -### Harness — what runs a lane - -The harness is the API surface. Per lane: `prompt`, `steer`, `followUp`, `nextRun`, `abort`, `resume`, `compact`, `navigateTree`, plus configuration getters and setters and a tree view. Harness-wide: lane management, tool and resource registries, hooks, events. - -An **operation** is one accepted unit of work on a lane — a `run` (prompt to final answer, including all tool calls), a `compaction`, or a `navigation`. One per lane at a time. +Below the session and harness, `Storage` exposes atomic transactions and queries over three durable forms: immutable entries, mutable registers, and append-only usage rows. Registers form a mutable, namespaced key-value store. Facts live there; internal harness namespaces durably store pending content and lane and operation state needed for crash recovery. In particular, `op.meta` is written once with an operation's metadata, while `op.state` is replaced after each transition with its complete current state. The terminal transaction deletes both and writes `lane.lastResult`. No partial transaction is visible. ## 0.3 Worked example — a Slack thread @@ -104,9 +98,9 @@ registers current mutable state — namespaced typed cells, overwrite or de usage ledger cost history — append-only rows ``` -*Every payload is in an entry, a register, or the ledger; there is no third place.* An entry is the complete conversation record — placement and payload in one row. A register holds its current typed value directly; overwriting discards the old value, and deletion removes the key. Content that durably exists before it has a place in the tree (queued input, deferred writes) waits in a `pending.entry` register and becomes an entry in the transaction that places it. Per-backend projections — branch index, full-text search, stats, partition inventory — are rebuildable from the three stores and carry no authority. +*Every payload is in an entry, a register, or the ledger; there is no third place.* An entry is the complete conversation record — placement and payload in one row. A register holds its current typed value directly; overwriting discards the old value, and deletion removes the key. Content that durably exists before it has a place in the tree (queued input, deferred writes) waits in a `pending.entry` register and becomes an entry in the transaction that places it. Per-backend projections — branch index, full-text search, stats — are rebuildable from the three stores and carry no authority. -**2. Atomic transactions.** A transaction is a set of entry inserts, usage inserts, and register writes (set or delete), committed all-or-none with consecutive sequence numbers. There is no crash state inside a transaction. This is the only write primitive. +**2. Atomic transactions.** A transaction is a set of entry inserts, usage inserts, and register writes (set or delete), committed all-or-none with strictly increasing sequence numbers. There is no crash state inside a transaction. This is the only write primitive. **3. The durable program counter.** After every step, the harness overwrites one register — `op.state/{operationId}` — with the *complete* current state of the operation. Recovery does not replay a journal or infer position from what is missing; it reads that register and switches on it. The state is *total* — it never depends on a previous state. Small captured values (configuration, stream options, retry policy) are inline; large stable payloads live in sibling `op.*` registers or are named by id. When the operation ends, the terminal transaction deletes its registers: a finished session holds exactly the conversation, the ledger, and a handful of lane and fact registers. There is no dead state to collect. @@ -127,7 +121,7 @@ Hooks follow their replay contract instead: a result becomes durable in the tran - **Multiple writers.** One process per session. The serving layer routes accordingly, and the SQLite backend enforces it with a fenced lease (§1.7). Lanes cover the workload that looks like multi-writer. - **Replication.** A session lives in one place. - **Durable write history.** Registers hold only current values: an overwritten register is gone, and there is no `getLog` or history table. Order-of-write assertions in tests use an instrumented storage decorator around `commit()` (Part 9); production auditing belongs to the telemetry layer (§5.8). -- **Compliance deletion through retention expiry.** Partition expiry is TTL and cost control, not erasure: `retainedTail` copies old messages forward into newer compaction entries, and summaries derive from old content. Compliance-grade "erase this" uses the precise-rewrite path (Part 6). +- **Deletion as a runtime feature.** Entries and usage rows are never deleted: compaction changes provider context, not storage, and terminal cleanup deletes only registers. Note that `retainedTail` copies old messages forward into newer compaction entries and summaries derive from old content, so compaction is not erasure either. Compliance-grade "erase this" is the administrative precise rewrite (§2.9), the sole sanctioned exception. ## 0.7 Notation and source types @@ -138,7 +132,8 @@ Hooks follow their replay contract instead: a result becomes durable in the tran Source type provenance: -- `AgentMessage`, `AgentTool`, `AgentToolResult`, `AgentEventSink`, `QueueMode`, and `ThinkingLevel`: `packages/agent/src/types.ts`. +- `AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, and `ThinkingLevel`: `packages/agent/src/types.ts`. +- `AgentEventSink`: `packages/agent/src/agent-loop.ts`. - `Skill`, `PromptTemplate`, `AgentHarnessResources` (`Resources` below), `AgentHarnessTool`, `AgentHarnessStreamOptions`, and `AgentHarnessStreamOptionsPatch`: `packages/agent/src/harness/types.ts`. - `Model`, `Models`, `Usage`, `RetryPolicy`, `StopReason`, `AssistantMessage`, `ImageContent`, provider messages, stream options, and deferred handles: `packages/ai`. - `CompactionSettings`, `CompactionPreparation`, `CompactResult`, `BranchPreparation`, and `BranchSummaryResult`: `packages/agent/src/harness/compaction/`. Existing preparation and split-turn algorithms remain the implementation starting point unless this document explicitly changes them. @@ -220,32 +215,19 @@ interface UsageRow { **Registers hold values, not pointers.** A register's value is the current typed state itself, never an id pointing at an immutable state value. Overwriting a register discards the previous value; nothing accumulates and there is no history to fold (§1.8). Deleting a register removes the key entirely and is a first-class write, distinct from storing JSON `null`, which remains a legal value where a namespace's type permits it (`lane.leaf` at the root, `fact.custom`). -## 1.2 Identity and partitions - -Every id storage stores — entry ids, usage ids, and every reserved id that will become one — is a **UUIDv7**, minted through the session's id generator (§2.8); the sole exception is imported legacy-format ids, preserved verbatim (Appendix C). A UUIDv7 begins with 48 bits of Unix milliseconds: the first 12 hex characters of the id *are* a timestamp, and that timestamp, truncated to the partition period, *is* the id's partition assignment. There are no partition columns anywhere — not on entries, not on ledger rows, not in any register value. The period length (monthly in every example) is a deployment property of the partitioned backend; Memory, JSONL, and SQLite never partition. +## 1.2 Identity -What the embedded prefix buys: - -- **Every reference is self-describing.** A `parentId`, a `lane.leaf` value, a `fact.label` key, an id inside `op.state` JSON — any of them can be classified against the partition retirement inventory by reading its prefix, with no lookup. -- **Native partition pruning.** Postgres compares `uuid` bytewise and UUIDv7 sorts in time order, so `PARTITION BY RANGE (id)` works directly, with period-boundary UUIDs (zeroed tails) as bounds. The primary key stays `(session_id, id)`, and a point lookup prunes to one partition from the id itself (§1.7). -- **The cost.** Ids leak their creation period to applications. Accepted: the alternative is a denormalized partition column on every row, plus no answer at all for references held inside register values. +Every id storage stores — entry ids, usage ids, and every reserved id that will become one — is a **UUIDv7**, minted through the session's id generator (§2.8); the sole exception is imported legacy-format ids, preserved verbatim (Appendix C). A UUIDv7 begins with 48 bits of Unix milliseconds: the first 12 hex characters of the id *are* its mint time. Every reference is therefore self-describing and time-sortable — a `parentId`, a `lane.leaf` value, a `fact.label` key, an id inside `op.state` JSON all carry their creation time with no lookup. The one cost: ids leak their creation time to applications. Accepted. (A future partitioned Postgres backend would build its retention design on this prefix; that sketch is the informative Part 6. Memory, JSONL, and SQLite never partition.) Minting rules: 1. An id is minted with `now()` **at reservation**. For born-placed entries — the hot path — reservation and placement are the same transaction, so the prefix equals the placement date. -2. **Followers inherit the leader's timestamp.** Tool-result ids are minted with their assistant entry id's 48-bit timestamp (fresh random bits keep them unique), so an assistant and its tool results share a partition by construction, even across a midnight or month boundary. This is a deliberate, documented deviation from "UUID timestamp = wall clock". It exists because dropping a partition must never orphan half of a call/result exchange: a retained tool result whose assistant call is gone heads a context every provider rejects. -3. **Synthetic settlement needs no special case.** Crash recovery and force-expiry write under already-reserved ids (§4.5), so synthetic responses and results land in the partition their intent promised. -4. **Late placement pins.** A `nextRun` message minted in January and consumed in April is placed as a January-partition entry — exact, but it means unplaced reservations pin their partitions. All such reservations are enumerable from hot registers (`pending.entry` keys and the reserved ids inside open `op.state` values are UUIDv7s: decode, take the minimum), so drop preflight is a bounded register scan. Retention policy for abandoned reservations is Part 6. - -Traversal discrimination is exact by construction: +2. **Group cohesion: followers inherit the leader's timestamp.** Tool-result ids are minted with their assistant entry id's 48-bit timestamp — `idGenerator.next(timestampMs?)` (§2.8); fresh random bits keep them unique — so an assistant and its tool results form one time-cohesive group under id order, even across a midnight boundary. This is a deliberate, documented deviation from "UUID timestamp = wall clock": a call/result exchange is one unit — a tool result whose assistant call is missing heads a context every provider rejects — and its ids say so. +3. **Synthetic settlement needs no special case.** Crash recovery writes under already-reserved ids (§4.5), so synthetic responses and results carry exactly the ids their intents promised. -```text -parent entry exists → continue -parent missing, id prefix in a retired period → retention boundary — clean stop -parent missing, id prefix in a live period → corruption — loud -``` +**The opaque-payload contract.** Application- and model-visible content — custom entry `data`, `details` fields, `fact.custom` values, message text, hook `resumeData` — may embed entry ids. The harness never tracks such references: they are opaque payload, invisible to validation and recovery, and a retention mechanism may leave them dangling. Content that must remain resolvable is copied into the payload rather than referenced by id. -Memory, JSONL, and SQLite never retire periods themselves, so with an empty retired-range set — the default — the middle case is unreachable there and a missing parent is always corruption. The rules are still core — branch scans and forks must implement the boundary stop (§2.5, §2.7) — but the middle case is exercised only where the retired-range inventory (§6.4) is non-empty: the future Postgres backend (§1.7), sessions truncated by retention compaction or fork import, and the conformance suite's abstract retired-range set (Part 9). +**Absolutes.** Entries and usage rows, once committed, are never deleted; the administrative precise rewrite (§2.9) is the sole sanctioned exception. A missing parent is always corruption — loud, never a clean stop. ## 1.3 Register namespaces @@ -319,11 +301,16 @@ pending.entry lives until its content is placed or cancelled ## 1.4 Transactions ```ts +/** Mapped discriminated union: the namespace forces the value type. */ +type RegisterSetWrite = { + [N in RegisterNamespace]: { kind: "register"; op: "set"; namespace: N; + key: string; value: RegisterValues[N] } +}[RegisterNamespace]; + type Write = | { kind: "entry"; entry: Omit } | { kind: "usage"; row: Omit } - | { kind: "register"; op: "set"; namespace: RegisterNamespace; key: string; - value: JsonValue } + | RegisterSetWrite | { kind: "register"; op: "delete"; namespace: RegisterNamespace; key: string }; interface Transaction { writes: Write[] } @@ -334,7 +321,7 @@ interface CommitResult { firstSeq: number; seqs: number[]; timestamp: number } Rules: 1. A transaction commits **all-or-none**. There is no observable state in which some of its writes exist and others do not. -2. Writes receive **consecutive** `seq` values in the order given. `seq` is monotonic session-wide across all lanes and all write kinds. A register `set` stamps the register with its assigned `seq`. +2. Writes receive **strictly increasing** `seq` values in the order given; gaps are legal, within and between transactions. `seq` is monotonic session-wide across all lanes and all write kinds. A register `set` stamps the register with its assigned `seq`. 3. Within a transaction, writes apply in order: an entry may name a parent created earlier in the same transaction; a register value may reference entry or usage ids created earlier in the same transaction. A placement transaction inserts the complete entry and deletes its `pending.entry` register together (§2.2) — there is never a moment where both exist. 4. Entry and usage ids share one session-wide id namespace. Writing either kind under any existing id is **corruption**, not an update. 5. A register `set` with the same `(namespace, key)` replaces the current value; `delete` removes the key; a later `set` recreates it. No history is retained. A `delete` naming an absent key is a no-op, so public deletions such as clearing an unset label stay legal. @@ -354,7 +341,10 @@ interface Storage { getRegister(namespace: N, key: string): Promise | undefined>; - listRegisters(namespace: N): Promise[]>; + /** keyPrefix is an indexed prefix listing over (namespace, key); terminal + cleanup's op.* prefix scans use it (§3.13). */ + listRegisters(namespace: N, keyPrefix?: string): + Promise[]>; scanBranch(q: BranchScan): Promise; // §2.5 scanBranchStructure(q: BranchScan): Promise; @@ -391,12 +381,12 @@ Every settled provider attempt writes one `UsageRow` — successful, failed, ret - `entryId` names the entry the cost belongs to, when there is one. Structural (summary) attempts that fail before producing an entry, and standalone adjustments, have none. - `adjustment: true` marks a caller-supplied reconciliation (`recordUsage`, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix C). -- Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, hook-supplied compaction/navigation usage rows (§3.9, §3.10), and import aggregates mint their ids at commit; nothing reserves them. A wall-clock-minted usage row may therefore land in a later partition than its follower-minted entry; per-period accounting tolerates that skew (Appendix D). +- Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, hook-supplied compaction/navigation usage rows (§3.9, §3.10), and import aggregates mint their ids at commit; nothing reserves them. - `getStats()` is a maintained projection over the ledger and the message-entry count — `messageCount` counts `message` entries only, not compactions, summaries, or custom entries. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). There is no ledger scan: totals come from the projection, and individual rows reach the application through the `usage` event at commit time (§5.5). ## 1.7 Backends -Three encodings of one model ship now — Memory, JSONL, SQLite — and all three pass the same conformance suite (Part 9). Postgres is a planned fourth; it appears here because its native partitioning shapes the retention design (Part 6). Each backend records the session's `storageVersion` (Part 7): a JSONL header field, a SQLite/Postgres catalog column. Memory sessions are always current. +Three encodings of one model ship now — Memory, JSONL, SQLite — and all three pass the same conformance suite (Part 9). Each backend records the session's `storageVersion` (Part 7): a JSONL header field, a SQLite catalog column. Memory sessions are always current. A possible fourth backend — partitioned Postgres — is sketched informatively in Part 6; nothing here depends on it. ### Memory @@ -425,13 +415,13 @@ The file is not the state; it is the **replay recipe** for the Memory maps above ``` - This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix C). -- Open replays lines in order into the Memory maps: entries and usage rows accumulate; a later register `set` overwrites the key, `delete` removes it. That is *decoding*, not recovery logic. Open verifies persisted sequence continuity and timestamps and never regenerates committed timestamps. All queries then run in RAM. +- Open replays lines in order into the Memory maps: entries and usage rows accumulate; a later register `set` overwrites the key, `delete` removes it. That is *decoding*, not recovery logic. Open verifies persisted sequence monotonicity — strictly increasing, gaps legal (§1.4) — and timestamps, and never regenerates committed timestamps. All queries then run in RAM. - **A torn final line is discarded whole**, including every element of an array, and is truncated before new writes are admitted. This is what makes "no crash prefix inside a transaction" true here. - A malformed *interior* line, or a complete-but-invalid transaction, is corruption. The one exception: superseded old-shape register lines from before a schema migration decode leniently as keyed raw JSON during replay (Part 7); compaction retires them. - Durability is process-crash level: a resolved `commit()` survives process death. No fsync promise. - Optional: retain `(offset, length)` per entry and load payloads lazily, keeping only structure and registers resident. Do this only if profiling demands it. -**Snapshot compaction.** In SQLite a register `set` is an in-place upsert — a 30-turn run leaves one `op.state` row and then zero. In JSONL every `set` appends, so the same run appends ~10 full `op.state` lines, all dead the moment the terminal `delete` line lands: the file grows with *write history* even though the logical state does not. The fix is rewriting the file as `header + current entries + current registers + usage rows`, via temp file + atomic rename. For a four-entry run: +**Snapshot compaction.** In SQLite a register `set` is an in-place upsert — a 30-turn run leaves one `op.state` row and then zero. In JSONL every `set` appends, so the same run appends ~10 full `op.state` lines, all dead the moment the terminal `delete` line lands: the file grows with *write history* even though the logical state does not. The fix is rewriting the file as `header + current entries + current registers + usage rows`, via temp file + atomic rename; surviving lines keep their original `seq` values, and the gaps the dropped lines leave are legal (§1.4), so compaction needs no renumbering machinery. For a four-entry run: ```text before compaction: ~10 transaction lines, ~27 writes — op.state revisions, @@ -539,23 +529,6 @@ regression. The repository's existing `SessionSearch` surface remains. SQLite replaces its rowid-dependent index with an FTS projection keyed by stored `session_id` and `entry_id`; searchable text is the JSON serialization of the entry, matching the scanning fallback. The transaction that places an entry also inserts its projection after validation. Pending content is not searchable before placement. Fork import populates the same projection, and session deletion removes its rows. Search never depends on `entries.rowid`. -### Postgres — future fourth backend - -Planned, not normative; named now because its native partitioning is what the identity design (§1.2) and the retention design (Part 6) are shaped for. The logical model is identical. Two temperature zones in one database: - -```text -hot, unpartitioned catalog: partitioned by entry-id range (period bounds): - registers entries - branch_meta usage_ledger - partition inventory branch index rows - session_stats FTS projection - writer leases, sessions -``` - -- `PARTITION BY RANGE (id)` on the uuid primary-key column, with period-boundary UUIDs (zeroed tails) as bounds. The primary key stays `(session_id, id)`; point lookups prune to one partition from the id's own time prefix, and no partition-key column exists. -- One database means **one transaction spans hot registers and partitioned entries**: an acceptance transaction — entry inserts plus several register writes — is a single Postgres transaction, exactly as on SQLite. -- Expiry is `seal period → write per-session aggregates into the inventory → DETACH CONCURRENTLY → DROP`. `DETACH CONCURRENTLY` is not transactional, so expiry is a small recoverable protocol driven by inventory state, not one atomic step; a crash between steps redoes the step the inventory names. Retention semantics — pins, preflight, boundaries — are Part 6. - ## 1.8 Why write-once plus registers - **Recovery is a read.** Five register point-lookups per lane, then exact-id dereference (§4.4). No reducer exists to have a bug. @@ -579,6 +552,8 @@ interface MessageEntry extends EntryBase { type: "message"; message: Agent interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; retainedTail: AgentMessage[]; tokensBefore: number; details?: JsonValue; usage?: Usage; fromHook: boolean } +/** fromId is the summarized branch's pre-navigation leaf: the producing + operation's sourceLeafId (§3.10). */ interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; summary: string; details?: JsonValue; usage?: Usage; fromHook: boolean } @@ -624,7 +599,7 @@ t1 TX[ insert e_q1 = { parent: e_a3, type: "message", message: cursor.seq`. A `stopAt` entry is returned only if it also passes the filter. -**Retention boundaries.** On a backend with retired partitions, a scan that reaches an entry whose `parentId` decodes to a retired period stops there cleanly, as if at a root (§1.2). The stop must be explicit at public surfaces: branch finders report a truncation marker — `truncatedAt: { parentId }`, the partition being the id's own time prefix — never a silently short result, because extension-state lookups walk past compactions by design (§5.3) and must distinguish "never set" from "expired". Storage itself needs no extra channel: the marker derives from the last returned entry's `parentId`. The three shipping backends never truncate while their per-session retired-range set is empty, which is the default (§6.4). - **Context projection** — how a provider request is built: 1. `scanBranch({ start: leaf, order: "newestFirst", stopAtType: "compaction" })`. @@ -715,7 +688,7 @@ There is no rule for omitting an overflow response, and no link anywhere pointin ## 2.6 The branch index -Memory and JSONL walk parent pointers in RAM. SQLite — and the future Postgres backend — maintain a private segmented branch cache so a diverging append does not copy an unbounded root prefix. +Memory and JSONL walk parent pointers in RAM. SQLite maintains a private segmented branch cache so a diverging append does not copy an unbounded root prefix. `branch_entries` stores the entries physically present in one segment. `branch_meta` stores its tip and optional `{ baseBranchId, baseSeq }`. A segment logically contains its own rows above `baseSeq` plus the referenced base prefix through `baseSeq`. @@ -732,21 +705,9 @@ Two correctness rules are mandatory: - The base branch must itself cover the leaf within its logical range; merely containing the leaf in an ancestor is insufficient. - The newest compaction search must traverse the base chain; checking only the newest physical segment can miss it. -**Partition purity** — two additional rules on a partitioned backend, vacuous on SQLite: - -- **Append rule.** Appending an entry whose partition differs from the current segment's closes the segment: the old segment becomes the base of a fresh one. Segments are single-partition by construction, so index rows live in the same partition as the entries they index and die with it (§1.7). -- **Diverge rule.** Copy-on-diverge caps at partition boundaries. Never copy older-partition index rows forward into a newer segment; chain a base reference into the older partition's own segments instead — otherwise new partitions accumulate rows referencing droppable ones, and a drop silently gaps retained scans. - -Traversal stepping into a base whose partition is retired is a retention boundary (§2.5): terminate the scan and report it. Truncate the chain lazily on first access; no eager `branch_meta` rebuild happens at drop time. `branch_meta` — tips and base pointers, hot, mutable, globally unique — always stays in the unpartitioned catalog. - -```text -S1 (2027-01): e1…e19 ←base─ S2 (2027-02): e20…e29 ←base─ S3 (2027-03): e30…e42 -drop 2027-01 → a scan via S3→S2 stops after e20 and reports the boundary; S2/S3 untouched -``` - The cache must preserve: -- following a segment chain yields the exact root path with no gaps or duplicates — up to a retention boundary, where it stops cleanly; +- following a segment chain yields the exact root path with no gaps or duplicates; - all chains containing an entry agree below it; - runtime reads never fall back to a table scan or parent walk; - stale branches remain valid cache history; @@ -769,7 +730,7 @@ type ForkOptions = - The destination is idle and its token/cost ledger starts at zero. Entry-local display usage remains on copied entries. - Facts follow the selected scope: name/custom facts always copy; labels copy only when their target copies unless tree scope copies all targets. - Any message may be the fork point. Request construction heals orphaned tool calls. -- Copied entries keep their ids, so they keep their partitions. Where the source path crosses a retention boundary, the copy stops there exactly as a scan does (§2.5): the boundary entry becomes a retained root in the destination, keeping its original `parentId`. How a fork destination classifies the dangling references it inherits — including on backends that never retire periods themselves — is defined with the rest of the retired-boundary semantics in Part 6. +- Copied entries keep their ids. - The destination metadata records `parentSessionId`. A source with only fresh/unconfigured `main`—new format 4 or read-only normalized v3—may have no configuration. Either fork scope then creates one unconfigured destination `main`, which first harness attachment seeds normally. Every configured format-4 lane copied by a fork keeps its current total configuration. @@ -824,7 +785,8 @@ interface Session extends SessionTr getEntries(ids: string[]): Promise>; getRegister(namespace: N, key: string): Promise | undefined>; - listRegisters(namespace: N): Promise[]>; + listRegisters(namespace: N, keyPrefix?: string): + Promise[]>; close(): Promise; } @@ -836,6 +798,10 @@ Repository constructors accept `SessionCodecOptions`. Every declaration-merged c Repository implementations resolve `fork(source, ...)` to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read transaction. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session `Storage` contract. +## 2.9 The precise rewrite + +Entries and usage rows are never deleted (§1.2). The sole sanctioned exception is the **precise rewrite**: an administrative repository operation that copies the retained set — entries, usage rows, facts, lane registers — into a fresh session store over a coherent snapshot, exactly as a fork does (§2.8), then atomically swaps it for the old store. Its keep-predicate can express what no runtime mechanism may: compliance-grade erasure (including content copied forward into `retainedTail`s and summaries), pruning abandoned branches, and re-minting legacy-format ids (Appendix C). It is tooling above the harness — no harness surface exposes it, and no core rule depends on it. + # Part 3 — The operation state machine ## 3.1 Operations @@ -994,6 +960,21 @@ type Deferred = One `resume()` performs at most one `fetchDeferred(handle, { wait: 0 })`. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A pending response must have a completely equal handle and becomes the next source. A mismatched pending handle is normalized to a durable `error` response explaining the mismatch; response, usage, `latestAssistantEntryId`, and response-provenance `failure_drain` commit atomically. +The complete transition table — every row is one `commit()`; classification order (§3.7) applies to every poll settlement, cancellation first: + +| From | Trigger | Transaction | To | +|---|---|---|---| +| assistant `effect_pending` | settlement classifies `deferred` with a valid handle | §3.7's deferred row | suspended, `poll: 0`, `sourceEntryId: R` | +| suspended, poll *k* | `resume()`: the poll's `before_request` settlement commits its intent, consuming the invocation's single poll permit | mint fresh R′ and U′, then `TX[ S(deferred{effect_pending, poll k+1, responseEntryId R′, usageId U′}) ]` | effect_pending, poll *k*+1 | +| effect_pending, poll *k*+1 | fetch returns **pending** with a completely equal handle | `TX[ insert response entry R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, deferred{suspended, sourceEntryId R′, poll k+1}) ]` — the pending response becomes the next source and the operation re-suspends; no second poll this invocation | suspended, poll *k*+1 | +| effect_pending | fetch returns **pending** with a mismatched handle | normalize to a durable `error` response explaining the mismatch: `TX[ insert normalized response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` | failure_drain | +| effect_pending | fetch returns **ready** with tool calls | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, tools{plan with reserved result ids}) ]` — result ids minted as followers of R′ (§1.2) | tools | +| effect_pending | fetch returns **ready** without tool calls | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | +| effect_pending | fetch settles as a provider `error` | `TX[ insert response R′, upsert lane.leaf = R′, insert usage U′, S(latestAssistantEntryId=R′, failure_drain{error, provenance:response R′}) ]` — polls have no retry path | failure_drain | +| effect_pending, restored, running control | crash left the poll's outcome unknown; the next `resume()` replaces it | mint fresh R″/U″ and commit a fresh intent at the **same** poll number — an unknown-outcome poll never completed, so `poll` does not increment; the old reserved id strings are abandoned, never materialized | effect_pending, poll *k*+1 | +| effect_pending, cancelled control | reconciliation, live or restored (§4.5, §4.6) | synthetic settlement under the **existing** reserved ids: `TX[ insert synthetic aborted response R′, upsert lane.leaf = R′, insert zero usage U′, S(latestAssistantEntryId=R′, cancelled checkpoint{may_finish}) ]` | cancelled checkpoint → aborted finish | +| suspended, cancelled control | reconciliation | no fetch starts; best-effort `cancel_deferred` targets the newest source (§4.6), and the operation finishes through the aborted terminal transaction | terminal | + ### Structural work ```ts @@ -1053,7 +1034,7 @@ interface LaneState { Restore validates only the current lane and operation registers and the entries/registers they directly name; there is no history to audit and none exists. Required checks: - `lane.state/{lane}` holds a `LaneState`; when it names operation O, `op.meta/O` holds an `Operation` for that lane, and `op.state/O` holds an `OperationState` compatible with O's intent kind; -- every entry id the current state names — trigger, latest assistant, batch assistant, deferred source, completed results, prompt entries, the lane leaf — resolves to an existing entry of the expected type; +- every entry id the current state or `op.meta` names — trigger, latest assistant, batch assistant, deferred source, completed results, prompt entries, a non-null `sourceLeafId`, a navigation intent's non-null `targetId`, the lane leaf — resolves to an existing entry of the expected type; - reserved response/result/usage ids, if materialized, contain the intended kind and identity; an unmaterialized reserved id resolves to nothing, which is the expected pre-settlement condition, never an error; - every id in `inbox.*`, `control.drained*`, and `pendingNextRun` has a `pending.entry` register with a valid payload; every effect-pending call has its `op.tool_args` register; every structural decision has its `op.preparation` register; - tool source indices are complete, ordered, unique, in range, and use unique result ids; completed result entries match their source calls; @@ -1129,11 +1110,11 @@ A declined summarized navigation moves nothing: the leaf stays at the source, an | idle lane | unsummarized `navigateTree()` after validation | `TX[ upsert op.meta/O, S(navigation{ready_to_commit}), L ]` | | reserved idle lane | summarized `navigateTree()` with preparation | `TX[ upsert op.preparation/O:{taskId} = P, upsert op.meta/O, S(navigation{summary.deciding, taskId}), L ]` | -Captured `nextRun` items already have their payloads in `pending.entry` registers; acceptance inserts their entries from those payloads, deletes the registers, and removes the ids from `pendingNextRun` — the placement half of the one deliberate double write (§1.8). A late-captured item keeps its enqueue-minted id and lands in that id's partition (§1.2). +Captured `nextRun` items already have their payloads in `pending.entry` registers; acceptance inserts their entries from those payloads, deletes the registers, and removes the ids from `pendingNextRun` — the placement half of the one deliberate double write (§1.8). A late-captured item keeps its enqueue-minted id (§1.2). Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. -Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve). Prompt allocates its operation id before `before_run` so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists. +Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, summarize from root, or a null target with summarize), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve), and `InvalidMessage` when acceptance would append zero entries — an empty normalized prompt with no hook injections and no captured `nextRun` items leaves no newest entry to anchor the checkpoint's trigger. Prompt allocates its operation id before `before_run` so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists. **Acceptance must observe `currentOperationId === null`.** Because acceptance is on the lane mutation line, this is validation, not compare-and-swap. @@ -1152,7 +1133,7 @@ Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `In | effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ insert response entry R, upsert lane.leaf = R, insert usage U, S(latestAssistantEntryId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | | retry_wait | `notBefore` elapsed | `TX[ S(assistant{ready, nextAttempt:k+1}) ]` | ready | -**There is never a durable "response without usage" or "response and usage without a decision."** All three land together or none do. `R` and `U` are minted at intent and exist only as strings in the state until settlement inserts the complete rows (§2.2). A settlement that plans tools mints each `resultEntryId` as a follower of `R`, inheriting its 48-bit timestamp (§1.2), so the assistant and its results share a partition by construction. +**There is never a durable "response without usage" or "response and usage without a decision."** All three land together or none do. `R` and `U` are minted at intent and exist only as strings in the state until settlement inserts the complete rows (§2.2). A settlement that plans tools mints each `resultEntryId` as a follower of `R`, inheriting its 48-bit timestamp (§1.2), so the assistant and its results form one id-cohesive group by construction. ### Classification order @@ -1289,7 +1270,8 @@ Unsummarized and summarized both finish in **one** transaction — navigation's TX[ insert hook-reported usage row (only for a hook-supplied summary), upsert lane.leaf = target, insert summary entry with its display usage snapshot (when summarize; - parent is the target), + parent is the target; fromId = the operation's sourceLeafId — the + pre-navigation source leaf), upsert lane.leaf = summary entry (when summarize), upsert fact.label (when a label is present), delete the operation's op.* registers, @@ -1371,8 +1353,9 @@ TX[ , delete op.meta/{O}, delete op.state/{O}, - delete op.tool_args/{O}:* defensive prefix scan; batch completion - already deletes these atomically (§3.8), + delete op.tool_args/{O}:* defensive prefix scan — listRegisters with + keyPrefix (§1.5); batch completion already + deletes these atomically (§3.8), delete op.preparation/{O}:* prefix scan; in-run compactions leave their preparation after resume, delete pending.entry/{id} for every operation-owned pending id, @@ -1601,7 +1584,7 @@ async function drive(current: CurrentOperation, live: DriveState): Promise`, `SessionTree` appends returning an id string — reject with `HarnessClosed` on and after close. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from a trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or an `entryProjector`) is an application defect and faults the harness; it never escapes as an undeclared operation error. `AgentTool.prepareArguments` is the deliberate exception handled by the tool pipeline as a synthetic tool error. + +## 4.9 External finalization + +An operation can end from outside its own drive: administrative force-kill tooling — or any future repairer (Part 6) — may commit the terminal transaction (§3.13), with or without synthetic settlements under the reserved ids, while a live drive still holds the operation in memory. The drive discovers this in exactly one way: a conditional commit or `reloadCurrent` finds the operation is no longer the lane's current operation — its registers are absent. + +The rule: **the drive stops.** It pulls the operation signal so in-flight effects cancel, discards every in-memory result without writing — no register remains to own a settlement — emits the operation's end events, and resolves the live caller's promise from `lane.lastResult`, which the finalizing transaction wrote. It never re-creates registers, never commits a competing terminal transaction, and never treats the absence as corruption: absent `op.*` registers with a cleared `currentOperationId` is the ordinary post-terminal shape (§3.13). + +A suspended operation needs no drive to stop. The finalizer's terminal transaction leaves the lane idle; a later `resume()` finds `currentOperationId: null` and returns `NothingToResume`, and the application reads the outcome from `getLastResult()` (§5.1) — the same reconciliation path as any post-crash outcome. --- @@ -1892,7 +1883,7 @@ interface WatchHandle { snapshot: T; start(listener: EventListener): void; un Skill/template expansion precedes storage. Prompt intent names only normalized caller messages, excluding captured `nextRun` and hook injections. -`getLastResult()` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads the `lane.lastResult` register for the outcome its promise never delivered (§3.13). On a partitioned backend, a dormant lane whose leaf id decodes to a retired period enters an explicit **expired-lane** condition on next access rather than failing obscurely; its semantics — surfacing, rebase-to-boundary policy — are defined in Part 6. The three shipping backends never produce it. +`getLastResult()` is the post-crash reconciliation path: an application that accepted an operation, lost its process, and reopened reads the `lane.lastResult` register for the outcome its promise never delivered (§3.13). It is also how a caller learns the outcome of an operation finalized externally (§4.9). `waitForIdle()` registers on the lane mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local operation/admission reservation is held. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. @@ -1954,7 +1945,6 @@ Expected errors use the existing `TaggedError` implementation in `harness/result | `UnknownSkill`, `UnknownTemplate` | `name` | | `UnknownTarget` | `targetId` | | `LaneExists`, `InvalidLane` | `lane` (`InvalidLane` also has `reason`) | -| `LaneExpired` | `lane`, `leafId` — partitioned-backend expired-lane condition (§6.4) | | `Closed` | none | ```ts @@ -2135,7 +2125,7 @@ interface SessionStats { messageCount: number; usage: Usage } Global queries filter first, then apply the exclusive cursor, then `limit`; default order is `"desc"`. A descending cursor retains `seq < cursor.seq`, and an ascending cursor retains `seq > cursor.seq`. -Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. On a partitioned backend, such walks can reach a retention boundary; branch finders then surface the §2.5 truncation marker instead of returning a silently short path, so "never set" stays distinguishable from "expired". The marker's exact API shape is defined with the rest of the retired-boundary semantics in Part 6; the three shipping backends never truncate while their retired-range set is empty (§6.4). +Useful patterns: effective extension state is `findEntryOnBranch({ type: "custom", customType })`; a collection is `findEntriesOnBranch(...)`; a global inventory is `findEntries(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. `SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getEntry` return only committed entries: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. @@ -2568,146 +2558,23 @@ Every storage transaction uses one `pi.session.write`. Its start attributes incl Telemetry attributes may contain declared ids, names, counts, durations, statuses, and usage. They must never contain prompts, completions, tool arguments/results, file contents, provider payloads, headers, handles, or credentials. Events and hooks may contain such content. The existing generated schema document and adapter/runtime conformance tests remain authoritative; implementation slices extend instrumentation only through those schemas. -# Part 6 — Retention and partitioning - -This part exists for one backend — the planned Postgres deployment (§1.7) — but its rules are core: identity (§1.2), branch segments (§2.6), scans (§2.5), and forks (§2.7) all carry obligations that only make sense against the retention design stated here. Memory, JSONL, and SQLite never retire periods; they meet this part through the retired-range inventory (§6.4), which for them is normally empty. - -## 6.1 Three lifecycles, three mechanisms - -```text -operation cleanup register deletion at the terminal TX continuous, invisible (§3.13) -context compaction provider-context only, never deletion an ordinary entry (§2.5) -conversation retention - ├─ partition expiry drop whole retired periods fast, routine TTL (§6.2) - └─ precise rewrite copy-retained-and-swap surgical, administrative (§6.6) -``` - -They never couple. Operation cleanup is orchestration hygiene: it deletes registers, never entries or ledger rows, and finishes inside the terminal transaction. Compaction changes what a provider sees, never what storage holds: a compaction entry is one more append, and everything before it stays queryable. Retention alone removes rows — and it consults orchestration state only through the bounded pin scan in §6.3, never through any per-entry lifecycle marker. There are no such markers to maintain, which is why the first two mechanisms can run forever without creating retention work. - -## 6.2 Physical layout and the expiry protocol - -The §1.7 sketch splits the Postgres database into a hot unpartitioned catalog — registers, `branch_meta`, the partition inventory, stats, leases, sessions — and period-partitioned bulk tables: entries, the usage ledger, branch index rows, the FTS projection. The bulk DDL follows directly from §1.2, because the id *is* the partition key: - -```sql -CREATE TABLE entries ( - session_id text, - id uuid, -- UUIDv7; the time prefix is the partition assignment - parent_id uuid, - seq bigint, - type text, - custom_type text, - timestamp bigint, - payload jsonb, - PRIMARY KEY (session_id, id) -) PARTITION BY RANGE (id); - --- Bounds are period-boundary UUIDv7s: the boundary timestamp, zeroed tail. -CREATE TABLE entries_2027_01 PARTITION OF entries - FOR VALUES FROM ('') - TO (''); -``` - -Two classic partitioning taxes disappear because the key lives inside the id. First, no partition column invades the schema or the primary key: `PRIMARY KEY (session_id, id)` covers the partition key, and because an id determines its partition, per-partition uniqueness is global uniqueness. Second, no global-index fan-out: `getEntries` prunes to one partition from each id's own prefix instead of probing every partition's index after years of monthly partitions — the hottest read path stays one index visit per id. The ledger, branch index rows, and FTS projection partition the same way and die with their entries; §2.6's partition-purity rules keep segment rows in the partitions of the entries they index, so this holds for the branch index by construction. - -One database also means one transaction spans hot registers and partitioned entries (§1.7): acceptance, settlement, and terminal transactions keep exactly the shapes Part 3 specifies. +# Part 6 — Future: partitioned retention (Postgres) -**The expiry protocol.** Dropping period P is not one atomic step, because `DETACH CONCURRENTLY` is not transactional. It is a small recoverable protocol driven by P's inventory row: +**This part is informative.** Nothing in it binds the shipping backends: Memory, JSONL, and SQLite never partition and never delete entries or usage rows (§1.2), and no core rule references this part for its correctness. It exists to show that the identity choices in §1.2 are sufficient for the one backend that would eventually retire old data — a possible Postgres deployment with TTL retention. It is a bridge we cross when we get there; this sketch is the current best guess, not a contract. -```text -1. preflight §6.3: refuse while any pin or compaction horizon covers P -2. seal mark P frozen in the inventory -3. aggregate fold P's per-session usage totals into the inventory -4. detach DETACH PARTITION CONCURRENTLY -5. drop DROP TABLE; record P's range as retired in the inventory -``` - -A crash between steps redoes the step the inventory names; every step is idempotent. Sealing is safe because a passed preflight implies nothing can write into P again: new ids mint with `now()`, and follower ids mint only inside open operations, which preflight already enumerated. The aggregates exist because ledger rows are about to disappear: `session_stats` stays valid (it is already an aggregate), but rebuildability — the rule that projections can always be recomputed from the three stores — now needs the inventory to stand in for the dropped rows, and per-period accounting survives only there. - -## 6.3 Pins, preflight, and the compaction horizon - -What must block a drop is exactly what retained state still needs. All of it is enumerable from hot registers — nothing requires scanning partitioned data: - -- **Unplaced reservations.** Every `pending.entry` key is a UUIDv7; a queued January message not yet consumed pins January (§1.2). `listRegisters("pending.entry")`, decode the keys, take the minimum. -- **Open-operation reservations.** Reserved response/result/usage ids inside open `op.state` values pin their partitions. Open operations are reachable through `lane.state` registers, so this scan is bounded by lane count. -- **The compaction horizon.** An open operation must be able to rebuild its provider context, and every lane must stay projectable: context reads the newest compaction at or below the leaf plus everything after it (§2.5). So the hard rule: **a drop may never remove a lane's newest compaction.** Partition P is droppable only if every lane's branch has a compaction — or its root — in a retained partition newer than P. The leaf needs its own check: a late-placed entry (§1.2 rule 4) can put a leaf's prefix *behind* the newest compaction's partition, so preflight also scans every `lane.leaf` register — one hot register per lane — and refuses to drop a partition any leaf decodes into. - -Preflight is those three checks. A deployment where they pass drops P with no per-entry bookkeeping, no reference counting, and no scan of P itself. - -**Abandoned pins.** A crashed operation nobody resumes, or a queued item nobody consumes or cancels, pins its partitions forever. Policy must therefore include an administrative **force-expiry** for over-age pins, built from machinery that already exists: force-settling an open operation is §4.5's synthetic settlement — interrupted/aborted results under exactly the reserved ids, inbox drained, terminal cleanup, `lane.lastResult` recording the outcome (Part 7 reuses the same mechanism for upgrades). Stale-queue expiry deletes an abandoned `pending.entry` register through the cancellation path (§3.11); a later `cancelQueued` answers `not_found`, which retrying clients already treat as success. Dormant never-compacted lanes either pin storage or fall under an explicit expired-lane policy (§6.4) — a product decision, not a storage decision (Appendix D). - -### Worked example — the yes/no dialog - -An assistant turn settles in January with one ask-the-user tool call. The result id is minted at settlement as a follower (§1.2), so it carries January's timestamp. The user answers in April; the result entry inserts into the January partition — which the open operation pinned the whole time. - -- While the operation is open: January is undroppable, so nothing is ever lost. The cost is retention lag on one partition. -- If policy force-expires the abandoned operation instead: a synthetic interrupted result lands under the already-reserved January id, beside its assistant. January's pins clear, the partition becomes droppable, and the exchange later disappears **as a unit** — never half of it. That is the follower rule doing its job: at every moment, the assistant and its results are either both retained or both gone. - -## 6.4 Retired-boundary semantics - -**The retired-range inventory.** Classification needs one datum: which id-prefix ranges are retired. It is the union of two sources — the deployment's partition inventory (partitioned backends only, hot catalog, shared because partitions are shared across sessions) and a per-session retired-range set in the catalog or header (all backends, normally empty). Memory, JSONL, and SQLite never run expiry, so their per-session set becomes non-empty in exactly three ways: a JSONL retention compaction records the ranges it pruned (§6.6), a fork import inherits ranges from a truncated source (below), and the conformance suite populates it directly to make boundary states testable on every backend (Part 9). Classification is then uniform everywhere, exactly as §1.2 states: parent present → continue; parent missing with a retired prefix → boundary; parent missing with a live prefix → corruption. +- **The id is the partition key.** UUIDv7 sorts bytewise in time order, so the bulk tables — entries, usage ledger, FTS projection — use `PARTITION BY RANGE (id)` on the uuid id column, with period-boundary UUIDs (zeroed tails) as bounds. No partition column exists anywhere; §1.2's time prefix is the whole mechanism. Registers, `branch_meta`, stats, leases, and sessions stay in a hot unpartitioned catalog. `branch_entries` partitions by `entry_id` with the same bounds, so dropping a period cleans the branch index for free; `branch_meta` stays hot, and base pointers dangling into a dropped period are trimmed lazily on first access. +- **Pre-pass repair.** Before a period P is dropped, an online repairer makes live state stop referencing it: reparent edges crossing into P onto the nearest retained ancestor, found by an indexed uuid-range query; null any dormant `lane.leaf` decoding into P via a register-seq CAS; force-expire open operations still referencing P register-only — the terminal transaction of §3.13 writing `lane.lastResult`, no synthetic entries, with any live drive stopping through external finalization (§4.9); delete `fact.label` registers whose keys decode into P with one uuid-range delete. +- **The commit barrier.** Repair races ordinary commits, so the final step is atomic against all of them: `BEGIN; LOCK entries, registers IN ACCESS EXCLUSIVE MODE; ; ALTER TABLE … DETACH PARTITION p; COMMIT;` — plain `DETACH`, not `CONCURRENTLY`, precisely because it is transactional under the lock; the `DROP TABLE` happens later, unhurried. The barrier makes repair-plus-detach one linearization point: every commit sees either the fully attached period or a fully repaired store without it. +- **The default partition.** A `DEFAULT` partition absorbs stray inserts whose ids predate every attached partition — an ancient `pendingNextRun` item consumed years after its mint still places under its reserved id and simply lands there. Nothing errors and nothing is lost; the default partition stays small and is never dropped. +- **Register access under an external repairer.** A backend that admits an external repairer must perform register reads and CAS checks inside the commit transaction itself, so a repairer holding the barrier cannot interleave between a harness's read and its dependent write. The shipping backends need no such rule: single-writer sessions have no external repairer. -**Traversal.** Scans stop cleanly at a boundary (§2.5); segment chains truncate lazily on first access, with no eager `branch_meta` rebuild at drop time (§2.6). The public marker whose shape §5.3 defers here is one `SessionTree` method, present on all backends and trivially null wherever the inventory is empty: - -```ts -/** Non-null when the path from start (default: the view's lane leaf) toward - the root ends at a retention boundary rather than a true root (§2.5). The - partition is the parentId's own time prefix. */ -getRetentionBoundary(start?: string): Promise<{ parentId: string } | null>; -``` - -Bulk finders stay `Entry[]`-shaped; a serving layer that pages branch scans attaches `truncatedAt: { parentId }` by reading the last returned entry's `parentId` against the inventory, or by calling this method — the two always agree. What makes the marker sufficient: after `findEntryOnBranch({ customType })` returns `undefined`, one `getRetentionBoundary()` call distinguishes "never set" from "possibly expired". - -**Expired lanes.** Under the default preflight an expired lane cannot exist — the compaction horizon and the leaf scan keep every lane's leaf retained (§6.3). The condition arises only when a deployment adopts a policy that expires dormant never-compacted lanes rather than letting them pin storage. When adopted: a lane whose leaf id decodes to a retired period enters an explicit **expired** condition on first access — detected lazily by the owning harness, never marked by the daemon (§6.7). Reads report the condition; state-mutating calls fail with the expected error `LaneExpired` — with one exemption: `navigateTree` to a retained entry is the rebase operation and is always admitted — after which the lane is ordinary again. Whether a deployment also auto-rebases to the boundary is the open product question (Appendix D). - -**Labels.** A `fact.label` register whose key decodes to a retired period reads as absent. The owning harness may delete it lazily on that access; an eager pre-drop sweep by each owning harness — never the daemon (§6.7) — is a legal optimization (the keys are ids and classify with no lookup) but never required, because a stale label register is harmless. - -**Forks — resolving §2.7.** A fork whose source path crosses a boundary copies exactly what a scan returns: the boundary entry becomes a retained root in the destination, keeping its original `parentId`. The same import copies the source's relevant retired ranges into the destination's per-session set. That single rule closes the question §2.7 deferred: the destination classifies its inherited dangling references by the ordinary §1.2 rules, on every backend — a Memory, JSONL, or SQLite destination never *retires* anything itself, but it can *hold* a session whose inventory says some ranges are gone, and that is all classification needs. Without the inventory copy, the dangling parent would carry a live-looking prefix and the destination would be indistinguishable from corruption. - -## 6.5 What expiry does not do - -`retainedTail` copies old messages verbatim into newer compaction entries; branch summaries derive from old content; the FTS projection still indexes retained compactions. **Partition expiry is cost and TTL retention, not erasure** (§0.6). Content originating in a dropped period can survive indefinitely in derived form. A compliance-grade "erase this" must use the precise rewrite, which can apply a content predicate to everything — including copied-forward tails and summaries. This is a contract statement for the serving layer, not an implementation detail. - -## 6.6 The precise rewrite - -The second mechanism, for everything expiry cannot express: per-branch policies, redacting copied-forward content, pruning abandoned branches, compliance erasure, migrating never-partitioned legacy sessions. - -```text -snapshot → copy the retained set into a fresh store O(retained), online - → keep recording live writes against the old -freeze → seal commit admission briefly -swap → apply the small tail, swap, unlink the old -``` - -Never `DELETE … NOT IN (keep_set)` over years of rows while holding a write freeze — that stop-the-world is what this design exists to avoid. The copy runs against a coherent snapshot exactly as forks do (§2.8), the freeze covers only the tail replay, and the swap is atomic per backend: a rename, a catalog switch. - -On JSONL the operation already exists: snapshot compaction (§1.7) is the same rewrite with a different keep-predicate: - -```text -GC compaction: keep = live state drop dead lines -retention compaction: keep = live state ∩ policy also drop pruned entries and - usage rows; fold pruned usage - into a header aggregate so - getStats() totals survive - (§1.6); record the pruned - ranges in the retired-range - inventory (§6.4) -``` - -One rewrite path, two filters. Partition expiry remains the partitioned-backend fast path; a JSONL session that wants TTL retention pays O(retained) at compaction time, which is fine at JSONL's scale — coding-agent sessions, not seven-year Slack channels. - -## 6.7 Who runs retention - -Sessions are owned by one fenced writer (§1.7); date partitions are shared by hundreds of sessions; the retention daemon owns none of them. So the daemon performs **only lease-free global actions** — preflight reads, inventory updates, and DDL. It never takes a session's writer lease and never writes a session's registers. Every per-session consequence is executed lazily by the owning harness on next access: expired-lane detection, label cleanup, branch-chain truncation, usage-aggregate visibility. That single constraint decides the lazy-versus-eager questions in favor of lazy — an eager design would require the daemon to acquire every affected session's lease, turning routine TTL into a coordination problem with every live harness. - -Force-expiry (§6.3) is the one retention action that must write session state, so it is not the daemon's: it runs through an owning harness — opened administratively if need be — under the ordinary lease, using the ordinary synthetic-settlement machinery. - -What stays open — per-session retention length versus shared date partitions, expired-lane product semantics, the partition of entry-less usage rows, Postgres partition-count operational limits, and measuring the pending-payload double write — is collected in Appendix D. +Everything else a real deployment would need — retention policy, per-session versus per-deployment periods, operational partition-count limits — is deliberately unspecified until the backend is real. # Part 7 — Schema evolution ## 7.1 The problem -Full durability means snapshotting in-flight state, and in-flight state has the shape of *today's* state machine. Ship a new version with a different machine and the durable state written by the old one still exists — mid-run, mid-batch, mid-drain. Most durable-execution systems answer this badly or not at all. This design cannot: sessions are long-lived by intent, and Part 6 plans for years of them. +Full durability means snapshotting in-flight state, and in-flight state has the shape of *today's* state machine. Ship a new version with a different machine and the durable state written by the old one still exists — mid-run, mid-batch, mid-drain. Most durable-execution systems answer this badly or not at all. This design cannot: sessions are long-lived by intent. ## 7.2 Why this design shrinks the problem @@ -2745,55 +2612,30 @@ JSONL has one wrinkle in each direction. Replay must decode superseded old-shape Legacy coding-agent format 3 predates `storageVersion` entirely; it normalizes through Appendix C on load and receives the current version with its first format-4 write. -## 7.4 What the version cannot do — and the settlement kernel +## 7.4 Migrations are total -Register conversion is a field mapping. A state-machine shape change is not. If the next version removes `failure_drain`, or restructures the tool-batch lifecycle, an old `op.state` sitting mid-`failure_drain` has no equivalent in the new machine — "convert the record" is simply not a defined operation, and no encoding trick answers "where does this in-flight operation land?" - -The escape hatch already exists. §4.5's crash recovery can force-settle any open operation from a tiny fragment of its state: the reserved ids awaiting settlement, the pending-entry ids, and the control status — synthetic interrupted or aborted results under exactly those reserved ids, inbox drained, terminal cleanup, lane idle. Entries and the ledger are untouched. Freeze that fragment as the **settlement kernel**: a minimal, versioned-never projection of the lane's open-operation state that every future version must keep decodable: - -```ts -interface SettlementKernel { - operationId: string; - kind: "run" | "compaction" | "navigation"; - control: "running" | "cancel_requested"; - reservedEntryIds: string[]; // response/result ids awaiting settlement - reservedUsageIds: string[]; - pendingEntryIds: string[]; // inbox + drained + pendingNextRun refs -} -``` - -The kernel is drawn from `op.state` plus the lane's queue refs. `pendingEntryIds` includes `pendingNextRun` so a migration can locate every `pending.entry` register whose payload shape it may need to convert; force-settlement itself still deletes only the operation-owned subset — inbox and drained items, never `pendingNextRun` (§3.13). - -The upgrade rule then covers every case: - -```text -per open operation at migration time: - semantic migration defined for this transition? → convert op.state - otherwise → force-settle via the kernel: - synthetic "interrupted by upgrade" - under the reserved ids, - terminal cleanup; - lane.lastResult records the outcome -``` +Register conversion is a field mapping; a state-machine shape change is more. If the next version removes `failure_drain`, or restructures the tool-batch lifecycle, an old `op.state` sitting mid-`failure_drain` has no field-by-field equivalent in the new machine. The rule: **migrations are total.** A vN→vN+1 migration translates every register value — lane and fact registers, `pending.entry` payloads, and open operations' `op.meta` and `op.state` included. The author of a state-machine change writes the mapping that carries every reachable old state into a well-defined new one, in the same change, reviewed and tested with it. A state with no natural successor maps to an explicit choice — typically the nearest safe pre-intent state, from which ordinary recovery (§4.5) proceeds. There is no force-settle path and no partial escape hatch. -Worst case, an in-flight run ends "interrupted" — indistinguishable from a crash, which the application already handles through the ordinary reconciliation path (§3.13, §5.1). No session is ever bricked by a state-machine redesign, and no version ever carries old-machine semantics forward. This is the same machinery Part 6 uses for force-expiry: one synthetic-settlement kernel, two administrative callers. +This is tractable for the same reason migrate-on-open is tractable at all (§7.2): the entire mutable surface is a few dozen current registers, and migration runs at open under the writer lease, so it sees **quiescent** registers — no drive is running, no effect is in flight, and every `op.state` is exactly the total state some transaction committed. A migration is a pure function over a small, fully enumerable, fully typed set of values. ## 7.5 The three strata, restated as policy ```text entries + usage the stability budget goes HERE. Payloads are provider-shaped messages plus three simple structural types; changes must be - read-compatible forever, because partitions cannot be - rewritten at open time — the precise rewrite (§6.6) exists, - but it is administrative, not an open-time step. Custom + read-compatible forever, because years of entries cannot + be rewritten at open time — the precise rewrite (§2.9) + exists, but it is administrative, not an open-time step. Custom entry payloads are the application's contract. lane / fact migrate on open, mechanically. A few registers per lane, registers cheap forever. -op.* / pending.* ephemeral by construction. Migrate when convenient, - force-settle when not. This is where the state machine is - allowed to churn freely between versions. +op.* / pending.* ephemeral by construction and few in number. Every + state-machine change ships the total register mapping for + its own states (§7.4). This is where the machine is allowed + to churn between versions, because the mapping cost is + bounded by open operations — usually zero. ``` The design conclusion: the volatile part of the system — orchestration — was made ephemeral, and the durable part — the conversation — was made structurally boring. Schema evolution is exactly as hard as the boring part, which is the best available outcome. @@ -2808,7 +2650,7 @@ If implementation exposes a design contradiction, missing transition, or materia | # | Slice | Implement | Required focused tests | |---|---|---|---| -| 1 | **Single-session Storage** | Write-once entries/usage, registers with first-class set/delete, atomic transactions, UUIDv7 id generator with follower minting, runtime entry/register/custom-message schemas, stats projection, per-session retired-range set plumbing (empty default), Memory backend, shared conformance helpers, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, close. | +| 1 | **Single-session Storage** | Write-once entries/usage, registers with first-class set/delete, atomic transactions, UUIDv7 id generator with follower minting, runtime entry/register/custom-message schemas, stats projection, Memory backend, shared conformance helpers, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, close. | | 2 | **JSONL v4 and format 3** | Single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | | 3 | **Tree and repositories** | Entries with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle with the `storageVersion` gate at open, coherent branch/tree forks. | Placement, divergence, filters/cursors/stops, custom entries with and without data, context, fork before first attachment, configured fork snapshots/facts/zero ledger. | | 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, `Models.lease` and runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), identity leases, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | @@ -2816,20 +2658,19 @@ If implementation exposes a design contradiction, missing transition, or materia | 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | | 7 | **Tools** | Refactor existing loop into three phases, bind `AgentHarnessTool` context, durable complete plans, `op.tool_args/{opId}:{stepId}:{i}` registers with batch-completion deletion, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage. | Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, tool-args register lifecycle including crash-leak prefix cleanup, safe/unsafe replay, ordering, termination, abort-ready states. | | 8 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pending.entry` registers, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation answering `not_found`, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | -| 9 | **Abort, close, and failure drain** | Orthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers. | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, drained-register survival and terminal deletion, close races, failure revived only by projecting input. | +| 9 | **Abort, close, and failure drain** | Orthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers, and the external-finalization stop on absent operation registers (§4.9). | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, drained-register survival and terminal deletion, close races, an externally finalized operation stopping the drive without writes and resolving from `lastResult`, failure revived only by projecting input. | | 10 | **Deferred provider redemption** | One poll per resume, copied configuration/options inline, leased request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of slice 9 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | | 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, leased nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | | 12 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | | 13 | **Navigation** | Validation, summarized decision/generation, and one final transaction combining move/summary/leaf/label with the terminal writes; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup. | | 14 | **SQLite** | Rework the current unfinished schema/backend directly to entries/registers/usage-ledger tables, transactions, stats, leases, catalog `storageVersion`, repository operations, segmented branch cache, entry-id-keyed FTS search projection, and explicit repair. No values table, no `slot_history`, no `getLog`, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, placed-only search, forks/search/stats/repair. | -| 15 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, migration registry, settlement-kernel decode and the force-settle path, JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer. | Version gate (equal/older/newer), chained idempotent migrations across crash, kernel force-settle leaving a valid idle lane plus `lastResult`, lenient replay of superseded shapes, compaction retiring old bytes. | -| 16 | **Retention scaffold** | Retired-range inventory on all backends (empty default), boundary classification in scans/segments/forks, `getRetentionBoundary` and truncation markers, fork range inheritance, expired-lane condition with `LaneExpired` and the `navigateTree` rebase exemption, lazy label handling, JSONL retention compaction with the pruned-usage header aggregate, pin-enumeration preflight helpers. Design-complete; the Postgres backend, partition DDL, and the retention daemon are deferred. | Boundary vs corruption discrimination, truncation markers, boundary-crossing forks including inherited ranges on never-retiring destinations, expired-lane surfacing and rebase, retention-compaction keep-predicate and stats aggregate, preflight pin enumeration including the `lane.leaf` scan. | -| 17 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | +| 15 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, migration registry with total register mappings — open operations' `op.meta`/`op.state` included (§7.4), JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer. | Version gate (equal/older/newer), chained idempotent migrations across crash, an open-operation state mapped across a state-machine change and resuming correctly, lenient replay of superseded shapes, compaction retiring old bytes. | +| 16 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | Existing source guidance: - `packages/agent/src/harness/session/**` and the old record reducer/tests: slices 1–3. Remove incompatible reducer code as soon as slice 1 replaces its inputs; do not preserve both durable models. -- `packages/agent/src/harness/agent-harness.ts` and new small transition/effects modules: slices 4–13 and 15–17. +- `packages/agent/src/harness/agent-harness.ts` and new small transition/effects modules: slices 4–13 and 15–16. - `packages/agent/src/agent-loop.ts`: preserve behavior while slice 7 extracts phases. - `packages/agent/src/harness/compaction/**`: adapt, do not rewrite gratuitously, in slices 11–13. - `packages/session-backends/sqlite-node`: slice 14; retain working transaction and lease primitives. @@ -2842,10 +2683,10 @@ Existing source guidance: Storage: 1. Entries and usage rows are **write-once** and share one session-wide id namespace. Writing either kind under any existing id is corruption. -2. Transactions are all-or-none, with consecutive `seq`. `seq` is monotonic session-wide. +2. Transactions are all-or-none, with strictly increasing `seq` in write order; gaps are legal. `seq` is monotonic session-wide. 3. Registers are the only mutable state. A register delete removes the key; there are no tombstones, and JSON `null` is a legal value only where a namespace's type permits it. 4. **Every payload lives in exactly one place**: an entry, a register, or the ledger. There is no third place data can hide. -5. No read on a hot path may fold history or infer state from an absent value — no history exists to fold — and no query may be a table scan. +5. No read on a hot path may fold history or infer state from an absent value — no history exists to fold. Execution, recovery, and branch hot paths must be index-driven; inventory and debugging APIs page through indexes. Tree: @@ -2853,8 +2694,8 @@ Tree: 7. An entry either decodes against its type's runtime schema or is corruption. Only a custom entry may omit payload data. 8. Configuration and orchestration never enter the tree. Deleting every `op.*` and `pending.entry` register must leave a complete, valid conversation and ledger. 9. A lane's leaf moves only by append or navigation. -10. A branch segment chain, followed to its end, yields the full root path — up to a retention boundary, where it stops cleanly (§2.6). -11. A missing parent whose id prefix is in a retired range is a retention boundary; a missing parent with a live prefix is corruption (§1.2, §6.4). +10. A branch segment chain, followed to its end, yields the full root path (§2.6). +11. A missing parent is corruption — always (§1.2). Operations: @@ -2865,8 +2706,9 @@ Operations: 16. Only terminal transitions construct a `LaneLastResult`. A terminal outcome is observable once through the live promise and thereafter through `lane.lastResult` until the next terminal transaction on that lane; recovery never reads it. 17. At most one operation is open per lane. Two is corruption. 18. `overflowRecoveryUsed` is `true` only after overflow compaction. A transition that adds projecting conversational input or tool results and requires an assistant writes `false`; an unprojected custom write preserves it. -19. **A committed response with `stopReason: "aborted"` must have `control.status === "cancel_requested"` in the same operation state.** Providers must comply with the harness-owned signal contract; violation is corruption. -20. Current-state validation (§3.3) runs on every decoded latest lane/operation state before execution — idle lanes included (§4.4). `lane.lastResult` never determines an open operation's next action, and the retired-range inventory's sole recovery role is boundary classification of missing entries (invariant 11). +19. **The settlement transaction that commits a response with `stopReason: "aborted"` must, in that same transaction, write an operation state with `control.status === "cancel_requested"`.** The invariant is scoped to the committing transaction — later terminal cleanup or forks may remove the state without violating it. Providers must comply with the harness-owned signal contract; violation is corruption. +20. Current-state validation (§3.3) runs on every decoded latest lane/operation state before execution — idle lanes included (§4.4). `lane.lastResult` never determines an open operation's next action. +21. At most one terminal transaction ever commits per operation. A drive whose conditional commit or reload finds its operation's registers absent stops without writing and resolves from `lane.lastResult` (§4.9). Everything that used to require a bounded historical validity audit is now either unrepresentable in the types, deleted by the terminal transaction, or covered by one of the above. @@ -2904,13 +2746,12 @@ One corruption assertion constructs an `aborted` response with running control d **Cross-cutting:** - **Backend conformance.** One suite, three backends, identical results — identical query results, register states, and stats after every scenario, including register set/delete/recreate semantics and torn-transaction handling. Write-order assertions use the instrumented decorator, never a durable log. -- **Retention boundaries.** Exercised via the per-session retired-range set (§6.4) on all three backends: boundary-versus-corruption discrimination, clean scan stops with truncation markers, `getRetentionBoundary`, segment-chain boundary stops, boundary-crossing forks with inherited ranges on never-retiring destinations, expired-lane surfacing with the `navigateTree` rebase exemption, and labels reading as absent. - **Drive equivalence.** The same scenario in automatic and manual drive must produce byte-identical durable state. - **Signal ownership.** No public surface accepts a signal; a `before_request` patch carrying one has it stripped. Assert by type and by test. -- **Ledger completeness.** Every settled attempt commits its response and its usage. Failed structural attempts retain their cost. `getStats()` equals the ledger sum after every commit — and, after a JSONL retention compaction, the header aggregate plus surviving rows. A fork starts at zero. +- **Ledger completeness.** Every settled attempt commits its response and its usage. Failed structural attempts retain their cost. `getStats()` equals the ledger sum after every commit. A fork starts at zero. - **Query-plan guards.** `EXPLAIN QUERY PLAN` for `scanBranch` matches §1.7 exactly — no `entries` scan or temporary ordering b-tree. Segment tests assert copied rows are bounded by the newest compaction interval. - **Transaction discipline.** Assert every SQLite transaction opens with `BEGIN IMMEDIATE`. Add a regression test that reads, lets a second connection commit, then writes — it must succeed, and would fail with `database is locked` under a deferred `BEGIN`. -- **Segment chain soundness.** Build a chain by alternating branch-and-append across several compactions, then assert that a full-to-root scan through the chain returns exactly the entries a flat branch would, with no duplicates and no gaps — and, with a retired range recorded, that the scan stops cleanly at the boundary. Both §2.6 rules — resolve-through-base coverage and the chain-searched newest compaction — fail this test when violated, and fail silently without it. +- **Segment chain soundness.** Build a chain by alternating branch-and-append across several compactions, then assert that a full-to-root scan through the chain returns exactly the entries a flat branch would, with no duplicates and no gaps. Both §2.6 rules — resolve-through-base coverage and the chain-searched newest compaction — fail this test when violated, and fail silently without it. --- @@ -2929,17 +2770,15 @@ One corruption assertion constructs an `aborted` response with running control d | **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | | **Operation state** | The complete state of one operation at one moment — the `op.state` register, the program counter. | | **Reserved id** | An id minted before its content exists: a string in `op.state` (settlement family) or a `pending.entry` key (queued content). | -| **Follower id** | An id minted with its leader's 48-bit timestamp so a call/result group shares a partition. | +| **Follower id** | An id minted with its leader's 48-bit timestamp so a call/result group shares one time prefix (§1.2). | | **Lane mutation line** | Per-lane serialization point where all state-dependent mutations queue. | | **Control** | Orthogonal cancellation flag: `running` or `cancel_requested`. | | **Checkpoint** | The state between turns where queues, writes, and finishing are decided. | | **Continuation** | Durable answer to "does this run still owe an assistant turn?" | | **Terminal transaction** | The commit that deletes an operation's registers, writes `lane.lastResult`, and clears `currentOperationId`. | | **Segment** | A branch-index range that references an older branch instead of copying it. | -| **Partition** | The period a row belongs to on a partitioned backend, read from its id's time prefix. | -| **Retention boundary** | A missing parent whose id prefix is in a retired range; a clean traversal stop, not corruption. | -| **Retired-range inventory** | The deployment partition inventory united with a per-session retired-range set; the classification input for boundaries. | -| **Settlement kernel** | The versioned-never fragment of open-operation state sufficient to force-settle it in any future version. | +| **External finalization** | A terminal transaction committed from outside the live drive; the drive detects absent registers, stops without writing, and resolves from `lane.lastResult` (§4.9). | +| **Precise rewrite** | The administrative copy-retained-and-swap rebuild of a session store — the sole sanctioned path that removes entries or usage rows (§2.9). | # Appendix B — Changes from agent-harness-spec.md @@ -2950,8 +2789,8 @@ One corruption assertion constructs an `aborted` response with running control d | `FinishedState` removed; terminal transactions delete `op.*` and operation-owned pending registers; `lane.lastResult` added | A finished session holds exactly the conversation, the ledger, and lane/fact registers — nothing to collect — while outcomes stay observable after a crash | | Queue items are single entry ids; `pending.entry` registers hold unplaced payloads | `{ nodeId, valueId }` collapses to one string; cancellation deletes content outright; the one deliberate double write is paid only by queued items | | Usage is a first-class append-only store (`UsageRow`) | Billing is decoupled from orchestration and survives terminal cleanup and aborts | -| Ids are UUIDv7; the partition is the id's time prefix; follower minting; `PARTITION BY RANGE (id)` | Every reference is self-describing with no partition columns; native pruning; call/result groups stay atomic under expiry | -| Partition-pure branch segments (append and diverge rules) | Index rows live and die with their partition; drops never gap retained scans | +| Ids are UUIDv7 with the mint time as prefix; follower minting for call/result group cohesion | Every reference is self-describing and time-sortable; call/result exchanges stay id-cohesive (§1.2) | +| Transaction seqs are strictly increasing with legal gaps (was: consecutive) | JSONL snapshot compaction leaves gaps; continuity bought nothing (§1.4) | | `queue.disposition` removed; `cancelQueued` triage is `cancelled`/`already_consumed`/`not_found`; `UnknownQueueItem` and `already_cleared` dropped | One immortal register per cancelled item bought only a rarely needed distinction; `not_found` is retry-safe | | Fact deletion is real register deletion; no tombstones | Delete is a first-class write; JSON `null` stays a legal custom value | | CAS tokens are register seqs (`operationStateSeq`, `laneStateSeq`, expected `lane.config` seq) | State values no longer exist; the linearization is unchanged, only the token | @@ -2963,9 +2802,10 @@ One corruption assertion constructs an `aborted` response with running control d | `getLastResult()` and the `lane.lastResult` read path | Post-crash outcome reconciliation, including outcomes the tree cannot reconstruct | | Restore validates idle lanes too (leaf plus `pendingNextRun` registers) | Idle lane state is current state; corruption there must not wait for the next operation to surface | | `PendingEntry.payload` optional; tool-reported usage ids mint at commit | Custom entries may carry no data; nothing reserves a tool usage id | -| Retention and partitioning specified (Part 6): recoverable expiry protocol, pins/preflight including the `lane.leaf` scan, retired-range inventory, expired lanes with `LaneExpired` and the `navigateTree` rebase exemption, truncation markers, precise rewrite | Long-lived deployments need routine TTL cost control that never touches orchestration correctness, plus a surgical path for what TTL cannot express | -| Schema evolution specified (Part 7): `storageVersion`, migrate-on-open, settlement kernel | In-flight state must never brick a session across state-machine redesigns | -| JSONL snapshot compaction | Register overwrites append in a log-structured file; physical reclamation is a rewrite, shared with retention compaction | +| Entries and usage rows are never deleted; the administrative precise rewrite (§2.9) is the sole sanctioned exception; partitioned retention reduced to an informative future-Postgres sketch (Part 6) | Retention machinery bought no correctness on the shipping backends; the absolutes are simpler, and the partition bridge is crossed when that backend is real | +| External finalization (§4.9) | Admin force-kill tooling — and a future repairer — can finalize an open operation; the drive stops cleanly instead of racing it | +| Schema evolution specified (Part 7): `storageVersion`, migrate-on-open, total migrations | In-flight state must never brick a session: every state-machine change ships the mapping for its own states | +| JSONL snapshot compaction | Register overwrites append in a log-structured file; physical reclamation is a rewrite | The interpreter, effects boundary, hooks, events, classifier, abort/close semantics, context projection, race catalog, and format-3 normalization carry from the base spec with mechanical renames; they are restated in full so this specification is self-contained with the named source types. @@ -2983,7 +2823,7 @@ The interpreter, effects boundary, hooks, events, classifier, abort/close semant - v3 ISO timestamps convert to Unix milliseconds. - A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`. - On first format-4 write, append one aggregate adjustment usage row with `details: { source: "v3-import" }`, summing v3 node usage so ledger-derived totals remain unchanged. -- Legacy v3 ids are preserved verbatim and are not UUIDv7s. This is sound on the shipping backends: prefix classification is consulted only against the retired-range inventory, which is empty for imported sessions (§6.4), and §6.6 retention compaction is prohibited on sessions containing legacy ids — they must go through the precise rewrite first. Moving such a session onto a partitioned backend goes through the precise rewrite (§6.6), which is where legacy sessions acquire partitionable ids. +- Legacy v3 ids are preserved verbatim and are not UUIDv7s. This is sound on the shipping backends, which never partition and treat ids as opaque strings (§1.2). A future partitioned backend (Part 6) requires time-prefixed ids; moving such a session there goes through the precise rewrite (§2.9), which is where legacy sessions acquire UUIDv7 ids. Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current `storageVersion` (§7.3). A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. @@ -2991,8 +2831,4 @@ Read-only open leaves the file unchanged and computes stats from normalized entr 1. **Repairing a missing model captured inside an open operation.** Registering the same provider/model identity unblocks it without changing state. Replacing it with a different durable identity needs an explicit repair API and is not silently performed by `setModel`. 2. **Overflow detection remains heuristic.** The normalization specified in §3.7 is authoritative. Preserve the original reason in `errorMessage` for diagnosis. -3. **Per-session retention length versus shared date partitions.** Retention-class table families versus one policy per deployment (§6.7). -4. **Expired-lane product semantics.** The mechanism — `LaneExpired`, the `navigateTree` rebase exemption — is specified (§6.4); whether a deployment auto-rebases to the boundary, exposes an explicit expiry state, or both is a product decision. -5. **Usage rows with no entry.** Failed structural attempts and adjustments partition by mint date like everything else; whether they belong there or in the hot catalog (epoch-of-operation versus hot) is unresolved (§6.7). -6. **Postgres partition count and operational limits.** Period length, partition maintenance at scale, and inventory growth need operational validation before the fourth backend ships. -7. **Pending-payload write amplification.** The deliberate double write (§1.8) is paid only by queued items; measure it for pathological payloads before optimizing (`INSERT … SELECT` placement exists on SQL backends, eager compaction on JSONL). +3. **Pending-payload write amplification.** The deliberate double write (§1.8) is paid only by queued items; measure it for pathological payloads before optimizing (`INSERT … SELECT` placement exists on SQL backends, eager compaction on JSONL). From 1279952de4a22f021bff7ee714e3aa18010a5378 Mon Sep 17 00:00:00 2001 From: Gators King <107609123+midastruth@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:06:55 +0800 Subject: [PATCH 100/284] feat(tui): add unbound single-line transcript scrolling actions (#7903) Add tui.altScreen.lineUp and tui.altScreen.lineDown keybinding actions for one-line viewport scrolling in fullscreen TUI. Both are unbound by default and can be configured via user keybindings. closes #7830 Co-authored-by: midas Co-authored-by: Mario Zechner --- packages/coding-agent/docs/keybindings.md | 4 +++- packages/tui/CHANGELOG.md | 1 + packages/tui/src/keybindings.ts | 10 ++++++++ packages/tui/src/tui-alt-screen.ts | 8 +++++++ packages/tui/test/keybindings.test.ts | 2 ++ packages/tui/test/tui-alt-screen.test.ts | 29 +++++++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index a7f493d9ced..c693346a162 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -97,7 +97,7 @@ Fullscreen transcript bindings take precedence over editor bindings. The default | `pageUp`, `pageDown` | Editor | Transcript | | `ctrl+pageUp`, `ctrl+pageDown` | Editor | Editor | -This routing remains configurable through the ordinary action bindings. For example, `"tui.altScreen.pageUp": "ctrl+pageUp"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Bind `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown` for smaller transcript steps while keeping the full-page bindings. Setting `"tui.altScreen.pageUp": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action. +This routing remains configurable through the ordinary action bindings. For example, `"tui.altScreen.pageUp": "ctrl+pageUp"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Bind `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown` for half-page steps, or bind `tui.altScreen.lineUp` and `tui.altScreen.lineDown` for single-line steps. Setting `"tui.altScreen.pageUp": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action. | Keybinding id | Default | Description | |--------|---------|-------------| @@ -105,6 +105,8 @@ This routing remains configurable through the ordinary action bindings. For exam | `tui.altScreen.pageDown` | `pageDown` | Scroll the transcript down by one page | | `tui.altScreen.halfPageUp` | *(none)* | Scroll the transcript up by half a page | | `tui.altScreen.halfPageDown` | *(none)* | Scroll the transcript down by half a page | +| `tui.altScreen.lineUp` | *(none)* | Scroll the transcript up by one line | +| `tui.altScreen.lineDown` | *(none)* | Scroll the transcript down by one line | | `tui.altScreen.previousPrompt` | `ctrl+shift+up` | Jump to the previous marked message | | `tui.altScreen.nextPrompt` | `ctrl+shift+down` | Jump to the next marked message | | `tui.altScreen.search` | `ctrl+shift+f` | Search the rendered transcript | diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ec81ca0222e..c917406706f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added unbound single-line transcript scrolling actions, `tui.altScreen.lineUp` and `tui.altScreen.lineDown`, for fullscreen TUI keybindings ([#7830](https://github.com/earendil-works/pi/issues/7830)). - Added incremental primary-scroll-view search to the fullscreen TUI with configurable match styles, `Ctrl+Shift+F`, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. ### Changed diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 421657bfa19..c1afc5df73e 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -46,6 +46,8 @@ export interface Keybindings { "tui.altScreen.pageDown": true; "tui.altScreen.halfPageUp": true; "tui.altScreen.halfPageDown": true; + "tui.altScreen.lineUp": true; + "tui.altScreen.lineDown": true; "tui.altScreen.previousPrompt": true; "tui.altScreen.nextPrompt": true; "tui.altScreen.search": true; @@ -171,6 +173,14 @@ export const TUI_KEYBINDINGS = { defaultKeys: [], description: "Scroll viewport down half a page", }, + "tui.altScreen.lineUp": { + defaultKeys: [], + description: "Scroll viewport up one line", + }, + "tui.altScreen.lineDown": { + defaultKeys: [], + description: "Scroll viewport down one line", + }, "tui.altScreen.previousPrompt": { defaultKeys: "ctrl+shift+up", description: "Jump to previous semantic prompt", diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index c3e9d6a27bd..8b7e8b8700e 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -601,6 +601,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (!isRelease) this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2))); return { consume: true }; } + if (keybindings.matches(data, "tui.altScreen.lineUp")) { + if (!isRelease) this.scrollBy(-1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.lineDown")) { + if (!isRelease) this.scrollBy(1); + return { consume: true }; + } if (keybindings.matches(data, "tui.altScreen.previousPrompt")) { if (!isRelease) this.scrollToPrompt(-1); return { consume: true }; diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index 29e4bf6a1cc..9a4ecb8817e 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -34,6 +34,8 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageDown"), ["pageDown"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageUp"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineUp"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineDown"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.search"), ["ctrl+shift+f"]); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 6612cd00621..7df229dbff6 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -508,6 +508,35 @@ describe("TuiAltScreen", () => { } }); + it("scrolls the transcript by one line with custom bindings", async () => { + const originalKeybindings = getKeybindings(); + const terminal = new VirtualTerminal(20, 10); + const tui = new TuiAltScreen(terminal); + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.altScreen.lineUp": "ctrl+y", + "tui.altScreen.lineDown": "ctrl+e", + }), + ); + try { + tui.addChild(new Text(Array.from({ length: 30 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + + terminal.sendInput("\x19"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 19); + + terminal.sendInput("\x05"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + } finally { + tui.stop(); + setKeybindings(originalKeybindings); + } + }); + it("routes Ctrl-modified viewport navigation to the focused component", async () => { const terminal = new VirtualTerminal(20, 6); const tui = new TuiAltScreen(terminal); From 230029078d99d647ef7f9935c5ef87039d6aa0a4 Mon Sep 17 00:00:00 2001 From: Maximo Guk <62088388+Maximo-Guk@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:09:30 -0500 Subject: [PATCH 101/284] feat(ai): AI Gateway transport over the Cloudflare AI binding (#7901) Add createGatewayBindingFetch: a FetchFunction factory that intercepts requests bound for an AI Gateway HTTPS endpoint and re-issues them through the Workers AI binding's universal endpoint (env.AI.gateway(id).run()). Binding calls are pre-authenticated in-account, so Workers running inside the gateway's account need no cf-aig-authorization API token. The shim is transport-only: it composes with the existing gateway passthrough base URLs and every API implementation that honors StreamOptions.fetch, preserving each provider's native wire format (verified: workers-ai v1/chat/completions OpenAI-compat SSE streams incrementally; anthropic v1/messages and openai responses route end-to-end; cf-aig-log-id is present and readable via gateway getLog). Binding types are structural, so no @cloudflare/workers-types dependency and no impact on browser bundling. Co-authored-by: Claude Fable 5 --- .../ai/src/api/cloudflare-gateway-binding.ts | 192 ++++++++++ .../test/cloudflare-gateway-binding.test.ts | 332 ++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 packages/ai/src/api/cloudflare-gateway-binding.ts create mode 100644 packages/ai/test/cloudflare-gateway-binding.test.ts diff --git a/packages/ai/src/api/cloudflare-gateway-binding.ts b/packages/ai/src/api/cloudflare-gateway-binding.ts new file mode 100644 index 00000000000..c2eaa5d4855 --- /dev/null +++ b/packages/ai/src/api/cloudflare-gateway-binding.ts @@ -0,0 +1,192 @@ +/** + * AI Gateway transport over the Workers AI binding. + * + * pi's Cloudflare AI Gateway support speaks HTTPS + * (`gateway.ai.cloudflare.com/v1/{account}/{gateway}/{provider}/...`, see `api/cloudflare.ts`), + * which needs a Cloudflare API token even when the caller is a Worker in the gateway's own + * account. + * + * In order to solve for this problem, `createGatewayBindingFetch` returns a {@link FetchFunction} + * that translates requests under a gateway HTTPS prefix into calls to the Workers AI binding's + * universal endpoint, `env.AI.gateway(id).run({provider, endpoint, headers, query})`. + * Binding calls are pre-authenticated in-account and return the provider's native wire format as a + * regular (streaming) `Response`, so API implementations behave identically over either + * transport. + * + * The result is the transport for one gateway-bound client, not a general-purpose fetch: + * requests it cannot serve — URLs outside the prefix, or in-prefix requests the universal + * endpoint cannot express (non-POST, non-JSON body) — reject with a descriptive error. + * Transport selection is the caller's job, per client: route such traffic over HTTPS with + * real gateway auth instead of through this shim. + */ + +import type { FetchFunction } from "../types.ts"; + +/** + * Structural type for the Workers AI binding's gateway surface (`env.AI`), so this + * module does not depend on `@cloudflare/workers-types`. Any real `Ai` binding satisfies it. + */ +export interface AiGatewayBinding { + gateway(id: string): AiGatewayBindingGateway; +} + +export interface AiGatewayBindingGateway { + run(data: AiGatewayUniversalRequestLike, options?: { signal?: AbortSignal }): Promise; +} + +/** One universal-endpoint request entry, as accepted by `AiGateway.run()`. */ +export interface AiGatewayUniversalRequestLike { + provider: string; + endpoint: string; + headers: Record; + query: unknown; +} + +/** + * Placeholder value for auth headers on binding-routed requests. API implementations + * require an API key or a recognized auth header (`authorization`, `x-api-key`, + * `cf-aig-authorization`) before dispatch; binding calls are pre-authenticated, so pass + * `cf-aig-authorization: Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}` to satisfy + * the check. The shim strips `cf-aig-authorization` before calling the binding. Pair it with + * `Authorization: null` / `x-api-key: null` so the SDKs' placeholder auth headers never reach + * the gateway, which would treat a request-supplied auth header as a BYOK provider key that + * overrides its stored keys — the same as it would over HTTPS. + */ +export const CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL = "cloudflare-gateway-binding"; + +export interface GatewayBindingFetchOptions { + /** The Workers AI binding (e.g. `env.AI`). */ + binding: AiGatewayBinding; + /** + * Gateway HTTPS prefix every request must fall under, without a trailing slash: + * `https://gateway.ai.cloudflare.com/v1/{accountId}/{gatewayName}`. + */ + baseUrl: string; + /** Gateway name passed to `binding.gateway()`. Must match the `baseUrl` gateway. */ + gateway: string; +} + +// Never forwarded to the binding: hop-by-hop/derived headers, and gateway auth +// (binding calls are pre-authenticated; the sentinel must not reach the wire). +const STRIP_HEADERS = new Set(["content-length", "host", "cf-aig-authorization"]); + +type FetchInput = Parameters[0]; + +/** + * Create a `fetch` that routes AI Gateway requests through the Workers AI binding. + * See the module docs for behavior and composition notes. + */ +export function createGatewayBindingFetch(options: GatewayBindingFetchOptions): FetchFunction { + const { binding, gateway } = options; + // Prefix matching runs on URL-normalized components (origin + pathname), not raw strings: + // dot segments resolve away and fragments drop, matching what real fetch would put on the + // wire, so a lexical variant can't split provider/endpoint differently than HTTPS would. + const base = new URL(options.baseUrl); + const basePath = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`; + + return async (input: FetchInput, init?: RequestInit): Promise => { + const request = input instanceof Request ? input : undefined; + const url = request ? request.url : input.toString(); + const method = (init?.method ?? request?.method ?? "GET").toUpperCase(); + let parsed: URL | undefined; + try { + parsed = new URL(url); + } catch { + parsed = undefined; + } + // Out-of-prefix URLs are a configuration bug, not passthrough traffic: silently + // forwarding would ship the auth sentinel to whatever host the URL names. + if (parsed === undefined || parsed.origin !== base.origin || !parsed.pathname.startsWith(basePath)) { + throw new Error( + `createGatewayBindingFetch: ${method} ${url} is outside the configured gateway ` + + `prefix (${base.origin}${basePath}); this fetch only serves its gateway-bound client`, + ); + } + + // In-prefix requests the universal endpoint cannot express always reject: forwarding + // them over HTTPS would send the sentinel to the gateway and fail with a misleading + // auth error instead of naming the real problem. Callers that need such endpoints + // route them over HTTPS with real gateway auth themselves. + const unexpressible = (reason: string): never => { + throw new Error( + `createGatewayBindingFetch: cannot express ${method} ${url} as a universal ` + + `gateway request (${reason}); route it over HTTPS with gateway auth instead`, + ); + }; + if (method !== "POST") return unexpressible("only POST is supported"); + + const rest = parsed.pathname.slice(basePath.length); + const slash = rest.indexOf("/"); + if (slash <= 0) { + return unexpressible("missing provider/endpoint path"); + } + const provider = rest.slice(0, slash); + // Keep the query string on the endpoint — it's part of what HTTPS would have sent. + const endpoint = rest.slice(slash + 1) + parsed.search; + + const bodyText = await readBodyText(request, init); + let query: unknown; + try { + query = bodyText === undefined ? undefined : JSON.parse(bodyText); + } catch { + return unexpressible("non-JSON body"); + } + if (query === undefined) { + return unexpressible("missing body"); + } + + const headers = collectHeaders(request, init); + // Per the fetch spec an explicit `signal: null` in init clears a Request input's signal. + const signal = init?.signal ?? (init && "signal" in init && init.signal === null ? undefined : request?.signal); + return binding.gateway(gateway).run({ provider, endpoint, headers, query }, signal ? { signal } : {}); + }; +} + +async function readBodyText(request: Request | undefined, init?: RequestInit): Promise { + const body = init?.body; + if (body === undefined || body === null) { + // Per the fetch spec an explicit `body: null` in init clears a Request input's body. + if (init && "body" in init && body === null) return undefined; + if (request && request.body !== null) return request.clone().text(); + return undefined; + } + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return new TextDecoder().decode(body); + if (body instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(body)); + // URLSearchParams, FormData, Blob, ReadableStream in init: read via a Request wrapper. + // Consuming a one-shot stream here is fine — unexpressible requests reject rather than + // replay, so nothing downstream needs the body again. + return new Request("http://body.local", { + method: "POST", + body, + // The fetch spec requires `duplex: "half"` to construct a Request with a stream body + // (Node's undici enforces it; it is ignored for the replayable body types). TypeScript's + // RequestInit does not declare the field yet, hence the cast. + duplex: "half", + } as RequestInit).text(); +} + +// Entry header names are lowercased so case-variant duplicates collapse and stripping is +// uniform. Per the fetch spec, `init.headers` replaces a Request input's headers entirely. +function collectHeaders(request: Request | undefined, init?: RequestInit): Record { + const result: Record = {}; + const add = (key: string, value: string) => { + const name = key.toLowerCase(); + if (!STRIP_HEADERS.has(name)) result[name] = value; + }; + const headers = init?.headers; + if (headers === undefined) { + if (request) { + for (const [key, value] of request.headers) add(key, value); + } + } else if (headers instanceof Headers) { + for (const [key, value] of headers) add(key, value); + } else if (Array.isArray(headers)) { + for (const [key, value] of headers) add(key, value); + } else { + for (const [key, value] of Object.entries(headers)) { + if (value !== undefined) add(key, String(value)); + } + } + return result; +} diff --git a/packages/ai/test/cloudflare-gateway-binding.test.ts b/packages/ai/test/cloudflare-gateway-binding.test.ts new file mode 100644 index 00000000000..d59611a887c --- /dev/null +++ b/packages/ai/test/cloudflare-gateway-binding.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from "vitest"; +import { + type AiGatewayUniversalRequestLike, + CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL, + createGatewayBindingFetch, +} from "../src/api/cloudflare-gateway-binding.ts"; +import { streamSimple as streamOpenAICompletions } from "../src/api/openai-completions.ts"; +import type { Model } from "../src/types.ts"; + +const BASE_URL = "https://gateway.ai.cloudflare.com/v1/account-id/my-gateway"; + +interface CapturedRun { + gatewayId: string; + data: AiGatewayUniversalRequestLike; + options: { signal?: AbortSignal } | undefined; +} + +function fakeBinding(response?: Response) { + const runs: CapturedRun[] = []; + const binding = { + gateway: (gatewayId: string) => ({ + run: (data: AiGatewayUniversalRequestLike, options?: { signal?: AbortSignal }) => { + runs.push({ gatewayId, data, options }); + return Promise.resolve(response ?? new Response("{}")); + }, + }), + }; + return { binding, runs }; +} + +describe("createGatewayBindingFetch", () => { + it("derives provider and endpoint from gateway passthrough URLs", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: JSON.stringify({ model: "claude" }), + }); + await fetchFn(`${BASE_URL}/openai/responses`, { + method: "POST", + body: JSON.stringify({ model: "gpt" }), + }); + await fetchFn(`${BASE_URL}/workers-ai/v1/chat/completions`, { + method: "POST", + body: JSON.stringify({ model: "@cf/meta/llama" }), + }); + + expect(runs.map((run) => [run.data.provider, run.data.endpoint])).toEqual([ + ["anthropic", "v1/messages"], + ["openai", "responses"], + ["workers-ai", "v1/chat/completions"], + ]); + expect(runs.map((run) => run.gatewayId)).toEqual(["my-gateway", "my-gateway", "my-gateway"]); + expect(runs[0].data.query).toEqual({ model: "claude" }); + }); + + it("keeps the query string in the endpoint", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn(`${BASE_URL}/openai/responses?beta=true`, { + method: "POST", + body: "{}", + }); + + expect(runs[0].data.endpoint).toBe("responses?beta=true"); + }); + + it("lowercases header names so case-variant duplicates collapse", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + headers: { "Anthropic-Version": "2023-06-01" }, + body: "{}", + }); + + expect(runs[0].data.headers).toEqual({ "anthropic-version": "2023-06-01" }); + }); + + it("lets init headers replace a Request input's headers, per the fetch spec", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn( + new Request(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + headers: { "x-from-request": "yes" }, + body: "{}", + }), + { headers: { "x-from-init": "yes" } }, + ); + + expect(runs[0].data.headers["x-from-init"]).toBe("yes"); + expect(runs[0].data.headers["x-from-request"]).toBeUndefined(); + }); + + it("strips gateway auth and derived headers, forwards the rest", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": "17", + "CF-AIG-Authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`, + "cf-aig-metadata": '{"user":"42"}', + "anthropic-version": "2023-06-01", + "x-api-key": "provider-key", + }, + body: "{}", + }); + + const headers = Object.fromEntries( + Object.entries(runs[0].data.headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + expect(headers["cf-aig-authorization"]).toBeUndefined(); + expect(headers["content-length"]).toBeUndefined(); + expect(headers["cf-aig-metadata"]).toBe('{"user":"42"}'); + expect(headers["anthropic-version"]).toBe("2023-06-01"); + // Provider auth headers pass through: that is how request-supplied (BYOK) keys ride. + expect(headers["x-api-key"]).toBe("provider-key"); + }); + + it("accepts Request inputs and forwards their headers and body", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await fetchFn( + new Request(`${BASE_URL}/openai/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ stream: true }), + }), + ); + + expect(runs).toHaveLength(1); + expect(runs[0].data.provider).toBe("openai"); + expect(runs[0].data.endpoint).toBe("chat/completions"); + expect(runs[0].data.query).toEqual({ stream: true }); + expect(runs[0].data.headers["content-type"]).toBe("application/json"); + }); + + it("forwards the abort signal", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + const controller = new AbortController(); + + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: "{}", + signal: controller.signal, + }); + + expect(runs[0].options?.signal).toBe(controller.signal); + }); + + it("lets an explicit `signal: null` in init clear a Request input's signal, per the fetch spec", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + const controller = new AbortController(); + + await fetchFn( + new Request(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: "{}", + signal: controller.signal, + }), + { signal: null }, + ); + + expect(runs).toHaveLength(1); + expect(runs[0].options?.signal).toBeUndefined(); + }); + + it("returns the binding response untouched, including streaming bodies", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: {}\n\n")); + controller.close(); + }, + }); + const bindingResponse = new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream", "cf-aig-log-id": "log-1" }, + }); + const { binding } = fakeBinding(bindingResponse); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + const response = await fetchFn(`${BASE_URL}/workers-ai/v1/chat/completions`, { + method: "POST", + body: "{}", + }); + + expect(response).toBe(bindingResponse); + expect(response.headers.get("cf-aig-log-id")).toBe("log-1"); + expect(await response.text()).toBe("data: {}\n\n"); + }); + + it("rejects in-prefix requests the universal endpoint cannot express", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await expect(fetchFn(`${BASE_URL}/anthropic/v1/messages`, { method: "GET" })).rejects.toThrow( + "cannot express GET", + ); + await expect(fetchFn(`${BASE_URL}/anthropic/v1/messages`, { method: "POST", body: "not json" })).rejects.toThrow( + "non-JSON body", + ); + await expect(fetchFn(`${BASE_URL}/anthropic`, { method: "POST", body: "{}" })).rejects.toThrow( + "missing provider/endpoint path", + ); + expect(runs).toHaveLength(0); + }); + + it("rejects URLs outside the gateway prefix: transport selection is the caller's", async () => { + // Silent passthrough would ship the auth sentinel to whatever host the URL names; a + // misconfigured baseUrl must fail loudly instead. + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + await expect( + fetchFn("https://api.openai.com/v1/chat/completions", { method: "POST", body: "{}" }), + ).rejects.toThrow("outside the configured gateway prefix"); + // Same origin, different path (another account's gateway) is just as out-of-prefix. + await expect( + fetchFn("https://gateway.ai.cloudflare.com/v1/other-account/my-gateway/anthropic/v1/messages", { + method: "POST", + body: "{}", + }), + ).rejects.toThrow("outside the configured gateway prefix"); + expect(runs).toHaveLength(0); + }); + + it("matches and splits on the URL-normalized path, as real fetch would send it", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + + // Dot segments normalize away before the provider/endpoint split, so a lexical variant + // routes exactly like its normal form (raw string prefixing would split it differently). + await fetchFn(`${BASE_URL}/anthropic/../anthropic/v1/./messages`, { + method: "POST", + body: JSON.stringify({ model: "claude" }), + }); + expect(runs.map((run) => [run.data.provider, run.data.endpoint])).toEqual([["anthropic", "v1/messages"]]); + + // A dot-segment URL that resolves outside the prefix is rejected even though it starts + // with the prefix as a raw string. + await expect( + fetchFn(`${BASE_URL}/../other-gateway/anthropic/v1/messages`, { method: "POST", body: "{}" }), + ).rejects.toThrow("outside the configured gateway prefix"); + expect(runs).toHaveLength(1); + }); + + it("consumes a one-shot stream body for the JSON probe", async () => { + const { binding, runs } = fakeBinding(); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + const streamOf = (text: string) => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); + + // JSON stream body: consumed once, reaches the binding as the parsed query. + await fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: streamOf('{"model":"claude"}'), + duplex: "half", + } as RequestInit); + expect(runs).toHaveLength(1); + expect(runs[0].data.query).toEqual({ model: "claude" }); + + // Non-JSON stream body: rejects like any other non-JSON body (never replayed). + await expect( + fetchFn(`${BASE_URL}/anthropic/v1/messages`, { + method: "POST", + body: streamOf("not json"), + duplex: "half", + } as RequestInit), + ).rejects.toThrow("non-JSON body"); + expect(runs).toHaveLength(1); + }); + + it("keeps SDK placeholder auth out of entries when paired with null auth headers", async () => { + // The full header contract from the module docs: the sentinel satisfies pi's request-auth + // check, and the explicit nulls make the OpenAI SDK delete its own `Authorization: Bearer + // unused` placeholder before the request reaches the shim. + const { binding, runs } = fakeBinding( + Response.json({ error: { type: "bad_request", message: "stubbed" } }, { status: 400 }), + ); + const fetchFn = createGatewayBindingFetch({ binding, baseUrl: BASE_URL, gateway: "my-gateway" }); + const model: Model<"openai-completions"> = { + id: "test-model", + name: "Test Model", + api: "openai-completions", + provider: "openai", + baseUrl: `${BASE_URL}/openai`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 10_000, + maxTokens: 1_000, + }; + + const result = await streamOpenAICompletions( + model, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { + headers: { + "cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`, + Authorization: null, + "x-api-key": null, + }, + fetch: fetchFn, + maxRetries: 0, + }, + ).result(); + + expect(result.stopReason).toBe("error"); + expect(runs).toHaveLength(1); + expect(runs[0].data.provider).toBe("openai"); + const headerNames = Object.keys(runs[0].data.headers); + expect(headerNames).not.toContain("authorization"); + expect(headerNames).not.toContain("x-api-key"); + expect(headerNames).not.toContain("cf-aig-authorization"); + }); +}); From 06ed871679a39019d924ac8a0b80b7902a2db398 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 11 Aug 2026 17:12:01 +0800 Subject: [PATCH 102/284] fix(tui): prevent split Alt+Enter from interrupting (#7899) * fix(tui): prevent split Alt+Enter from interrupting Increase the escape-sequence timeout over SSH and allow high-latency terminals to override it with PI_TUI_ESC_TIMEOUT. Fixes #7876 * docs(coding-agent): shorten escape timeout description --------- Co-authored-by: Mario Zechner --- .../docs/environment-variables.md | 1 + packages/tui/src/terminal.ts | 21 +++++++++++- packages/tui/test/stdin-buffer.test.ts | 30 +++++++++++++++++ packages/tui/test/terminal.test.ts | 32 +++++++++++++++++-- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 413bd259b6e..1bebe10f33d 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -87,6 +87,7 @@ These variables are read by Pi itself: | `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported | | `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` | | `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) | +| `PI_TUI_ESC_TIMEOUT` | Escape-sequence timeout in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape | | `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset | | `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests | diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 68f8b1a355c..ade941a3aec 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -101,6 +101,25 @@ export interface Terminal { setProgress(active: boolean): void; } +const DEFAULT_ESCAPE_TIMEOUT_MS = 10; +const DEFAULT_SSH_ESCAPE_TIMEOUT_MS = 100; + +/** + * Resolve how long to wait for the rest of an escape sequence before + * dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus + * another byte, so high-latency transports need a longer reassembly window. + */ +export function resolveEscapeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const configured = Number(env.PI_TUI_ESC_TIMEOUT); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + if (env.SSH_CONNECTION || env.SSH_TTY) { + return DEFAULT_SSH_ESCAPE_TIMEOUT_MS; + } + return DEFAULT_ESCAPE_TIMEOUT_MS; +} + /** * Real terminal using process.stdin/stdout */ @@ -183,7 +202,7 @@ export class ProcessTerminal implements Terminal { * to handle the case where the response arrives split across multiple events. */ private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer(); + this.stdinBuffer = new StdinBuffer({ timeout: resolveEscapeTimeoutMs() }); // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 7c4997f0064..a06f1ec2bb4 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -7,6 +7,7 @@ import assert from "node:assert"; import { beforeEach, describe, it } from "node:test"; +import { matchesKey } from "../src/keys.ts"; import { StdinBuffer } from "../src/stdin-buffer.ts"; describe("StdinBuffer", () => { @@ -134,6 +135,35 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); + + it("should flush a lone ESC as Escape when CR arrives after the timeout", async () => { + // Legacy-mode Alt+Enter is ESC + CR; when the terminal/transport splits + // the bytes further apart than the timeout, ESC is flushed alone and the + // host sees Escape (interrupt) instead of Alt+Enter. This locks in the + // behavior so the configurable timeout in ProcessTerminal stays honest. + processInput("\x1b"); + await wait(20); // buffer timeout is 10ms in beforeEach + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true); + }); + + it("should merge ESC + CR split across chunks within a larger timeout", async () => { + buffer = new StdinBuffer({ timeout: 100 }); + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + processInput("\x1b"); + await wait(20); // > 10ms old default, < 100ms configured timeout + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true); + }); + it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { const delayedBuffer = new StdinBuffer(); const delayedSequences: string[] = []; diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 06c751dbc5a..88d9ea747b5 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,7 +1,35 @@ import assert from "node:assert"; import { describe, it, mock } from "node:test"; import { setKittyProtocolActive } from "../src/keys.ts"; -import { normalizeAppleTerminalInput, normalizeNativeShiftEnterInput, ProcessTerminal } from "../src/terminal.ts"; +import { + normalizeAppleTerminalInput, + normalizeNativeShiftEnterInput, + ProcessTerminal, + resolveEscapeTimeoutMs, +} from "../src/terminal.ts"; + +describe("resolveEscapeTimeoutMs", () => { + it("uses PI_TUI_ESC_TIMEOUT when configured", () => { + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80" }), 80); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80", SSH_TTY: "/dev/pts/1" }), 80); + }); + + it("ignores invalid PI_TUI_ESC_TIMEOUT values", () => { + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "abc" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "0" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "-5" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "" }), 10); + }); + + it("defaults to 100ms over SSH", () => { + assert.equal(resolveEscapeTimeoutMs({ SSH_CONNECTION: "10.0.0.1 22" }), 100); + assert.equal(resolveEscapeTimeoutMs({ SSH_TTY: "/dev/pts/1" }), 100); + }); + + it("defaults to 10ms otherwise", () => { + assert.equal(resolveEscapeTimeoutMs({}), 10); + }); +}); describe("normalizeNativeShiftEnterInput", () => { it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => { @@ -195,7 +223,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { const harness = setupNegotiation(); try { harness.send("\x1b["); - mock.timers.tick(10); + mock.timers.tick(resolveEscapeTimeoutMs()); assert.equal(harness.getInput(), undefined); From e3798ca91f23a69c6833443005ec8a252f292efc Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Tue, 11 Aug 2026 05:12:34 -0400 Subject: [PATCH 103/284] fix(coding-agent): inherit subagent session config (#7897) --- .../examples/extensions/subagent/README.md | 2 ++ .../examples/extensions/subagent/index.ts | 24 ++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/examples/extensions/subagent/README.md b/packages/coding-agent/examples/extensions/subagent/README.md index f98b64586a3..9290342dc17 100644 --- a/packages/coding-agent/examples/extensions/subagent/README.md +++ b/packages/coding-agent/examples/extensions/subagent/README.md @@ -137,6 +137,8 @@ model: claude-haiku-4-5 System prompt for the agent goes here. ``` +When `model` is omitted, the subagent inherits the dispatching session's active model and thinking level. + **Locations:** - `~/.pi/agent/agents/*.md` - User-level (always loaded) - `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`) diff --git a/packages/coding-agent/examples/extensions/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts index 832dcc7423d..fa3b09e3804 100644 --- a/packages/coding-agent/examples/extensions/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -16,7 +16,7 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentToolResult, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Message } from "@earendil-works/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai"; import { @@ -264,8 +264,14 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } { type OnUpdateCallback = (partial: AgentToolResult) => void; +interface DispatchDefaults { + model?: string; + thinkingLevel?: ThinkingLevel; +} + async function runSingleAgent( defaultCwd: string, + dispatchDefaults: DispatchDefaults, agents: AgentConfig[], agentName: string, task: string, @@ -292,7 +298,12 @@ async function runSingleAgent( } const args: string[] = ["--mode", "json", "-p", "--no-session"]; - if (agent.model) args.push("--model", agent.model); + const inheritsDispatchConfig = !agent.model; + const model = agent.model ?? dispatchDefaults.model; + if (model) args.push("--model", model); + if (inheritsDispatchConfig && dispatchDefaults.thinkingLevel) { + args.push("--thinking", dispatchDefaults.thinkingLevel); + } if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); let tmpPromptDir: string | null = null; @@ -306,7 +317,7 @@ async function runSingleAgent( messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, - model: agent.model, + model, step, }; @@ -471,6 +482,10 @@ export default function (pi: ExtensionAPI) { async execute(_toolCallId, params, signal, onUpdate, ctx) { const agentScope: AgentScope = params.agentScope ?? "user"; + const dispatchDefaults: DispatchDefaults = { + model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, + thinkingLevel: ctx.thinkingLevel, + }; const discovery = discoverAgents(ctx.cwd, agentScope); const agents = discovery.agents; const confirmProjectAgents = params.confirmProjectAgents ?? true; @@ -552,6 +567,7 @@ export default function (pi: ExtensionAPI) { const result = await runSingleAgent( ctx.cwd, + dispatchDefaults, agents, step.agent, taskWithContext, @@ -624,6 +640,7 @@ export default function (pi: ExtensionAPI) { const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { const result = await runSingleAgent( ctx.cwd, + dispatchDefaults, agents, t.agent, t.task, @@ -666,6 +683,7 @@ export default function (pi: ExtensionAPI) { if (params.agent && params.task) { const result = await runSingleAgent( ctx.cwd, + dispatchDefaults, agents, params.agent, params.task, From 4a879dd759936870ae4f6d424d5be465e7df55c6 Mon Sep 17 00:00:00 2001 From: Michael Renner Date: Tue, 11 Aug 2026 11:13:37 +0200 Subject: [PATCH 104/284] fix(tui): avoid repainting idle fullscreen sessions on focus loss (#7892) * fix(tui): avoid idle focus-loss repaint * fix(tui): skip zero-width focus-loss repaint * fix(tui): clarify focus-loss selection check --- packages/tui/src/tui-alt-screen.ts | 3 +- packages/tui/test/tui-alt-screen.test.ts | 77 +++++++++++++++++++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 8b7e8b8700e..f4d64e0ad2f 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -528,6 +528,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private handleViewportInput(data: string): { consume?: boolean } | undefined { if (data === FOCUS_OUT) { const hadActiveSelection = this.selectionPressActive; + const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined; this.selectionPressActive = false; this.stopSelectionAutoScroll(); this.stopScrollbarHover(); @@ -539,9 +540,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.selectionFocus = undefined; this.selectionGranularity = "character"; this.selectionInitialRange = undefined; + if (hadNonEmptyActiveSelection) this.requestRender(); } this.lastClick = undefined; - this.requestRender(); return { consume: true }; } if (data === FOCUS_IN) return { consume: true }; diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 7df229dbff6..06b7dc0d412 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1020,16 +1020,23 @@ describe("TuiAltScreen", () => { tui.stop(); }); - it("ignores orphan selection events and cancels an active selection on focus loss", async () => { + it("does not repaint idle or zero-width selections on focus loss", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); tui.start(); await terminal.waitForRender(); + const writeCount = () => terminal.events.filter((event) => event.type === "write").length; const clipboardWriteCount = () => terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;")).length; + const idleWriteCount = writeCount(); + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(writeCount(), idleWriteCount); + // A completed click leaves a zero-width anchor, but later orphaned drag/release events must not extend it. terminal.sendInput("\x1b[<0;1;1M"); terminal.sendInput("\x1b[<0;1;1m"); @@ -1038,10 +1045,14 @@ describe("TuiAltScreen", () => { await terminal.waitForRender(); assert.strictEqual(clipboardWriteCount(), 0); - // Losing focus also cancels a press whose matching release never arrived. - terminal.sendInput("\x1b[<0;1;1M"); + // Losing focus after a press without a drag cancels the press without repainting. + terminal.sendInput("\x1b[<0;1;3M"); + await terminal.waitForRender(); + const pressedWriteCount = writeCount(); terminal.sendInput("\x1b[O"); terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(writeCount(), pressedWriteCount); terminal.sendInput("\x1b[<32;4;2M"); terminal.sendInput("\x1b[<0;4;2m"); await terminal.waitForRender(); @@ -1052,6 +1063,66 @@ describe("TuiAltScreen", () => { assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1004l"))); }); + it("clears an active visible selection on focus loss and ignores orphan events", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + await terminal.waitForRender(); + const focusLossEventCount = terminal.events.length; + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + const focusLossWrites = terminal.events + .slice(focusLossEventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(focusLossWrites.includes("alpha")); + assert.ok(focusLossWrites.includes("beta")); + assert.ok(!focusLossWrites.includes("\x1b[7m")); + + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); + tui.stop(); + }); + + it("retains a completed visible selection across focus changes", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + const completedWriteCount = terminal.events.filter((event) => event.type === "write").length; + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(terminal.events.filter((event) => event.type === "write").length, completedWriteCount); + + const redrawEventCount = terminal.events.length; + tui.renderNow(true); + const redrawWrites = terminal.events + .slice(redrawEventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(redrawWrites.includes("alpha")); + assert.ok(redrawWrites.includes("beta")); + assert.ok(redrawWrites.includes("\x1b[7m")); + tui.stop(); + }); + it("stacks flash messages and collapses them as they expire", async () => { const terminal = new VirtualTerminal(20, 4); const tui = new TuiAltScreen(terminal); From b987ead3583d2d2d796868f062b811abfa9ccb52 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Tue, 11 Aug 2026 11:14:28 +0200 Subject: [PATCH 105/284] feat(agent): expose `expandPromptTemplates` in `sendUserMessage` (#7857) --- packages/coding-agent/docs/extensions.md | 4 ++ .../coding-agent/src/core/agent-session.ts | 8 +-- .../coding-agent/src/core/extensions/types.ts | 7 +- .../test/suite/agent-session-prompt.test.ts | 64 ++++++++++++++++++- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 00e92a1d619..9ec65f89bb0 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1426,12 +1426,16 @@ pi.sendUserMessage([ // During streaming - must specify delivery mode pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" }); pi.sendUserMessage("And then summarize", { deliverAs: "followUp" }); + +// Opt in to extension command dispatch and skill/prompt template expansion +pi.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true }); ``` **Options:** - `deliverAs` - Required when agent is streaming: - `"steer"` - Queues the message for delivery after the current assistant turn finishes executing its tool calls - `"followUp"` - Waits for agent to finish all tools +- `expandPromptTemplates` - Dispatch extension commands and expand skill commands and prompt templates. Defaults to `false`. When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b210c5ace4f..179a5521932 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -238,7 +238,7 @@ export interface ExtensionBindings { /** Options for AgentSession.prompt() */ export interface PromptOptions { - /** Whether to expand file-based prompt templates (default: true) */ + /** Whether to dispatch extension commands and expand skill commands and prompt templates (default: true) */ expandPromptTemplates?: boolean; /** Image attachments */ images?: ImageContent[]; @@ -1476,10 +1476,11 @@ export class AgentSession { * * @param content User message content (string or content array) * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" + * @param options.expandPromptTemplates Whether to dispatch extension commands and expand skill commands and prompt templates. Default: false. */ async sendUserMessage( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ): Promise { // Normalize content to text string + optional images let text: string; @@ -1501,9 +1502,8 @@ export class AgentSession { if (images.length === 0) images = undefined; } - // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion await this.prompt(text, { - expandPromptTemplates: false, + expandPromptTemplates: options?.expandPromptTemplates ?? false, streamingBehavior: options?.deliverAs, images, source: "extension", diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index a87322ce9e1..db0ebacaa54 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -399,7 +399,7 @@ export interface ReplacedSessionContext extends ExtensionCommandContext { sendUserMessage( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ): Promise; } @@ -1307,10 +1307,11 @@ export interface ExtensionAPI { /** * Send a user message to the agent. Always triggers a turn. * When the agent is streaming, use deliverAs to specify how to queue the message. + * Set expandPromptTemplates to dispatch extension commands and expand skill commands and prompt templates. */ sendUserMessage( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ): void; /** Append a custom entry to the session for state persistence (not sent to LLM). */ @@ -1560,7 +1561,7 @@ export type SendMessageHandler = ( export type SendUserMessageHandler = ( content: string | (TextContent | ImageContent)[], - options?: { deliverAs?: "steer" | "followUp" }, + options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }, ) => void; export type AppendEntryHandler = (customType: string, data?: T) => void; diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index 0e9c587fd05..1fa23fbe29c 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -5,7 +5,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; -import type { InputEvent } from "../../src/core/extensions/index.ts"; +import type { ExtensionAPI, InputEvent } from "../../src/core/extensions/index.ts"; import type { PromptTemplate } from "../../src/core/prompt-templates.ts"; import { createSyntheticSourceInfo } from "../../src/core/source-info.ts"; import { createTestResourceLoader } from "../utilities.ts"; @@ -224,6 +224,39 @@ describe("AgentSession prompt characterization", () => { expect(expandedPrompt).toBe("Review this code: src/index.ts"); }); + it("sendUserMessage can opt into prompt template expansion", async () => { + const template: PromptTemplate = { + name: "review", + description: "Review template", + content: "Review this code: $1", + filePath: "/virtual/review.md", + sourceInfo: createSyntheticSourceInfo("/virtual/review.md", { + source: "local", + scope: "temporary", + origin: "top-level", + }), + }; + const resourceLoader = { + ...createTestResourceLoader(), + getPrompts: () => ({ prompts: [template], diagnostics: [] }), + }; + const harness = await createHarness({ resourceLoader }); + harnesses.push(harness); + let expandedPrompt = ""; + + harness.setResponses([ + (context) => { + const user = context.messages.find((message) => message.role === "user"); + expandedPrompt = user ? getMessageText(user) : ""; + return fauxAssistantMessage("ok"); + }, + ]); + + await harness.session.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true }); + + expect(expandedPrompt).toBe("Review this code: src/index.ts"); + }); + it("dispatches extension commands without consuming a provider response", async () => { const commandRuns: string[] = []; const harness = await createHarness({ @@ -248,6 +281,35 @@ describe("AgentSession prompt characterization", () => { expect(harness.getPendingResponseCount()).toBe(1); }); + it("extension sendUserMessage can opt into extension command dispatch", async () => { + let extensionApi: ExtensionAPI | undefined; + let resolveCommandRun: (args: string) => void = () => {}; + const commandRun = new Promise((resolve) => { + resolveCommandRun = resolve; + }); + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + extensionApi = pi; + pi.registerCommand("testcmd", { + description: "Test command", + handler: async (args) => { + resolveCommandRun(args); + }, + }); + }, + ], + }); + harnesses.push(harness); + expect(extensionApi).toBeDefined(); + + extensionApi?.sendUserMessage("/testcmd hello world", { expandPromptTemplates: true }); + + await expect(commandRun).resolves.toBe("hello world"); + expect(harness.session.messages).toEqual([]); + expect(harness.getPendingResponseCount()).toBe(0); + }); + it("sendUserMessage while idle triggers a turn", async () => { const harness = await createHarness(); harnesses.push(harness); From 2f8b4b42fecf2388f0f3326eab16c72eca3f62b0 Mon Sep 17 00:00:00 2001 From: Michael Yu Date: Tue, 11 Aug 2026 17:15:11 +0800 Subject: [PATCH 106/284] fix(ai): expose low reasoning effort for native DeepSeek V4 Flash (#7807) --- packages/ai/scripts/generate-models.ts | 8 +++++++- packages/ai/test/supports-xhigh.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 95c939c7071..d44c75e97af 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -267,6 +267,10 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = { high: "high", max: "max", } as const; +const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP = { + ...DEEPSEEK_V4_THINKING_LEVEL_MAP, + low: "low", +} as const; const QWEN_TOKEN_PLAN_HIGH_MAX_THINKING_LEVEL_MAP = { minimal: null, low: null, @@ -875,7 +879,9 @@ function applyThinkingLevelMetadata(model: Model): void { model, model.provider === "openrouter" ? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null } - : DEEPSEEK_V4_THINKING_LEVEL_MAP, + : model.provider === "deepseek" && model.id === "deepseek-v4-flash" + ? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP + : DEEPSEEK_V4_THINKING_LEVEL_MAP, ); } if (isGoogleThinkingApi(model) && isGemini3ProModel(model.id)) { diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 29e16fa8280..a1fd7ff8f06 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -82,10 +82,10 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["medium", "high", "xhigh"]); }); - it("includes only high/max plus off for DeepSeek V4 Flash on the DeepSeek provider", () => { + it("includes low/high/max plus off for DeepSeek V4 Flash on the DeepSeek provider", () => { const model = getModel("deepseek", "deepseek-v4-flash"); expect(model).toBeDefined(); - expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high", "max"]); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "high", "max"]); }); it("includes only high/max plus off for DeepSeek V4 Flash on opencode-go", () => { From c0468b922c2e300eee1f136ce4f987e3e05ac898 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Aug 2026 09:20:47 +0000 Subject: [PATCH 107/284] chore: approve contributors from issue #7908 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 3a369467b3d..26535d81f31 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -357,3 +357,5 @@ johnatbasicas pr powerfooI pr yearth pr + +pablasso pr From 536eb71794c99e409c2292641b22ede64886fed7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Aug 2026 09:23:49 +0000 Subject: [PATCH 108/284] chore: approve contributors from issue #7912 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 26535d81f31..c268f0d4832 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -359,3 +359,5 @@ powerfooI pr yearth pr pablasso pr + +bilby91 pr From 00eb2d1515e18e00940139f5e6568230a071f33c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Aug 2026 09:28:46 +0000 Subject: [PATCH 109/284] chore: approve contributors from issue #7919 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index c268f0d4832..15211d52141 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -361,3 +361,5 @@ yearth pr pablasso pr bilby91 pr + +giannisCKS pr From f8c71c6a0693bc7f71f84e92783315d6a725a721 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 11:35:03 +0200 Subject: [PATCH 110/284] docs: fix security policy typos Fixes #7935 --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ddd75e14d1f..e54ba8be6b0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ This document should guide you about understanding the security concept behind Pi and also where the boundaries are. In general Pi is a coding agent that runs locally within the security boundary -of the user that is running it. It's the responsibiltiy of the user to monitor +of the user that is running it. It's the responsibility of the user to monitor its operations or to contain it within a container, virtual machine or other Sandbox solution. @@ -42,7 +42,7 @@ reports and coordinate disclosure as appropriate. ## Scope Security issues in the distributed packages, command-line tools, APIs, and -repository code are in scope as well as earendil operated infrastricture +repository code are in scope as well as earendil operated infrastructure on `pi.dev`. ## Out Of Scope From 9c53c47f8086190d0b433216fcfffae69d3e255f Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 11 Aug 2026 09:49:08 +0000 Subject: [PATCH 111/284] fix indent --- packages/tui/test/stdin-buffer.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index a06f1ec2bb4..10d2cc3eca0 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -135,7 +135,6 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); - it("should flush a lone ESC as Escape when CR arrives after the timeout", async () => { // Legacy-mode Alt+Enter is ESC + CR; when the terminal/transport splits // the bytes further apart than the timeout, ESC is flushed alone and the @@ -162,8 +161,8 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b\r"]); assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true); - }); - + }); + it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { const delayedBuffer = new StdinBuffer(); const delayedSequences: string[] = []; From 7915cdac64abdb5fe8674d017e69f8c4f3bf6ff9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 11 Aug 2026 11:32:32 +0200 Subject: [PATCH 112/284] feat(ai): add strict tool schema conversion --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/anthropic-messages.ts | 7 +- .../ai/src/api/bedrock-converse-stream.ts | 7 +- packages/ai/src/api/constrained-sampling.ts | 137 +++++++++++++++++- packages/ai/src/api/google-generative-ai.ts | 8 +- packages/ai/src/api/google-shared.ts | 21 ++- packages/ai/src/api/google-vertex.ts | 8 +- packages/ai/src/api/mistral-conversations.ts | 4 +- packages/ai/src/api/openai-completions.ts | 3 +- .../ai/src/api/openai-responses-shared.ts | 6 +- packages/ai/src/utils/validation.ts | 33 +++++ .../anthropic-eager-tool-input-compat.test.ts | 7 +- packages/ai/test/constrained-sampling.test.ts | 78 +++++++++- packages/ai/test/validation.test.ts | 45 ++++++ packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/experimental.ts | 6 + packages/coding-agent/src/core/tools/bash.ts | 2 + packages/coding-agent/src/core/tools/edit.ts | 2 + packages/coding-agent/src/core/tools/read.ts | 2 + packages/coding-agent/src/core/tools/write.ts | 2 + .../coding-agent/src/server/create-harness.ts | 2 + .../experimental-tool-strict-mode.test.ts | 38 +++++ 22 files changed, 391 insertions(+), 29 deletions(-) create mode 100644 packages/coding-agent/test/experimental-tool-strict-mode.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9e1b7a98515..316fc08a191 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed +- Automatically converted supported strict tool schemas to provider-compatible closed objects with required nullable optional fields while preserving original tool definitions, and treated `null` values for optional non-nullable tool arguments as omitted. - Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). - Replaced the Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 04709374686..10f91f2a3cf 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -37,7 +37,7 @@ import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; +import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; @@ -1296,7 +1296,8 @@ function convertTools( return tools.map((tool, index) => { const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools); - const schema = tool.parameters as { properties?: unknown; required?: string[] }; + const parameters = getJsonSchemaToolParameters(tool, strict); + const schema = parameters as { properties?: unknown; required?: string[] }; const legacyInputSchema = { type: "object" as const, properties: schema.properties ?? {}, @@ -1305,7 +1306,7 @@ function convertTools( const inputSchema = strict === true ? { - ...(tool.parameters as Record), + ...(parameters as Record), ...legacyInputSchema, } : legacyInputSchema; diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index c04b7c6ecd4..20f50efe861 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -55,7 +55,7 @@ import { parseStreamingJson } from "../utils/json-parse.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; +import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { adjustMaxTokensForThinking, buildBaseOptions, @@ -225,6 +225,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = let responseRequestId: string | undefined; try { + const supportsStrictMode = model.compat?.supportsStrictMode ?? false; const client = new BedrockRuntimeClient(config); const customHeaders = providerHeadersToRecord(options.headers); if (customHeaders) { @@ -240,7 +241,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }), ...(options.temperature !== undefined && { temperature: options.temperature }), }, - toolConfig: convertToolConfig(context.tools, options.toolChoice, model.compat?.supportsStrictMode ?? false), + toolConfig: convertToolConfig(context.tools, options.toolChoice, supportsStrictMode), additionalModelRequestFields: buildAdditionalModelRequestFields(model, options), ...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }), }; @@ -1007,7 +1008,7 @@ function convertToolConfig( toolSpec: { name: tool.name, description: tool.description, - inputSchema: { json: tool.parameters as unknown as DocumentType }, + inputSchema: { json: getJsonSchemaToolParameters(tool, strict) as unknown as DocumentType }, ...(strict === true ? { strict: true } : {}), }, }; diff --git a/packages/ai/src/api/constrained-sampling.ts b/packages/ai/src/api/constrained-sampling.ts index ec961a12399..ff9c4f1514a 100644 --- a/packages/ai/src/api/constrained-sampling.ts +++ b/packages/ai/src/api/constrained-sampling.ts @@ -1,11 +1,135 @@ import type { Tool } from "../types.ts"; interface JsonSchemaObject { + [key: string]: unknown; type?: unknown; properties?: Record; required?: unknown; } +class UnsupportedStrictJsonSchemaError extends Error {} + +const UNSUPPORTED_STRICT_SCHEMA_KEYS = [ + "$ref", + "$defs", + "definitions", + "allOf", + "oneOf", + "patternProperties", + "dependentSchemas", + "dependencies", + "unevaluatedProperties", + "propertyNames", + "contains", + "prefixItems", + "not", + "if", + "then", + "else", +] as const; + +function isJsonSchemaObject(value: unknown): value is JsonSchemaObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStructuredSchema(schema: unknown): boolean { + if (!isJsonSchemaObject(schema)) return false; + const types = typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? schema.type : []; + return ( + types.includes("object") || + types.includes("array") || + schema.properties !== undefined || + schema.items !== undefined + ); +} + +function schemaAllowsNull(schema: unknown): boolean { + if (!isJsonSchemaObject(schema)) return false; + if (schema.type === "null" || (Array.isArray(schema.type) && schema.type.includes("null"))) return true; + if (schema.const === null || (Array.isArray(schema.enum) && schema.enum.includes(null))) return true; + return Array.isArray(schema.anyOf) && schema.anyOf.some((variant) => schemaAllowsNull(variant)); +} + +function makeJsonSchemaNodeStrict(schema: unknown): void { + if (!isJsonSchemaObject(schema)) { + throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported"); + } + for (const key of UNSUPPORTED_STRICT_SCHEMA_KEYS) { + if (schema[key] !== undefined) { + throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`); + } + } + + if (schema.anyOf !== undefined) { + if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) { + throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema"); + } + for (const variant of schema.anyOf) { + if (isStructuredSchema(variant)) { + throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported"); + } + makeJsonSchemaNodeStrict(variant); + } + } + + if (schema.items !== undefined) { + if (Array.isArray(schema.items)) { + throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported"); + } + makeJsonSchemaNodeStrict(schema.items); + } + + const isObjectSchema = schema.type === "object"; + if (schema.properties !== undefined && !isObjectSchema) { + throw new UnsupportedStrictJsonSchemaError("properties require type object"); + } + if (!isObjectSchema) return; + if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) { + throw new UnsupportedStrictJsonSchemaError("schema-valued or true additionalProperties is unsupported"); + } + if (schema.properties !== undefined && !isJsonSchemaObject(schema.properties)) { + throw new UnsupportedStrictJsonSchemaError("object properties must be a schema map"); + } + if ( + schema.required !== undefined && + (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string")) + ) { + throw new UnsupportedStrictJsonSchemaError("object required must be a string array"); + } + + const properties = schema.properties ?? {}; + const propertyNames = Object.keys(properties); + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + if ([...required].some((key) => !propertyNames.includes(key))) { + throw new UnsupportedStrictJsonSchemaError("required contains an unknown property"); + } + for (const [key, property] of Object.entries(properties)) { + makeJsonSchemaNodeStrict(property); + if (!required.has(key) && !schemaAllowsNull(property)) { + properties[key] = { anyOf: [property, { type: "null" }] }; + } + } + schema.required = propertyNames; + schema.additionalProperties = false; +} + +/** Convert a tool schema to the strict subset expected by provider constrained sampling. */ +export function makeStrictJsonSchema(schema: Tool["parameters"]): Record { + const cloned: unknown = structuredClone(schema); + if (!isJsonSchemaObject(cloned)) { + throw new UnsupportedStrictJsonSchemaError("root schema must have type object"); + } + makeJsonSchemaNodeStrict(cloned); + if (cloned.type !== "object") { + throw new UnsupportedStrictJsonSchemaError("root schema must have type object"); + } + return cloned; +} + +export function getJsonSchemaToolParameters(tool: Tool, strict: boolean | undefined): Tool["parameters"] { + return (strict === true ? makeStrictJsonSchema(tool.parameters) : tool.parameters) as Tool["parameters"]; +} + export interface GrammarConstrainedSampling { format: "lark" | "regex"; definition: string; @@ -83,12 +207,17 @@ function inferGrammarInputProperty(tool: Tool): string { export function resolveJsonSchemaStrictSampling(tool: Tool, supportsStrictMode: boolean): boolean | undefined { const config = tool.constrainedSampling; - if (!config || config.type !== "json_schema") { - return undefined; - } + if (!config || config.type !== "json_schema") return undefined; if (supportsStrictMode) { - return true; + try { + makeStrictJsonSchema(tool.parameters); + return true; + } catch (error) { + if (!(error instanceof UnsupportedStrictJsonSchemaError)) throw error; + if (config.strict !== "require") return undefined; + throw new Error(`Tool "${tool.name}" requires JSON-schema constrained sampling, but ${error.message}.`); + } } if (config.strict === "require") { throw new Error( diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 8bd6319004a..30da47cd833 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -367,13 +367,17 @@ function buildParams( generationConfig.maxOutputTokens = options.maxTokens; } + const supportsStrictMode = supportsGoogleStrictToolSampling(model.id); const functionCallingMode = context.tools?.length - ? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsGoogleStrictToolSampling(model.id)) + ? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode) : undefined; const config: GenerateContentConfig = { ...(Object.keys(generationConfig).length > 0 && generationConfig), ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + ...(context.tools && + context.tools.length > 0 && { + tools: convertTools(context.tools, false, supportsStrictMode), + }), ...(functionCallingMode !== undefined && { toolConfig: { functionCallingConfig: { mode: functionCallingMode } }, }), diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index ae1dcdd73f5..fa81624004c 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -6,7 +6,7 @@ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from import type { Context, ImageContent, Model, StopReason, StreamOptions, TextContent, Tool } from "../types.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; +import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { transformMessages } from "./transform-messages.ts"; type GoogleApiType = "google-generative-ai" | "google-vertex"; @@ -285,17 +285,22 @@ function sanitizeForOpenApi(schema: unknown): unknown { export function convertTools( tools: Tool[], useParameters = false, + supportsStrictMode = true, ): { functionDeclarations: Record[] }[] | undefined { if (tools.length === 0) return undefined; return [ { - functionDeclarations: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - ...(useParameters - ? { parameters: sanitizeForOpenApi(tool.parameters as unknown) } - : { parametersJsonSchema: tool.parameters }), - })), + functionDeclarations: tools.map((tool) => { + const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode); + const parameters = getJsonSchemaToolParameters(tool, strict); + return { + name: tool.name, + description: tool.description, + ...(useParameters + ? { parameters: sanitizeForOpenApi(parameters as unknown) } + : { parametersJsonSchema: parameters }), + }; + }), }, ]; } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 112c385f3e2..5bb90663e8f 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -466,13 +466,17 @@ function buildParams( generationConfig.maxOutputTokens = options.maxTokens; } + const supportsStrictMode = supportsGoogleStrictToolSampling(model.id); const functionCallingMode = context.tools?.length - ? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsGoogleStrictToolSampling(model.id)) + ? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode) : undefined; const config: GenerateContentConfig = { ...(Object.keys(generationConfig).length > 0 && generationConfig), ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), - ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + ...(context.tools && + context.tools.length > 0 && { + tools: convertTools(context.tools, false, supportsStrictMode), + }), ...(functionCallingMode !== undefined && { toolConfig: { functionCallingConfig: { mode: functionCallingMode } }, }), diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index e29d1a13a33..b52eae4cf8f 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -18,7 +18,7 @@ import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; +import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { buildBaseOptions } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; @@ -753,7 +753,7 @@ function toFunctionTools(tools: Tool[]): MistralFunctionTool[] { function: { name: tool.name, description: tool.description, - parameters: stripSymbolKeys(tool.parameters) as Record, + parameters: stripSymbolKeys(getJsonSchemaToolParameters(tool, strict)) as Record, strict: strict ?? false, }, }; diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 73e96e9402b..c62ef9f874e 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -47,6 +47,7 @@ import { createGrammarToolInputProperties, type GrammarToolInputJsonBuffer, getGrammarToolInput, + getJsonSchemaToolParameters, resolveGrammarConstrainedSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.ts"; @@ -1363,7 +1364,7 @@ function convertTools( function: { name: tool.name, description: tool.description, - parameters: tool.parameters as Record, // TypeBox already generates JSON Schema + parameters: getJsonSchemaToolParameters(tool, strict) as Record, // Only include strict if provider supports it. Some reject unknown fields. ...(compat.supportsStrictMode !== false && { strict: strict ?? false }), }, diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 8ed84468132..dca994a6f32 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -36,6 +36,7 @@ import { appendGrammarToolInputJsonDelta, type GrammarToolInputJsonBuffer, getGrammarToolInput, + getJsonSchemaToolParameters, resolveGrammarConstrainedSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.ts"; @@ -377,17 +378,18 @@ export function convertResponsesTools(tools: readonly Tool[], options?: ConvertR } const constrainedStrict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode); + const strict = constrainedStrict ?? defaultStrict; const functionTool: Omit, "strict"> & { strict?: Extract["strict"]; } = { type: "function", name: tool.name, description: tool.description, - parameters: tool.parameters as Record, // TypeBox already generates JSON Schema + parameters: getJsonSchemaToolParameters(tool, strict === true) as Record, ...(options?.deferLoading ? { defer_loading: true } : {}), }; if (supportsStrictMode) { - functionTool.strict = constrainedStrict ?? defaultStrict; + functionTool.strict = strict; } return functionTool as OpenAITool; }); diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 17a00b8ec23..cf6902df263 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -9,6 +9,7 @@ const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); interface JsonSchemaObject { type?: string | string[]; properties?: Record; + required?: string[]; items?: JsonSchemaObject | JsonSchemaObject[]; additionalProperties?: boolean | JsonSchemaObject; allOf?: JsonSchemaObject[]; @@ -236,6 +237,37 @@ function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown return nextValue; } +function normalizeOptionalNulls(value: unknown, schema: JsonSchemaObject): void { + if (Array.isArray(value)) { + if (Array.isArray(schema.items)) { + for (let index = 0; index < value.length; index++) { + const itemSchema = schema.items[index]; + if (itemSchema) normalizeOptionalNulls(value[index], itemSchema); + } + } else if (schema.items) { + for (const item of value) normalizeOptionalNulls(item, schema.items); + } + return; + } + if (typeof value !== "object" || value === null || !schema.properties) return; + + const object = value as Record; + const required = new Set(schema.required ?? []); + for (const [key, propertySchema] of Object.entries(schema.properties)) { + if (!(key in object)) continue; + if ( + object[key] === null && + !required.has(key) && + typeof (propertySchema as { $ref?: unknown }).$ref !== "string" && + getSubSchemaValidator(propertySchema)?.Check(null) === false + ) { + delete object[key]; + } else { + normalizeOptionalNulls(object[key], propertySchema); + } + } +} + function getValidator(schema: Tool["parameters"]): ReturnType { const key = schema as object; const cached = validatorCache.get(key); @@ -284,6 +316,7 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { */ export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { const args = structuredClone(toolCall.arguments); + normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject); Value.Convert(tool.parameters, args); const validator = getValidator(tool.parameters); diff --git a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts index 39be3a9a1b4..37280cb49e3 100644 --- a/packages/ai/test/anthropic-eager-tool-input-compat.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-compat.test.ts @@ -39,7 +39,10 @@ const schemaCompatibilityTool: Tool = { const strictTool: Tool = { ...tool, - parameters: Type.Object({ value: Type.String() }, { additionalProperties: false, title: "StrictLookupInput" }), + parameters: Type.Object( + { value: Type.String(), optional: Type.Optional(Type.Number()) }, + { title: "StrictLookupInput" }, + ), constrainedSampling: { type: "json_schema", strict: "prefer" }, }; @@ -155,6 +158,8 @@ describe("Anthropic eager tool input streaming compatibility", () => { expect(getFirstTool(strictRequest.body).strict).toBe(true); expect(getFirstToolInputSchema(strictRequest.body)).toMatchObject({ additionalProperties: false, + required: ["value", "optional"], + properties: { optional: { anyOf: [{ type: "number" }, { type: "null" }] } }, title: "StrictLookupInput", }); }); diff --git a/packages/ai/test/constrained-sampling.test.ts b/packages/ai/test/constrained-sampling.test.ts index 9cf962e7022..f24edd1ff3e 100644 --- a/packages/ai/test/constrained-sampling.test.ts +++ b/packages/ai/test/constrained-sampling.test.ts @@ -1,7 +1,11 @@ import type { ResponseStreamEvent } from "openai/resources/responses/responses.js"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { appendGrammarToolInputJsonDelta } from "../src/api/constrained-sampling.ts"; +import { + appendGrammarToolInputJsonDelta, + makeStrictJsonSchema, + resolveJsonSchemaStrictSampling, +} from "../src/api/constrained-sampling.ts"; import { convertResponsesMessages, convertResponsesTools, @@ -114,6 +118,78 @@ describe("constrained tool sampling", () => { ); }); + it("derives strict provider schemas without changing tool definitions", () => { + const parameters = Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + metadata: Type.Object({ enabled: Type.Optional(Type.Boolean()) }), + nullable: Type.Optional(Type.Union([Type.String(), Type.Null()])), + }); + + const strict = makeStrictJsonSchema(parameters); + + expect(parameters).not.toHaveProperty("additionalProperties"); + expect(parameters.required).toEqual(["path", "metadata"]); + expect(strict).toMatchObject({ + additionalProperties: false, + required: ["path", "offset", "metadata", "nullable"], + properties: { + offset: { anyOf: [{ type: "number" }, { type: "null" }] }, + metadata: { + additionalProperties: false, + required: ["enabled"], + properties: { enabled: { anyOf: [{ type: "boolean" }, { type: "null" }] } }, + }, + nullable: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + }); + }); + + it("falls back or rejects schemas that cannot be safely converted", () => { + const cases: Array<{ parameters: Tool["parameters"]; error: string }> = [ + { + parameters: Type.Object({ metadata: Type.Object({}, { additionalProperties: Type.String() }) }), + error: "additionalProperties is unsupported", + }, + { + parameters: Type.Intersect([Type.Object({ a: Type.String() }), Type.Object({ b: Type.Number() })]), + error: "allOf schemas are unsupported", + }, + { + parameters: Type.Object({ + value: Type.Union([Type.Object({ nested: Type.String() }), Type.Null()]), + }), + error: "object and array unions are unsupported", + }, + { + parameters: { + type: "object", + properties: { child: { $ref: "https://example.com/child.json" } }, + required: ["child"], + } as Tool["parameters"], + error: "$ref schemas are unsupported", + }, + ]; + + for (const { parameters, error } of cases) { + const tool: Tool = { + ...makeTool(), + parameters, + constrainedSampling: { type: "json_schema", strict: "prefer" }, + }; + + expect(() => makeStrictJsonSchema(parameters)).toThrow(error); + expect(resolveJsonSchemaStrictSampling(tool, true)).toBeUndefined(); + expect(convertResponsesTools([tool], { supportsStrictMode: true })[0]).toMatchObject({ + strict: false, + parameters, + }); + + tool.constrainedSampling = { type: "json_schema", strict: "require" }; + expect(() => resolveJsonSchemaStrictSampling(tool, true)).toThrow(error); + } + }); + it("replays grammar calls as custom Responses items", () => { const replayedToolCall: ToolCall = { type: "toolCall", diff --git a/packages/ai/test/validation.test.ts b/packages/ai/test/validation.test.ts index 39f070dd3d7..1a212ac1276 100644 --- a/packages/ai/test/validation.test.ts +++ b/packages/ai/test/validation.test.ts @@ -98,6 +98,51 @@ describe("validateToolArguments", () => { } }); + it("treats null as omission for optional non-nullable properties", () => { + const tool: Tool = { + name: "echo", + description: "Echo tool", + parameters: Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + nullable: Type.Optional(Type.Union([Type.String(), Type.Null()])), + metadata: Type.Object({ enabled: Type.Optional(Type.Boolean()) }), + }), + }; + const toolCall: ToolCall = { + type: "toolCall", + id: "tool-1", + name: "echo", + arguments: { path: "file.txt", offset: null, nullable: null, metadata: { enabled: null } }, + }; + + expect(validateToolArguments(tool, toolCall)).toEqual({ + path: "file.txt", + nullable: null, + metadata: {}, + }); + }); + + it("preserves optional nulls whose referenced schema is nullable", () => { + const tool: Tool = { + name: "echo", + description: "Echo tool", + parameters: { + type: "object", + properties: { value: { $ref: "#/$defs/value" } }, + $defs: { value: { anyOf: [{ type: "number" }, { type: "null" }] } }, + } as Tool["parameters"], + }; + const toolCall: ToolCall = { + type: "toolCall", + id: "tool-1", + name: "echo", + arguments: { value: null }, + }; + + expect(validateToolArguments(tool, toolCall)).toEqual({ value: null }); + }); + it("preserves a value that already matches a nullable union arm", () => { const tool: Tool = { name: "echo", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 22fa86f7046..edab5849f3b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. +- Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`. - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. ### Changed diff --git a/packages/coding-agent/src/core/experimental.ts b/packages/coding-agent/src/core/experimental.ts index 12d33c74c88..77a7b5d2a7c 100644 --- a/packages/coding-agent/src/core/experimental.ts +++ b/packages/coding-agent/src/core/experimental.ts @@ -1,3 +1,9 @@ +const PREFER_STRICT_TOOL_SAMPLING = { type: "json_schema", strict: "prefer" } as const; + export function areExperimentalFeaturesEnabled(): boolean { return process.env.PI_EXPERIMENTAL === "1"; } + +export function getExperimentalToolSampling() { + return areExperimentalFeaturesEnabled() ? PREFER_STRICT_TOOL_SAMPLING : undefined; +} diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index d1c80487412..9c745df595e 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -15,6 +15,7 @@ import { trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.ts"; +import { getExperimentalToolSampling } from "../experimental.ts"; import type { ExtensionContext, ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { OutputAccumulator } from "./output-accumulator.ts"; import { getTextOutput, invalidArgText, str } from "./render-utils.ts"; @@ -333,6 +334,7 @@ export function createBashToolDefinition( promptSnippet: bashToolSystemPromptContribution.snippet, promptGuidelines: exposeSessionEnvironment ? [...bashToolSystemPromptContribution.guidelines] : undefined, parameters: bashSchema, + constrainedSampling: getExperimentalToolSampling(), async execute( _toolCallId, { command, timeout }: { command: string; timeout?: number }, diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 01fd4145391..45281c254ce 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -5,6 +5,7 @@ import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } import { type Static, Type } from "typebox"; import { renderDiff } from "../../modes/interactive/components/diff.ts"; import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import { getExperimentalToolSampling } from "../experimental.ts"; import type { ToolDefinition } from "../extensions/types.ts"; import { applyEditsToNormalizedContent, @@ -307,6 +308,7 @@ export function createEditToolDefinition( promptSnippet: editToolSystemPromptContribution.snippet, promptGuidelines: [...editToolSystemPromptContribution.guidelines], parameters: editSchema, + constrainedSampling: getExperimentalToolSampling(), renderShell: "self", prepareArguments: prepareEditArguments, async execute(_toolCallId, input: EditToolInput, signal?: AbortSignal, _onUpdate?, _ctx?) { diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 130e6a2ea4c..1f766610175 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -11,6 +11,7 @@ import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/inte import { processImage } from "../../utils/image-process.ts"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts"; +import { getExperimentalToolSampling } from "../experimental.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts"; import { getTextOutput, renderToolPath, replaceTabs, str } from "./render-utils.ts"; @@ -218,6 +219,7 @@ export function createReadToolDefinition( promptSnippet: readToolSystemPromptContribution.snippet, promptGuidelines: [...readToolSystemPromptContribution.guidelines], parameters: readSchema, + constrainedSampling: getExperimentalToolSampling(), async execute( _toolCallId, { path, offset, limit }: { path: string; offset?: number; limit?: number }, diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 876aa6d8001..d25435b0b4e 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -5,6 +5,7 @@ import { dirname } from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts"; +import { getExperimentalToolSampling } from "../experimental.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; @@ -196,6 +197,7 @@ export function createWriteToolDefinition( promptSnippet: writeToolSystemPromptContribution.snippet, promptGuidelines: [...writeToolSystemPromptContribution.guidelines], parameters: writeSchema, + constrainedSampling: getExperimentalToolSampling(), async execute( _toolCallId, { path, content }: { path: string; content: string }, diff --git a/packages/coding-agent/src/server/create-harness.ts b/packages/coding-agent/src/server/create-harness.ts index 80294fec23e..3ad0bad237c 100644 --- a/packages/coding-agent/src/server/create-harness.ts +++ b/packages/coding-agent/src/server/create-harness.ts @@ -11,6 +11,7 @@ import { type HarnessTool, } from "@earendil-works/pi-agent-core"; import type { Static, TSchema } from "typebox"; +import { getExperimentalToolSampling } from "../core/experimental.ts"; import { type BuildSystemPromptOptions, buildSystemPrompt } from "../core/system-prompt.ts"; import { bashToolSystemPromptContribution } from "../core/tools/bash.ts"; import { editToolSystemPromptContribution } from "../core/tools/edit.ts"; @@ -30,6 +31,7 @@ function createCodingAgentHarnessTool( return { ...tool, ...prompt, + constrainedSampling: getExperimentalToolSampling(), execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params as Static, signal, onUpdate, context), }; diff --git a/packages/coding-agent/test/experimental-tool-strict-mode.test.ts b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts new file mode 100644 index 00000000000..07a00d94c91 --- /dev/null +++ b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createBashToolDefinition, + createEditToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, +} from "../src/core/tools/index.ts"; + +function createBuiltInTools() { + return [ + createReadToolDefinition(process.cwd()), + createBashToolDefinition(process.cwd()), + createEditToolDefinition(process.cwd()), + createWriteToolDefinition(process.cwd()), + ]; +} + +describe("experimental strict built-in tools", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + + afterEach(() => { + if (originalPiExperimental === undefined) delete process.env.PI_EXPERIMENTAL; + else process.env.PI_EXPERIMENTAL = originalPiExperimental; + }); + + it("only enables strict-prefer sampling in experimental mode", () => { + delete process.env.PI_EXPERIMENTAL; + const normalTools = createBuiltInTools(); + process.env.PI_EXPERIMENTAL = "1"; + const experimentalTools = createBuiltInTools(); + + for (const [index, tool] of experimentalTools.entries()) { + expect(tool.constrainedSampling).toEqual({ type: "json_schema", strict: "prefer" }); + expect(tool.parameters).toEqual(normalTools[index]?.parameters); + expect(normalTools[index]?.constrainedSampling).toBeUndefined(); + } + }); +}); From 452923b54a6c8b2f95b80157a8f6c7963f183101 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 11 Aug 2026 12:37:45 +0200 Subject: [PATCH 113/284] fix(tui): parse multiline LaTeX arguments Closes #7760 --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 1 + packages/tui/src/latex.ts | 2 +- packages/tui/test/latex.test.ts | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index edab5849f3b..887f8e665f1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixed - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. +- Fixed inherited required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). ## [0.84.1] - 2026-08-07 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c917406706f..5a23149f43f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented SGR mouse input leaking into the search query. +- Fixed required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). ## [0.84.1] - 2026-08-07 diff --git a/packages/tui/src/latex.ts b/packages/tui/src/latex.ts index 58b6633b4b3..74cbd060acc 100644 --- a/packages/tui/src/latex.ts +++ b/packages/tui/src/latex.ts @@ -1145,7 +1145,7 @@ class LatexParser { } private parseRequiredArgumentValue(): string { - while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { this.position++; } if (this.position >= this.source.length) { diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts index 3644ec42b32..a9aca930b62 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/tui/test/latex.test.ts @@ -443,6 +443,7 @@ R\left(\frac{\pi}{4}\right) " -b±√(b²-4ac)\nx = ────────────\n 2a", ); assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1"); + assert.strictEqual(renderLatex("\\frac{1}\n{2}", { display: true }), "1\n─\n2"); }); it("keeps nested display fractions linear", () => { From 2a95ef70db83a19cf5500f31dc4ff8247e04043e Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 11 Aug 2026 12:52:39 +0200 Subject: [PATCH 114/284] fix(tui): apply PI_TUI_ESC_TIMEOUT only to lone ESC Split StdinBuffer's incomplete-sequence wait from the lone-ESC wait so SSH and PI_TUI_ESC_TIMEOUT can reassemble split Alt+Enter without delaying Escape or mouse/CSI buffering. --- .../docs/environment-variables.md | 2 +- packages/tui/src/stdin-buffer.ts | 18 +++++++++++++----- packages/tui/src/terminal.ts | 2 +- packages/tui/test/stdin-buffer.test.ts | 19 +++++++++++++++++-- packages/tui/test/terminal.test.ts | 2 +- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 1bebe10f33d..29c324f63fd 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -87,7 +87,7 @@ These variables are read by Pi itself: | `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported | | `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` | | `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) | -| `PI_TUI_ESC_TIMEOUT` | Escape-sequence timeout in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape | +| `PI_TUI_ESC_TIMEOUT` | How long to wait after a lone ESC before treating it as Escape, in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape | | `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset | | `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests | diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index b6c483fe9dc..cc10c61a551 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -20,7 +20,8 @@ import { EventEmitter } from "events"; const ESC = "\x1b"; -const LONE_ESCAPE_TIMEOUT_MS = 10; +const DEFAULT_SEQUENCE_TIMEOUT_MS = 50; +const DEFAULT_ESCAPE_TIMEOUT_MS = 10; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; @@ -257,10 +258,15 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain export type StdinBufferOptions = { /** - * Maximum time to wait for sequence completion (default: 50ms). - * A lone Escape uses at most 10ms to preserve keyboard responsiveness. + * Maximum time to wait for an incomplete sequence such as CSI or mouse + * (default: 50ms). */ timeout?: number; + /** + * Maximum time to wait after a lone ESC before treating it as Escape + * (default: 10ms). Increase for high-latency Alt+key input (SSH). + */ + escapeTimeout?: number; }; export type StdinBufferEventMap = { @@ -276,13 +282,15 @@ export class StdinBuffer extends EventEmitter { private buffer: string = ""; private timeout: ReturnType | null = null; private readonly timeoutMs: number; + private readonly escapeTimeoutMs: number; private pasteMode: boolean = false; private pasteBuffer: string = ""; private pendingKittyPrintableCodepoint: number | undefined; constructor(options: StdinBufferOptions = {}) { super(); - this.timeoutMs = options.timeout ?? 50; + this.timeoutMs = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT_MS; + this.escapeTimeoutMs = options.escapeTimeout ?? DEFAULT_ESCAPE_TIMEOUT_MS; } public process(data: string | Buffer): void { @@ -377,7 +385,7 @@ export class StdinBuffer extends EventEmitter { } if (this.buffer.length > 0) { - const timeoutMs = this.buffer === ESC ? Math.min(this.timeoutMs, LONE_ESCAPE_TIMEOUT_MS) : this.timeoutMs; + const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs; this.timeout = setTimeout(() => { const flushed = this.flush(); diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index ade941a3aec..08d8cbbcfe0 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -202,7 +202,7 @@ export class ProcessTerminal implements Terminal { * to handle the case where the response arrives split across multiple events. */ private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer({ timeout: resolveEscapeTimeoutMs() }); + this.stdinBuffer = new StdinBuffer({ escapeTimeout: resolveEscapeTimeoutMs() }); // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 10d2cc3eca0..4ed8a09136a 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -149,20 +149,35 @@ describe("StdinBuffer", () => { }); it("should merge ESC + CR split across chunks within a larger timeout", async () => { - buffer = new StdinBuffer({ timeout: 100 }); + buffer = new StdinBuffer({ escapeTimeout: 100 }); emittedSequences = []; buffer.on("data", (sequence) => { emittedSequences.push(sequence); }); processInput("\x1b"); - await wait(20); // > 10ms old default, < 100ms configured timeout + await wait(20); // > 10ms default escapeTimeout, < 100ms configured escapeTimeout processInput("\r"); assert.deepStrictEqual(emittedSequences, ["\x1b\r"]); assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true); }); + it("does not apply the sequence timeout to a lone ESC", async () => { + buffer = new StdinBuffer({ timeout: 100 }); + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + processInput("\x1b"); + await wait(20); + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true); + }); + it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { const delayedBuffer = new StdinBuffer(); const delayedSequences: string[] = []; diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 88d9ea747b5..80eadd6264c 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -223,7 +223,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { const harness = setupNegotiation(); try { harness.send("\x1b["); - mock.timers.tick(resolveEscapeTimeoutMs()); + mock.timers.tick(50); // StdinBuffer sequence timeout, not the lone-ESC timeout assert.equal(harness.getInput(), undefined); From 43b4e057ac816f0c1da7a48f560344ad23a5c1a3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:16:36 +0200 Subject: [PATCH 115/284] docs(agent): fix implementation-readiness review findings --- packages/agent/docs/harness-v3.md | 56 +++++++++++++++---------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 290d839e9c6..e97650115e0 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -1,23 +1,6 @@ # AgentHarness v3 — implementation specification -**Status:** complete, pending final audit. Once that audit passes, this document supersedes `agent-harness-spec.md` in full, which itself superseded `harness-v2.md`. Until then, `agent-harness-spec.md` remains authoritative. - -**Sources being merged:** - -- `agent-harness-spec.md` — the audited base spec ("base" below). Its interpreter, effects boundary, hooks, events, classifier, abort/close, and race catalog carry over with mechanical renames only; do not rewrite them. -- The storage walkthrough jot ("jot" below, parts 1–9) — the three-store model, pending registers, terminal cleanup, recovery, retention/partitioning, schema evolution, backend stories, API deltas, and the binding decisions in jot part 9. -- The storage-redesign critique findings, as resolved by jot part 9. - -**Global renames applied throughout v3:** - -```text -node / Node* → entry / *Entry (continuity with coding-agent) -slot → register -"storage substrate" → "Storage" -StoredValue / valueId → gone (no values table) -``` - ---- +This document supersedes `agent-harness-spec.md` in full, which itself superseded `harness-v2.md`. # Part 0 — Orientation @@ -151,6 +134,8 @@ type SettledAssistantMessage = AssistantMessage & { provider/model and Models auth resolver without resolving auth yet. */ interface ModelRequestLease { readonly model: Model; + // Generic parameters elided; the landed pi-ai API picks concrete generics + // (Model has no default Api parameter today). stream(context: Context, options?: ModelsApiStreamOptions): AssistantMessageEventStream; streamSimple(context: Context, options?: ModelsSimpleStreamOptions): @@ -221,13 +206,13 @@ Every id storage stores — entry ids, usage ids, and every reserved id that wil Minting rules: -1. An id is minted with `now()` **at reservation**. For born-placed entries — the hot path — reservation and placement are the same transaction, so the prefix equals the placement date. +1. An id is minted with `now()` **at reservation**. For direct appends and prompt entries, reservation and placement are the same transaction, so the prefix equals the placement date; assistant responses and tool results mint at the intent commit and place at settlement, so their prefixes trail placement by at most the request duration. 2. **Group cohesion: followers inherit the leader's timestamp.** Tool-result ids are minted with their assistant entry id's 48-bit timestamp — `idGenerator.next(timestampMs?)` (§2.8); fresh random bits keep them unique — so an assistant and its tool results form one time-cohesive group under id order, even across a midnight boundary. This is a deliberate, documented deviation from "UUID timestamp = wall clock": a call/result exchange is one unit — a tool result whose assistant call is missing heads a context every provider rejects — and its ids say so. 3. **Synthetic settlement needs no special case.** Crash recovery writes under already-reserved ids (§4.5), so synthetic responses and results carry exactly the ids their intents promised. **The opaque-payload contract.** Application- and model-visible content — custom entry `data`, `details` fields, `fact.custom` values, message text, hook `resumeData` — may embed entry ids. The harness never tracks such references: they are opaque payload, invisible to validation and recovery, and a retention mechanism may leave them dangling. Content that must remain resolvable is copied into the payload rather than referenced by id. -**Absolutes.** Entries and usage rows, once committed, are never deleted; the administrative precise rewrite (§2.9) is the sole sanctioned exception. A missing parent is always corruption — loud, never a clean stop. +**Absolutes.** Within a session, entries and usage rows, once committed, are never deleted; the administrative precise rewrite (§2.9) is the sole sanctioned exception (repository-level `delete()` removes whole sessions, §2.8). A missing parent is always corruption — loud, never a clean stop. ## 1.3 Register namespaces @@ -744,7 +729,8 @@ interface SessionMetadata { id: string; createdAt: number; /** Current storage schema version (Part 7). */ - storageVersion: number; + storageVersion: number; // starts at 1 for new format-4 sessions + cwd?: string; // working directory, when the application records one parentSessionId?: string; /** Only when a v3 parent path cannot be resolved to an available header id. */ legacyParentSessionPath?: string; @@ -757,7 +743,7 @@ interface SessionCodecOptions { interface SessionSearchOptions { text: string; cwd?: string } interface SessionSearchHit { - metadata: M; entryId: string; timestamp: string; snippet?: string; score?: number; + metadata: M; entryId: string; timestamp: number; snippet?: string; score?: number; } interface SessionSearch { search(options: SessionSearchOptions): Promise[]>; @@ -1082,6 +1068,8 @@ stateDiagram-v2 failure_drain --> terminal : inbox drained (failed) checkpoint --> terminal : abort reconciled (aborted) + compaction --> terminal : abort before structural commit (aborted) + failure_drain --> terminal : abort reconciled after writes drain (aborted) terminal --> [*] ``` @@ -1288,8 +1276,8 @@ Every queued admission mints the item's entry id (§1.2) and writes its payload | Public input | Admitted when | Transaction | |---|---|---| | `nextRun(msg)` | any state, including idle | `TX[ upsert pending.entry/{id} = payload, L(pendingNextRun += id) ]` — never starts a run | -| `steer(msg)` | active running run | `TX[ upsert pending.entry/{id} = payload, S(inbox.steer += id) ]` | -| `followUp(msg)` | active running run | `TX[ upsert pending.entry/{id} = payload, S(inbox.followUp += id) ]` | +| `steer(msg)` | open run with running control — including deferred suspension; under `cancel_requested` → `NoActiveRun` | `TX[ upsert pending.entry/{id} = payload, S(inbox.steer += id) ]` | +| `followUp(msg)` | open run with running control — including deferred suspension; under `cancel_requested` → `NoActiveRun` | `TX[ upsert pending.entry/{id} = payload, S(inbox.followUp += id) ]` | | tree write, run active | including suspended and cancelling | `TX[ upsert pending.entry/{id} = payload, S(inbox.writes += id) ]` — survives abort | | tree write, lane idle | idle | `TX[ insert entry, upsert lane.leaf ]` | | tree write, structural op open | — | wait for the operation to end, then re-evaluate | @@ -1508,7 +1496,7 @@ type Action = | { kind: "await_effect"; key: EffectKey } | { kind: "wait"; until: number; telemetryContext: TelemetryContext } | { kind: "suspend"; result: OperationResult } - | { kind: "done"; result: OperationResult }; + | { kind: "finish"; result: OperationResult }; async function drive(current: CurrentOperation, live: DriveState): Promise { while (true) { @@ -1576,8 +1564,11 @@ async function drive(current: CurrentOperation, live: DriveState): Promise; + /** The terminal transaction (§3.13): register deletes, lane.lastResult, + lane.state clear — plus any final entry/label writes the outcome carries + (§3.10). Conditional on op.state still being present at its expected seq; + undefined = externally finalized first (§4.9). Transition commits derive + their entry/usage writes from the state diff the same way. */ + commitTerminal(current: CurrentOperation, result: OperationResult): + Promise; /** Runs after_tool for the raw phase-two result selected in source order. */ finalizeTool(plan: Extract, output: Extract): @@ -1823,7 +1821,9 @@ A failed storage commit faults the whole harness. A faulted harness stops all ef An operation can end from outside its own drive: administrative force-kill tooling — or any future repairer (Part 6) — may commit the terminal transaction (§3.13), with or without synthetic settlements under the reserved ids, while a live drive still holds the operation in memory. The drive discovers this in exactly one way: a conditional commit or `reloadCurrent` finds the operation is no longer the lane's current operation — its registers are absent. -The rule: **the drive stops.** It pulls the operation signal so in-flight effects cancel, discards every in-memory result without writing — no register remains to own a settlement — emits the operation's end events, and resolves the live caller's promise from `lane.lastResult`, which the finalizing transaction wrote. It never re-creates registers, never commits a competing terminal transaction, and never treats the absence as corruption: absent `op.*` registers with a cleared `currentOperationId` is the ordinary post-terminal shape (§3.13). +The rule: **the drive stops.** It pulls the operation signal so in-flight effects cancel, discards every in-memory result without writing — no register remains to own a settlement — emits the operation's end events, and resolves the live caller's promise from `lane.lastResult`, which the finalizing transaction wrote (dereferencing `finalAssistantEntryId` to reconstruct `finalMessage` when present). + +On the shipping backends a finalizer is either in-process — an admin surface committing on the lane mutation line like any other job — or a separate process that first takes over the writer lease after close/crash. Every terminal transaction, the drive's own included, is conditional on `op.state` still existing at its expected seq, which is what makes invariant 21 (at most one terminal transaction per operation) hold under the race. It never re-creates registers, never commits a competing terminal transaction, and never treats the absence as corruption: absent `op.*` registers with a cleared `currentOperationId` is the ordinary post-terminal shape (§3.13). A suspended operation needs no drive to stop. The finalizer's terminal transaction leaves the lane idle; a later `resume()` finds `currentOperationId: null` and returns `NothingToResume`, and the application reads the outcome from `getLastResult()` (§5.1) — the same reconciliation path as any post-crash outcome. @@ -2537,7 +2537,7 @@ External output that violates durable JSON/schema contracts is converted before For each live tool batch, the harness resolves `toolContext` exactly once, caches bound `AgentHarnessTool` adapters in `DriveState.toolBatches`, and passes that same context as the fifth execute argument for every call. Safe replay after restart creates one new batch snapshot; context is environmental and never persisted. -`executeToolBatch` preserves the existing sequential/parallel behavior: source-ordered preparation and dispatch, concurrent effects in parallel mode, source-ordered finalization/results, no effect for blocked/invalid/genuine-length calls, and `terminate: true` only when every finalized outcome terminates. Compatibility wrappers keep existing public loop signatures and events. +`executeToolBatch` (the exported successor of the source's private `executeToolCalls`) preserves the existing sequential/parallel behavior: source-ordered preparation and dispatch, concurrent effects in parallel mode, source-ordered finalization/results, no effect for blocked/invalid/genuine-length calls, and `terminate: true` only when every finalized outcome terminates. Compatibility wrappers keep existing public loop signatures and events. ## 5.8 Telemetry From 9c75cd2f3a4a1f172b0a2f3cd99f40d8854b927c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:21:12 +0200 Subject: [PATCH 116/284] docs(agent): add transaction traces to orientation examples --- packages/agent/docs/harness-v3.md | 34 ++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index e97650115e0..2838512c36a 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -53,6 +53,25 @@ What happens, in order: 5. Tool calls follow the same intent → effect → settlement shape, one pair of commits each. 6. When the model stops without tool calls, a terminal transaction deletes the operation's registers, records the outcome in `lane.lastResult`, and leaves the lane idle. +As a trace (ids abbreviated; every `TX[...]` is one atomic commit): + +```text +TX[ insert entry n1 (user msg), upsert op.meta/O, upsert op.state/O = checkpoint, + upsert lane.leaf = n1, upsert lane.state = { currentOperationId: O } ] +TX[ upsert op.state/O = assistant ready (config snapshot) ] +TX[ upsert op.state/O = effect_pending (reserves response n2, usage u1) ] +… provider streams … ← the uncertain window +TX[ insert entry n2, insert usage u1, upsert lane.leaf = n2, + upsert op.state/O = tools (result id n3 reserved) ] +TX[ upsert op.tool_args/O:s1:0, upsert op.state/O = call 0 effect_pending ] +… tool runs … +TX[ insert entry n3, upsert lane.leaf = n3, upsert op.state/O = checkpoint ] +… second turn: ready · intent · stream · settle (n4, u2) … +TX[ delete op.meta/O, op.state/O, op.tool_args/O:*, + upsert lane.lastResult = { O, completed, n4 }, + upsert lane.state = { currentOperationId: null } ] +``` + Kill the process between any two of those transactions and restart. The harness reads the lane's registers, sees exactly which of those sentences was the last one committed, and continues. If it died in step 3, it knows a request may have been billed and may or may not have produced output — that is the one genuinely uncertain window in the whole system, and there is a stated policy for it. Meanwhile a second thread in the same channel is running its own lane, over the same 400 entries of shared history, with no coordination between them. @@ -65,7 +84,20 @@ lane.prompt("delete the stale migrations and run the test suite") The model returns two tool calls. The harness commits the batch plan, then commits `call 0 is about to execute, with these exact arguments, and it declares itself unsafe to replay`. The tool starts deleting files. The process is killed. -On restart the harness reads one register and finds `calls[0].status = "effect_pending", replay = "never"`. It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1. The conversation stays coherent — every tool call has a result — and nothing ran twice. +```text +TX[ insert entry n2 (assistant, 2 calls), insert usage u1, upsert lane.leaf = n2, + upsert op.state/O = tools (result ids n3, n4 reserved) ] +TX[ upsert op.tool_args/O:s1:0, upsert op.state/O = call 0 effect_pending, + replay: "never" ] +… tool deletes files … ← CRASH +``` + +On restart the harness reads one register and finds `calls[0].status = "effect_pending", replay = "never"`. It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1: + +```text +TX[ insert entry n3 (synthetic "interrupted" result), upsert lane.leaf = n3, + upsert op.state/O = call 0 completed ] +``` The conversation stays coherent — every tool call has a result — and nothing ran twice. Had the tool declared `replay: "safe"` (a read, a query), the harness would have re-executed it with the persisted arguments instead. From f132845654d07fcd83e51bce7f04a0feb499485e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:21:55 +0200 Subject: [PATCH 117/284] docs(agent): introduce the three stores before the worked examples --- packages/agent/docs/harness-v3.md | 64 +++++++++++++++---------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 2838512c36a..149a3d3351f 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -35,7 +35,35 @@ An **operation** is one accepted unit of lane work: a run, compaction, or naviga Below the session and harness, `Storage` exposes atomic transactions and queries over three durable forms: immutable entries, mutable registers, and append-only usage rows. Registers form a mutable, namespaced key-value store. Facts live there; internal harness namespaces durably store pending content and lane and operation state needed for crash recovery. In particular, `op.meta` is written once with an operation's metadata, while `op.state` is replaced after each transition with its complete current state. The terminal transaction deletes both and writes `lane.lastResult`. No partial transaction is visible. -## 0.3 Worked example — a Slack thread +## 0.3 The three stores + +Everything in Parts 1–5 follows from these. + +**1. Three stores, one invariant.** Everything durable is one of: + +```text +entries the conversation tree — write-once, append-only +registers current mutable state — namespaced typed cells, overwrite or delete +usage ledger cost history — append-only rows +``` + +*Every payload is in an entry, a register, or the ledger; there is no third place.* An entry is the complete conversation record — placement and payload in one row. A register holds its current typed value directly; overwriting discards the old value, and deletion removes the key. Content that durably exists before it has a place in the tree (queued input, deferred writes) waits in a `pending.entry` register and becomes an entry in the transaction that places it. Per-backend projections — branch index, full-text search, stats — are rebuildable from the three stores and carry no authority. + +**2. Atomic transactions.** A transaction is a set of entry inserts, usage inserts, and register writes (set or delete), committed all-or-none with strictly increasing sequence numbers. There is no crash state inside a transaction. This is the only write primitive. + +**3. The durable program counter.** After every step, the harness overwrites one register — `op.state/{operationId}` — with the *complete* current state of the operation. Recovery does not replay a journal or infer position from what is missing; it reads that register and switches on it. The state is *total* — it never depends on a previous state. Small captured values (configuration, stream options, retry policy) are inline; large stable payloads live in sibling `op.*` registers or are named by id. When the operation ends, the terminal transaction deletes its registers: a finished session holds exactly the conversation, the ledger, and a handful of lane and fact registers. There is no dead state to collect. + +**4. The effect sandwich.** Provider requests and real tool calls are wrapped in two commits: + +``` +commit: "about to do X; its output will use ids R and U" ← intent + do X ← the uncertain part +commit: output + usage + next state ← settlement +``` + +Hooks follow their replay contract instead: a result becomes durable in the transaction that consumes it, and a crash before that transaction may rerun the hook. Thus every external effect can still happen without durable settlement. Provider/tool intents make that uncertainty explicit where replay policy depends on it; idempotent hooks accept it as a non-goal. + +## 0.4 Worked example — a Slack thread A user posts in a channel that already has 400 entries of history. The application creates a lane for the thread, anchored at the channel's current leaf. Entry ids are UUIDv7s (§1.2); examples abbreviate them. @@ -76,7 +104,7 @@ Kill the process between any two of those transactions and restart. The harness Meanwhile a second thread in the same channel is running its own lane, over the same 400 entries of shared history, with no coordination between them. -## 0.4 Worked example — a crash mid-tool +## 0.5 Worked example — a crash mid-tool ``` lane.prompt("delete the stale migrations and run the test suite") @@ -101,34 +129,6 @@ TX[ insert entry n3 (synthetic "interrupted" result), upsert lane.leaf = n3, Had the tool declared `replay: "safe"` (a read, a query), the harness would have re-executed it with the persisted arguments instead. -## 0.5 The three stores - -Everything in Parts 1–5 follows from these. - -**1. Three stores, one invariant.** Everything durable is one of: - -```text -entries the conversation tree — write-once, append-only -registers current mutable state — namespaced typed cells, overwrite or delete -usage ledger cost history — append-only rows -``` - -*Every payload is in an entry, a register, or the ledger; there is no third place.* An entry is the complete conversation record — placement and payload in one row. A register holds its current typed value directly; overwriting discards the old value, and deletion removes the key. Content that durably exists before it has a place in the tree (queued input, deferred writes) waits in a `pending.entry` register and becomes an entry in the transaction that places it. Per-backend projections — branch index, full-text search, stats — are rebuildable from the three stores and carry no authority. - -**2. Atomic transactions.** A transaction is a set of entry inserts, usage inserts, and register writes (set or delete), committed all-or-none with strictly increasing sequence numbers. There is no crash state inside a transaction. This is the only write primitive. - -**3. The durable program counter.** After every step, the harness overwrites one register — `op.state/{operationId}` — with the *complete* current state of the operation. Recovery does not replay a journal or infer position from what is missing; it reads that register and switches on it. The state is *total* — it never depends on a previous state. Small captured values (configuration, stream options, retry policy) are inline; large stable payloads live in sibling `op.*` registers or are named by id. When the operation ends, the terminal transaction deletes its registers: a finished session holds exactly the conversation, the ledger, and a handful of lane and fact registers. There is no dead state to collect. - -**4. The effect sandwich.** Provider requests and real tool calls are wrapped in two commits: - -``` -commit: "about to do X; its output will use ids R and U" ← intent - do X ← the uncertain part -commit: output + usage + next state ← settlement -``` - -Hooks follow their replay contract instead: a result becomes durable in the transaction that consumes it, and a crash before that transaction may rerun the hook. Thus every external effect can still happen without durable settlement. Provider/tool intents make that uncertainty explicit where replay policy depends on it; idempotent hooks accept it as a non-goal. - ## 0.6 Non-goals - **Exactly-once external effects.** See above. Hooks with their own side effects must be idempotent, keyed by operation id. @@ -1385,7 +1385,7 @@ TX[ { currentOperationId: "op_9" } From b75be04d92fa1919eeb92fecc3d6051e745922cf Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:27:48 +0200 Subject: [PATCH 118/284] refactor: search (#7797) * refactor: search out of repo * fix: apply cases, no resolve for index on every hit * chore: tests * fix: use iterator and no open * refactor: public dir stuff in jsonl, source * cleanup and sqlite time number * fix: use storage for label search too, delete types * refactor: abstract some more * docs: example jsonl with elastic search * docs: Christian's md * refactor: simpler api surface * feat: rebuild * docs: consolidate * fix: use trigger for sqlite, cleanup indexable, add example in doc * chore: restore upstream responses shared * fix: delete metadata from jsonl search hit * fix: AsynIterable * refactor: scanning for memory, reuse * fix: dont export jsonlSessionDirectoryName * cleanup: more shared stuff * cleanup: why, jsonl? --- packages/agent/docs/search.md | 276 ++++++++++++++++++ .../agent/src/harness/session/jsonl/repo.ts | 122 +++++--- packages/agent/src/harness/session/search.ts | 71 ----- packages/agent/src/index.ts | 2 +- packages/agent/src/search/index.ts | 32 ++ packages/agent/src/search/scanning.ts | 176 +++++++++++ .../agent/test/harness/session/search.test.ts | 125 ++++++++ .../session-backends/sqlite-node/README.md | 13 +- .../session-backends/sqlite-node/src/index.ts | 9 + .../sqlite-node/src/sqlite/search-backend.ts | 122 +++++--- .../sqlite-node/src/sqlite/sql.ts | 4 + .../sqlite-node/src/sqlite/types.ts | 1 + .../sqlite-node/test/repository.test.ts | 2 + .../sqlite-node/test/search.test.ts | 176 +++++++++-- 14 files changed, 949 insertions(+), 182 deletions(-) create mode 100644 packages/agent/docs/search.md delete mode 100644 packages/agent/src/harness/session/search.ts create mode 100644 packages/agent/src/search/index.ts create mode 100644 packages/agent/src/search/scanning.ts create mode 100644 packages/agent/test/harness/session/search.test.ts diff --git a/packages/agent/docs/search.md b/packages/agent/docs/search.md new file mode 100644 index 00000000000..97f94770f39 --- /dev/null +++ b/packages/agent/docs/search.md @@ -0,0 +1,276 @@ +# Session Search + +Pi search is a small query interface over committed session entries. The shared contract returns only stable hit identity; implementations may extend hits with backend-specific display data. + +## Core API + +```ts +export interface SessionSearchHit { + /** Logical identifier of the session that owns the entry. */ + readonly sessionId: string; + + /** Logical identifier of the entry within that session. */ + readonly entryId: string; +} + +export interface SessionSearchOptions { + /** Restrict results to specific canonical entry types. */ + readonly entryTypes?: readonly Entry["type"][]; + + /** Maximum number of hits to return. Backends may return fewer, not more. */ + readonly limit?: number; + + /** Abort signal for cancellation, e.g. search-as-you-type. */ + readonly signal?: AbortSignal; +} + +export interface SessionSearch { + search(text: string, options?: SessionSearchOptions): AsyncIterable; +} +``` + +The base hit is intentionally minimal: `(sessionId, entryId)` is the portable identity across JSONL, memory, SQLite FTS, and remote indexes. Snippets, timestamps, scores, metadata, offsets, and ranking semantics belong to concrete implementations. + +## Why async iterable + +`AsyncIterable` lets consumers render early results, stop iteration when they have enough, and cancel in-flight work with `AbortSignal`. Debouncing remains a UI/caller concern; the API only provides the cancellation primitive. + +```ts +let currentAbortController: AbortController | undefined; + +async function updateResults(query: string) { + currentAbortController?.abort(); + const controller = new AbortController(); + currentAbortController = controller; + + try { + for await (const hit of search.search(query, { limit: 10, signal: controller.signal })) { + render(hit); + } + } catch (error) { + if (!(error instanceof Error) || error.name !== "AbortError") throw error; + } +} +``` + +## Default implementations + +### Scanning search + +The reusable scanner adapts session-like readables (`getMetadata`, `findEntries`, and `getLabel`) into projected entries: + +```ts +export interface SessionSearchCandidate { + readonly entryId: string; + readonly seq: number; + readonly type: Entry["type"]; + readonly timestamp: number; + readonly text: string; + readonly fields?: Record; +} + +export interface ScanningSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; +} +``` + +`SessionSearchCandidate` is pre-match scanner input: it contains searchable text, type, sequence, and optional projected fields. The scanner turns matching candidates into public hits. + +Already-open sessions or storages can be scanned directly: + +```ts +const search = createScanningSessionSearch(sessions); + +for await (const hit of search.search("authentication", { limit: 10 })) { + const session = sessionsById.get(hit.sessionId)!; + const entry = await session.getEntry(hit.entryId); + console.log(entry); +} +``` + +JSONL does not need a separate public search adapter. JSONL-backed code can keep discovery/loading local, then pass the loaded storages to the same scanner: + +```ts +async function* jsonlReadables(jsonl: JsonlSessionRepoOptions, query: JsonlSessionListOptions = {}) { + for (const metadata of await listJsonlSessionMetadata(jsonl, query)) { + yield loadJsonlSessionStorage(jsonl, metadata); + } +} + +const search = createScanningSessionSearch((query) => jsonlReadables(jsonl, query)); +``` + +A scanning source must not call `SessionRepo.open()` on a harness-owned session if that operation may claim a writer lease. JSONL should use read-only loading helpers; already-open sessions/storages can be scanned directly. + +### SQLite FTS + +SQLite search exposes an extended hit: + +```ts +export interface SqliteSessionSearchHit extends SessionSearchHit { + readonly metadata: SqliteSessionMetadata; + readonly timestamp: number; + readonly score: number; +} +``` + +```ts +const search = createSqliteSessionSearch({ env, sqlite, databasePath }); + +for await (const hit of search.search("auth", { + entryTypes: ["message", "compaction"], + limit: 20, +})) { + console.log(hit.sessionId, hit.entryId, hit.score); +} +``` + +The FTS table and triggers are created lazily on first non-blank search. When FTS is first created, SQLite performs a one-time rebuild from canonical `entries`; after that, SQLite triggers keep FTS in sync with canonical entry inserts, deletes, and payload updates. This makes SQLite search fresh after commit, but it also means FTS trigger failures can roll back canonical SQLite writes while search is enabled for that database. + +## Indexed backends + +Search indexing is backend-owned derived state. The shared package only exports the query API; applications or backend packages may define their own writer/feed contracts when they need explicit index maintenance. + +### JSONL sessions with Elasticsearch + +This is application-owned glue. Core provides the query contract and JSONL session discovery; the Elastic writer contract is local to this adapter. + +```ts +import { Client } from "@elastic/elasticsearch"; +import { + scanningEntries, + type JsonlSessionMetadata, + type JsonlSessionRepoOptions, + type SessionSearch, + type SessionSearchHit, + type SessionSearchOptions, +} from "@earendil-works/pi-agent-core"; + +// JSONL-backed code can provide this locally from existing JSONL list/load helpers. +async function* jsonlReadables(jsonl: JsonlSessionRepoOptions, options: { cwd?: string } = {}) { + for (const metadata of await listJsonlSessionMetadata(jsonl, options)) { + yield loadJsonlSessionStorage(jsonl, metadata); + } +} + +interface SearchIndexWriter { + apply(items: TItem[]): Promise; + flush?(): Promise; +} + +interface IndexedSessionSearch + extends SessionSearch, SearchIndexWriter {} + +type ElasticSessionFeedItem = + | { type: "upsert"; id: string; body: ElasticSessionDoc } + | { type: "delete"; id: string }; + +interface ElasticSessionDoc { + sessionId: string; + entryId: string; + seq: number; + timestamp: number; + cwd: string; + text: string; + metadata: JsonlSessionMetadata; + fields?: Record; +} + +interface ElasticSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; + readonly score?: number; +} + +class ElasticSessionSearch + implements IndexedSessionSearch +{ + constructor( + private readonly client: Client, + private readonly index: string, + ) {} + + async apply(items: ElasticSessionFeedItem[]): Promise { + const operations = items.flatMap((item) => { + if (item.type === "delete") { + return [{ delete: { _index: this.index, _id: item.id } }]; + } + return [{ index: { _index: this.index, _id: item.id } }, item.body]; + }); + + if (operations.length > 0) await this.client.bulk({ operations }); + } + + async flush(): Promise { + await this.client.indices.refresh({ index: this.index }); + } + + async *search( + text: string, + options: SessionSearchOptions = {}, + ): AsyncIterable { + const result = await this.client.search({ + index: this.index, + size: options.limit ?? 20, + query: { + bool: { + must: [{ match: { text } }], + }, + }, + }); + + for (const hit of result.hits.hits) { + if (!hit._source) continue; + if (options.signal?.aborted) throw options.signal.reason; + yield { + sessionId: hit._source.sessionId, + entryId: hit._source.entryId, + timestamp: hit._source.timestamp, + snippet: hit._source.text, + score: hit._score ?? undefined, + }; + } + } +} +``` + +A catch-up/rebuild job can feed JSONL projections into Elasticsearch without taking a writer lease: + +```ts +async function indexJsonlSessionsIntoElastic( + jsonl: JsonlSessionRepoOptions, + elastic: ElasticSessionSearch, + options: { cwd?: string } = {}, +): Promise { + for await (const session of jsonlReadables(jsonl, { cwd: options.cwd })) { + const metadata = await session.getMetadata(); + for await (const candidate of scanningEntries(session)) { + await elastic.apply([{ + type: "upsert", + id: `${metadata.id}:${candidate.entryId}`, + body: { + sessionId: metadata.id, + entryId: candidate.entryId, + seq: candidate.seq, + timestamp: candidate.timestamp, + cwd: metadata.cwd, + text: candidate.text, + metadata, + fields: candidate.fields, + }, + }]); + } + } + + await elastic.flush(); +} +``` + +## Correctness and failure boundaries + +Search indexes are derived state for the shared API: applications can retry, rebuild, or mark search stale. Backend-specific choices may make different tradeoffs; SQLite FTS uses co-located triggers, so FTS failures can roll back canonical SQLite writes after search has initialized the triggers. + +Scanning sources should fail fast if they yield duplicate `sessionId` values, because base hit identity is `(sessionId, entryId)`. Indexed backends usually enforce uniqueness in their storage/index layer. + +Search opt-in still needs a sync/indexing layer. A follow-up should add a no-op-by-default search index sink (for example `NOOP_SEARCH_INDEX_SINK`) so canonical write sites can emit indexing events unconditionally, similar to how telemetry uses no-op implementations when telemetry is disabled. diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index 98bb12ddde0..bf8bb4874cc 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -24,10 +24,83 @@ function validateSessionId(id: string): void { } } -function sessionDirectoryName(cwd: string): string { +function jsonlSessionDirectoryName(cwd: string): string { return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } +async function jsonlSessionsRoot(options: JsonlSessionRepoOptions): Promise { + return fileResult( + await options.fs.absolutePath(options.sessionsRoot), + `Failed to resolve sessions root ${options.sessionsRoot}`, + ); +} + +async function jsonlSessionDirectory( + fs: JsonlSessionRepoFileSystem, + sessionsRoot: string, + cwd: string, +): Promise { + return fileResult( + await fs.joinPath([sessionsRoot, jsonlSessionDirectoryName(cwd)]), + `Failed to resolve sessions directory for ${cwd}`, + ); +} + +async function jsonlSessionDirectories(options: JsonlSessionRepoOptions, cwd?: string): Promise { + const sessionsRoot = await jsonlSessionsRoot(options); + if (cwd !== undefined) { + const resolvedCwd = fileResult(await options.fs.absolutePath(cwd), `Failed to resolve session cwd ${cwd}`); + const directory = await jsonlSessionDirectory(options.fs, sessionsRoot, resolvedCwd); + return fileResult(await options.fs.exists(directory), `Failed to check sessions directory ${directory}`) + ? [directory] + : []; + } + if (!fileResult(await options.fs.exists(sessionsRoot), `Failed to check sessions directory ${sessionsRoot}`)) + return []; + return fileResult(await options.fs.listDir(sessionsRoot), `Failed to list sessions directory ${sessionsRoot}`) + .filter((entry) => entry.kind === "directory" || entry.kind === "symlink") + .map((entry) => entry.path); +} + +export async function listJsonlSessionMetadata( + options: JsonlSessionRepoOptions, + query: JsonlSessionListOptions = {}, +): Promise { + const metadata: JsonlSessionMetadata[] = []; + for (const directory of await jsonlSessionDirectories(options, query.cwd)) { + const files = fileResult( + await options.fs.listDir(directory), + `Failed to list sessions directory ${directory}`, + ).filter((entry) => entry.kind !== "directory" && entry.name.endsWith(".jsonl")); + for (const file of files) { + const [firstLine] = fileResult( + await options.fs.readTextLines(file.path, { maxLines: 1 }), + `Failed to read session header ${file.path}`, + ); + if (!firstLine) continue; + const headerResult = parseHeader(firstLine); + if (!headerResult.ok) continue; + metadata.push(metadataFromHeader(headerResult.value, file.path, file.mtimeMs)); + } + } + return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); +} + +export async function loadJsonlSessionStorage( + options: JsonlSessionRepoOptions, + metadata: JsonlSessionMetadata, +): Promise { + if (!fileResult(await options.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)) { + throw new SessionError("not_found", `Session not found: ${metadata.id}`); + } + const storage = await JsonlSessionStorage.load(options.fs, metadata.path); + const loadedMetadata = await storage.getMetadata(); + if (loadedMetadata.id !== metadata.id) { + throw new SessionError("invalid_entry", `Session id does not match header: ${metadata.id}`); + } + return storage; +} + function sessionFileName(createdAt: number, id: string): string { const timestamp = new Date(createdAt).toISOString().replace(/[:.]/g, "-"); return `${timestamp}_${id}.jsonl`; @@ -83,15 +156,7 @@ export class JsonlSessionRepo } private async loadStorage(metadata: JsonlSessionMetadata): Promise { - if (!fileResult(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)) { - throw new SessionError("not_found", `Session not found: ${metadata.id}`); - } - const storage = await JsonlSessionStorage.load(this.fs, metadata.path); - const loadedMetadata = await storage.getMetadata(); - if (loadedMetadata.id !== metadata.id) { - throw new SessionError("invalid_entry", `Session id does not match header: ${metadata.id}`); - } - return storage; + return loadJsonlSessionStorage({ fs: this.fs, sessionsRoot: this.sessionsRootInput }, metadata); } private async resolveCreateDestination(options: JsonlSessionCreateOptions): Promise<{ id: string; cwd: string }> { @@ -155,25 +220,7 @@ export class JsonlSessionRepo } private async listDirect(options: JsonlSessionListOptions): Promise { - const directories = await this.sessionDirectories(options.cwd); - const metadata: JsonlSessionMetadata[] = []; - for (const directory of directories) { - const files = fileResult( - await this.fs.listDir(directory), - `Failed to list sessions directory ${directory}`, - ).filter((entry) => entry.kind !== "directory" && entry.name.endsWith(".jsonl")); - for (const file of files) { - const [firstLine] = fileResult( - await this.fs.readTextLines(file.path, { maxLines: 1 }), - `Failed to read session header ${file.path}`, - ); - if (!firstLine) continue; - const headerResult = parseHeader(firstLine); - if (!headerResult.ok) continue; - metadata.push(metadataFromHeader(headerResult.value, file.path, file.mtimeMs)); - } - } - return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); + return listJsonlSessionMetadata({ fs: this.fs, sessionsRoot: this.sessionsRootInput }, options); } private async sessionIdExists(id: string, cwd: string): Promise { @@ -184,24 +231,9 @@ export class JsonlSessionRepo return files.some((entry) => entry.kind !== "directory" && entry.name.endsWith(suffix)); } - private async sessionDirectories(cwd?: string): Promise { - const root = await this.root(); - if (cwd !== undefined) { - const resolvedCwd = fileResult(await this.fs.absolutePath(cwd), `Failed to resolve session cwd ${cwd}`); - const directory = await this.sessionDirectory(resolvedCwd); - return fileResult(await this.fs.exists(directory), `Failed to check sessions directory ${directory}`) - ? [directory] - : []; - } - if (!fileResult(await this.fs.exists(root), `Failed to check sessions directory ${root}`)) return []; - return fileResult(await this.fs.listDir(root), `Failed to list sessions directory ${root}`) - .filter((entry) => entry.kind === "directory" || entry.kind === "symlink") - .map((entry) => entry.path); - } - private async sessionDirectory(cwd: string): Promise { return fileResult( - await this.fs.joinPath([await this.root(), sessionDirectoryName(cwd)]), + await this.fs.joinPath([await this.root(), jsonlSessionDirectoryName(cwd)]), `Failed to resolve sessions directory for ${cwd}`, ); } diff --git a/packages/agent/src/harness/session/search.ts b/packages/agent/src/harness/session/search.ts deleted file mode 100644 index d75a606eb99..00000000000 --- a/packages/agent/src/harness/session/search.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { FileError, Result } from "../types.ts"; -import type { Session } from "./session.ts"; -import { type SessionCreateOptions, SessionError, type SessionMetadata, type SessionRepo } from "./types.ts"; - -export interface SessionSearchOptions { - text: string; - cwd?: string; -} - -export interface SessionSearchHit { - metadata: TMetadata; - entryId: string; - timestamp: number; - snippet?: string; - score?: number; -} - -export interface SessionSearch { - search(options: SessionSearchOptions): Promise[]>; -} - -export function getFileSystemResultOrThrow(result: Result, message: string): TValue { - if (!result.ok) { - const code = result.error.code === "not_found" ? "not_found" : "storage"; - throw new SessionError(code, `${message}: ${result.error.message}`, result.error); - } - return result.value; -} - -type ScanningSessionSearchSource = { - list(): Promise; - open(metadata: TMetadata): Promise>; -}; - -class ScanningSessionSearch implements SessionSearch { - private readonly source: ScanningSessionSearchSource; - - constructor(source: ScanningSessionSearchSource) { - this.source = source; - } - - async search(options: SessionSearchOptions): Promise[]> { - const normalizedText = options.text.trim().toLowerCase(); - if (!normalizedText) return []; - const hits: SessionSearchHit[] = []; - for (const metadata of await this.source.list()) { - const cwd = (metadata as { cwd?: unknown }).cwd; - if (options.cwd !== undefined && cwd !== options.cwd) continue; - const session = await this.source.open(metadata); - for (const entry of await session.findEntries({ order: "oldestFirst" })) { - const payload = JSON.stringify(entry); - if (!payload.toLowerCase().includes(normalizedText)) continue; - hits.push({ - metadata, - entryId: entry.id, - timestamp: entry.timestamp, - snippet: payload, - }); - } - } - return hits; - } -} - -export function createScanningSessionSearch< - TMetadata extends SessionMetadata, - TCreateOptions extends SessionCreateOptions, - TListOptions, ->(source: Pick, "list" | "open">): SessionSearch { - return new ScanningSessionSearch(source); -} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index b1315b68b67..109cb05008d 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -78,7 +78,6 @@ export * from "./harness/prompt-templates.ts"; // Harness export * from "./harness/result.ts"; export * from "./harness/session/index.ts"; -export * from "./harness/session/search.ts"; export * from "./harness/skills.ts"; export * from "./harness/system-prompt.ts"; export type { @@ -139,6 +138,7 @@ export * from "./harness/utils/shell-output.ts"; export * from "./harness/utils/truncate.ts"; // Proxy utilities export * from "./proxy.ts"; +export * from "./search/index.ts"; // Stream defaults export { setDefaultStreamFn } from "./stream-fn.ts"; // Types diff --git a/packages/agent/src/search/index.ts b/packages/agent/src/search/index.ts new file mode 100644 index 00000000000..73584789545 --- /dev/null +++ b/packages/agent/src/search/index.ts @@ -0,0 +1,32 @@ +import type { Entry } from "../harness/session/types.ts"; + +export type { + ScanningReadable, + ScanningReadableOptions, + ScanningReadableSource, + ScanningSearchTextProjector, + ScanningSessionSearchHit, + ScanningSessionSearchOptions, + SessionSearchCandidate, +} from "./scanning.ts"; +export { createScanningSessionSearch, scanningEntries } from "./scanning.ts"; + +export interface SessionSearchOptions { + /** Restrict results to specific canonical entry types. */ + readonly entryTypes?: readonly Entry["type"][]; + /** Maximum number of hits to return. */ + readonly limit?: number; + /** Abort signal for cancellation, e.g. search-as-you-type. */ + readonly signal?: AbortSignal; +} + +export interface SessionSearchHit { + /** Logical identifier of the session that owns the entry. */ + readonly sessionId: string; + /** Logical identifier of the entry within that session. */ + readonly entryId: string; +} + +export interface SessionSearch { + search(text: string, options?: SessionSearchOptions): AsyncIterable; +} diff --git a/packages/agent/src/search/scanning.ts b/packages/agent/src/search/scanning.ts new file mode 100644 index 00000000000..14e6bfbf26c --- /dev/null +++ b/packages/agent/src/search/scanning.ts @@ -0,0 +1,176 @@ +import type { Entry, SessionMetadata, SessionStorage } from "../harness/session/types.ts"; +import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "./index.ts"; + +export interface SessionSearchCandidate { + readonly entryId: string; + readonly seq: number; + readonly type: Entry["type"]; + readonly timestamp: number; + readonly text: string; + readonly fields?: Record; +} + +export type ScanningReadable = Pick< + SessionStorage, + "getMetadata" | "findEntries" | "getLabel" +>; + +export type ScanningReadableSource = ( + options?: TOptions, +) => AsyncIterable>; + +export type ScanningSearchTextProjector = ( + metadata: TMetadata, + entry: Entry, + label: string | undefined, +) => string; + +export interface ScanningReadableOptions { + projectText?: ScanningSearchTextProjector; + pageSize?: number; +} + +export interface ScanningSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; +} + +export interface ScanningSessionSearchOptions< + TMetadata extends SessionMetadata = SessionMetadata, + TSourceOptions = unknown, + THit extends SessionSearchHit = ScanningSessionSearchHit, +> extends ScanningReadableOptions { + sourceOptions?: (text: string, options: SessionSearchOptions) => TSourceOptions | undefined; + match?: (queryText: string, candidate: SessionSearchCandidate, metadata: TMetadata) => boolean; + createHit?: (metadata: TMetadata, candidate: SessionSearchCandidate) => THit; +} + +function defaultSearchText( + _metadata: TMetadata, + entry: Entry, + label: string | undefined, +): string { + return label === undefined ? JSON.stringify(entry) : `${JSON.stringify(entry)} ${label}`; +} + +async function* scanReadableEntries( + readable: ScanningReadable, + metadata: TMetadata, + options: ScanningReadableOptions, + query: { afterSeq?: number; limit?: number; entryTypes?: readonly Entry["type"][] } = {}, +): AsyncIterable { + const projectText = options.projectText ?? defaultSearchText; + const pageSize = query.limit ?? options.pageSize ?? 100; + let afterSeq = query.afterSeq ?? 0; + const entryTypes = query.entryTypes === undefined ? undefined : new Set(query.entryTypes); + while (true) { + const entries = await readable.findEntries({ + order: "oldestFirst", + limit: pageSize, + cursor: { afterSeq }, + type: query.entryTypes?.length === 1 ? query.entryTypes[0] : undefined, + }); + if (entries.length === 0) break; + for (const entry of entries) { + if (entryTypes !== undefined && !entryTypes.has(entry.type)) continue; + const label = await readable.getLabel(entry.id); + yield { + entryId: entry.id, + seq: entry.seq, + type: entry.type, + timestamp: entry.timestamp, + text: projectText(metadata, entry, label), + fields: label === undefined ? undefined : { label }, + }; + } + afterSeq = entries[entries.length - 1]?.seq ?? afterSeq; + if (entries.length < pageSize) break; + } +} + +export async function* scanningEntries( + readable: ScanningReadable, + options: ScanningReadableOptions = {}, +): AsyncIterable { + yield* scanReadableEntries(readable, await readable.getMetadata(), options); +} + +async function* arraySource( + readables: readonly ScanningReadable[], +): AsyncIterable> { + yield* readables; +} + +function readablesFor( + source: readonly ScanningReadable[] | ScanningReadableSource, + options: TSourceOptions | undefined, +): AsyncIterable> { + return typeof source === "function" ? source(options) : arraySource(source); +} + +function defaultMatch(queryText: string, candidate: SessionSearchCandidate): boolean { + return candidate.text.toLowerCase().includes(queryText); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + throw error; +} + +function createDefaultScanningHit( + metadata: TMetadata, + candidate: SessionSearchCandidate, +): ScanningSessionSearchHit { + return { + sessionId: metadata.id, + entryId: candidate.entryId, + timestamp: candidate.timestamp, + snippet: candidate.text, + }; +} + +export function createScanningSessionSearch< + TMetadata extends SessionMetadata, + TSourceOptions = unknown, + THit extends SessionSearchHit = ScanningSessionSearchHit, +>( + source: readonly ScanningReadable[] | ScanningReadableSource, + options: ScanningSessionSearchOptions = {}, +): SessionSearch { + const createHit = + options.createHit ?? + ((metadata: TMetadata, candidate: SessionSearchCandidate) => + createDefaultScanningHit(metadata, candidate) as unknown as THit); + return { + async *search(text: string, searchOptions: SessionSearchOptions = {}): AsyncIterable { + const normalizedText = text.trim().toLowerCase(); + if (!normalizedText || (searchOptions.limit !== undefined && searchOptions.limit <= 0)) return; + if (searchOptions.entryTypes?.length === 0) return; + let hitCount = 0; + const seenSessionIds = new Set(); + const entryTypes = searchOptions.entryTypes === undefined ? undefined : new Set(searchOptions.entryTypes); + const sourceOptions = options.sourceOptions?.(normalizedText, searchOptions); + for await (const readable of readablesFor(source, sourceOptions)) { + throwIfAborted(searchOptions.signal); + const metadata = await readable.getMetadata(); + if (seenSessionIds.has(metadata.id)) throw new Error(`Duplicate sessionId: ${metadata.id}`); + seenSessionIds.add(metadata.id); + for await (const candidate of scanReadableEntries(readable, metadata, options, { + entryTypes: searchOptions.entryTypes, + })) { + throwIfAborted(searchOptions.signal); + if (entryTypes !== undefined && !entryTypes.has(candidate.type)) continue; + const matches = + options.match?.(normalizedText, candidate, metadata) ?? defaultMatch(normalizedText, candidate); + if (!matches) continue; + yield createHit(metadata, candidate); + hitCount += 1; + if (searchOptions.limit !== undefined && hitCount >= searchOptions.limit) return; + } + } + }, + }; +} diff --git a/packages/agent/test/harness/session/search.test.ts b/packages/agent/test/harness/session/search.test.ts new file mode 100644 index 00000000000..883f583964d --- /dev/null +++ b/packages/agent/test/harness/session/search.test.ts @@ -0,0 +1,125 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts"; +import { + InMemorySessionStorage, + type JsonlSessionListOptions, + JsonlSessionRepo, + type JsonlSessionRepoOptions, + Session, + type SessionMetadata, + type SessionStorage, +} from "../../../src/harness/session/index.ts"; +import { listJsonlSessionMetadata, loadJsonlSessionStorage } from "../../../src/harness/session/jsonl/repo.ts"; +import { createScanningSessionSearch } from "../../../src/search/index.ts"; +import type { AgentMessage } from "../../../src/types.ts"; + +interface WorkspaceMetadata extends SessionMetadata { + cwd: string; +} + +const tempDirs: string[] = []; + +function createTempDir(): string { + const directory = mkdtempSync(join(tmpdir(), "pi-agent-search-")); + tempDirs.push(directory); + return directory; +} + +afterEach(() => { + while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true }); +}); + +function message(text: string): AgentMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +function createMemorySession(metadata: WorkspaceMetadata): Session { + return new Session( + new InMemorySessionStorage(metadata) as unknown as SessionStorage, + ); +} + +async function collect(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) items.push(item); + return items; +} + +async function* jsonlReadables(options: JsonlSessionRepoOptions, query: JsonlSessionListOptions = {}) { + for (const metadata of await listJsonlSessionMetadata(options, query)) { + yield loadJsonlSessionStorage(options, metadata); + } +} + +describe("session search", () => { + it("scans an arbitrary in-memory projected source", async () => { + const root = createMemorySession({ id: "root", createdAt: 1, cwd: "/repo" }); + await root.appendMessage(message("fix auth flow")); + const other = createMemorySession({ id: "other", createdAt: 2, cwd: "/other" }); + await other.appendMessage(message("auth in another workspace")); + const search = createScanningSessionSearch([root, other]); + + expect("apply" in search).toBe(false); + expect(await collect(search.search("auth"))).toMatchObject([{ sessionId: "root" }, { sessionId: "other" }]); + expect(await collect(search.search("missing"))).toEqual([]); + }); + + it("includes labels in memory scanning projections", async () => { + const session = createMemorySession({ id: "session", createdAt: 1, cwd: "/repo" }); + const entryId = await session.appendMessage(message("plain body")); + await session.setLabel(entryId, "important label"); + const search = createScanningSessionSearch([session]); + + expect(await collect(search.search("important"))).toMatchObject([{ sessionId: "session", entryId }]); + }); + + it("honors entry type filters and abort signals in scanning search", async () => { + const session = createMemorySession({ id: "session", createdAt: 1, cwd: "/repo" }); + const messageEntryId = await session.appendMessage(message("auth message")); + await session.appendCustomEntry("note", { text: "auth custom" }); + const search = createScanningSessionSearch([session]); + + expect(await collect(search.search("auth", { entryTypes: ["message"] }))).toMatchObject([ + { sessionId: "session", entryId: messageEntryId }, + ]); + + const controller = new AbortController(); + controller.abort(); + await expect(collect(search.search("auth", { signal: controller.signal }))).rejects.toMatchObject({ + name: "AbortError", + }); + }); + + it("scans JSONL sessions from disk through the JSONL scanning source", async () => { + const root = createTempDir(); + const options = { fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }; + const repository = new JsonlSessionRepo(options); + const cwd = join(root, "workspace"); + const otherCwd = join(root, "other"); + const session = await repository.create({ id: "jsonl", cwd }); + const entryId = await session.appendMessage(message("jsonl backed auth entry")); + await session.setLabel(entryId, "disk label"); + const other = await repository.create({ id: "other", cwd: otherCwd }); + const otherEntryId = await other.appendMessage(message("jsonl backed auth entry in another cwd")); + const search = createScanningSessionSearch((query?: JsonlSessionListOptions) => jsonlReadables(options, query)); + + const authHits = await collect(search.search("auth")); + expect(authHits).toHaveLength(2); + expect(authHits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "jsonl", + entryId, + }), + expect.objectContaining({ + sessionId: "other", + entryId: otherEntryId, + }), + ]), + ); + expect(await collect(search.search("disk"))).toMatchObject([{ sessionId: "jsonl", entryId }]); + }); +}); diff --git a/packages/session-backends/sqlite-node/README.md b/packages/session-backends/sqlite-node/README.md index 40de9f804b4..e56f205f37f 100644 --- a/packages/session-backends/sqlite-node/README.md +++ b/packages/session-backends/sqlite-node/README.md @@ -8,8 +8,15 @@ migrations, materialized views, and optional FTS search. await using repository = new SqliteSessionRepository(options); const search = createSqliteSessionSearch(options); const session = await repository.create({ cwd }); -const hits = await search.search({ text: "needle" }); +await session.appendMessage(message); + +const hits = []; +for await (const hit of search.search("needle")) hits.push(hit); ``` -The repository lazily owns one shared database connection. Search is an independent, -query-only projection over the same canonical database. +The repository lazily owns one shared database connection. Search is an independent +service over the same canonical database: repositories do not expose `search()`. +The FTS table and triggers are created lazily on the first non-blank search; when +FTS is first created, search performs a one-time rebuild from canonical entries. +After that, SQLite triggers keep FTS in sync with canonical entry inserts, deletes, +and payload updates. diff --git a/packages/session-backends/sqlite-node/src/index.ts b/packages/session-backends/sqlite-node/src/index.ts index 83552d1cc2f..14bd887c9f7 100644 --- a/packages/session-backends/sqlite-node/src/index.ts +++ b/packages/session-backends/sqlite-node/src/index.ts @@ -48,6 +48,15 @@ class NodeSqliteStatement implements SqliteStatement { : this.statement.all(...(params as SQLInputValue[])) ) as TRow[]; } + + iterate(...params: unknown[]): Iterable { + const [first, ...rest] = params; + return ( + isNamedParameters(first) + ? this.statement.iterate(first, ...(rest as SQLInputValue[])) + : this.statement.iterate(...(params as SQLInputValue[])) + ) as Iterable; + } } class NodeSqliteDatabase implements SqliteDatabase { diff --git a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts index cf6976dfd6c..6e28bfefccd 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts @@ -1,5 +1,11 @@ -import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "@earendil-works/pi-agent-core"; -import { getFileSystemResultOrThrow } from "@earendil-works/pi-agent-core"; +import type { + FileError, + Result, + SessionSearch, + SessionSearchHit, + SessionSearchOptions, +} from "@earendil-works/pi-agent-core"; +import { SessionError } from "@earendil-works/pi-agent-core"; import { applyMigrations } from "./migrations.ts"; import { sql } from "./sql.ts"; import { decodeSessionMetadata, type SessionRow } from "./storage/sessions.ts"; @@ -10,6 +16,14 @@ import type { SqliteSessionRepositoryEnv, } from "./types.ts"; +function getFileSystemResultOrThrow(result: Result, message: string): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + function getParentPath(path: string): string { const normalized = path.replace(/[\\/]+$/, ""); const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); @@ -18,6 +32,14 @@ function getParentPath(path: string): string { return normalized.slice(0, lastSlash); } +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + throw error; +} + function configureSqliteDatabase(db: SqliteDatabase): void { sql`PRAGMA journal_mode=WAL`.exec(db); sql`PRAGMA synchronous=FULL`.exec(db); @@ -36,9 +58,15 @@ function tableExists(db: SqliteDatabase, name: string): boolean { }>(db); } +function rebuildSearchIndex(db: SqliteDatabase): void { + sql`INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')`.run(db); +} + function ensureSearchSchema(db: SqliteDatabase): void { const ftsExists = tableExists(db, "session_search_fts"); - sql` + const entriesExist = tableExists(db, "entries"); + db.transaction(() => { + sql` CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5( payload, content = 'entries', @@ -56,11 +84,18 @@ CREATE TRIGGER IF NOT EXISTS session_search_fts_au AFTER UPDATE OF payload ON en INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); END; `.exec(db); - if (!ftsExists) sql`INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')`.exec(db); + if (!ftsExists && entriesExist) rebuildSearchIndex(db); + }); +} + +export interface SqliteSessionSearchHit extends SessionSearchHit { + readonly metadata: SqliteSessionMetadata; + readonly timestamp: number; + readonly score: number; } /** SQLite FTS search over a co-located canonical session database. */ -class SqliteSessionSearch implements SessionSearch { +class SqliteSessionSearch implements SessionSearch { private readonly options: SqliteSessionSearchOptions; private databasePath: string | undefined; @@ -97,44 +132,63 @@ class SqliteSessionSearch implements SessionSearch { } } - async search(options: SessionSearchOptions): Promise[]> { - const text = options.text.trim(); - if (!text) return []; + async *search(text: string, options: SessionSearchOptions = {}): AsyncIterable { + const queryText = text.trim(); + if (!queryText || (options.limit !== undefined && options.limit <= 0)) return; + if (options.entryTypes?.length === 0) return; + throwIfAborted(options.signal); const db = await this.openDatabase(); try { - const query = `"${text.replaceAll('"', '""')}"`; - const cwd = options.cwd ?? null; - const rows = sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name, - se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score - FROM session_search_fts - JOIN entries AS se ON se.rowid = session_search_fts.rowid - JOIN sessions AS s ON s.id = se.session_id - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL + const query = `"${queryText.replaceAll('"', '""')}"`; + const predicates = ["session_search_fts MATCH ?"]; + const params: unknown[] = [query]; + if (options.entryTypes !== undefined) { + predicates.push(`se.type IN (${options.entryTypes.map(() => "?").join(", ")})`); + params.push(...options.entryTypes); + } + const rows = db + .prepare( + `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, + name_fact.seq IS NOT NULL AS has_session_name, + name_fact.value AS session_name, + se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score + FROM session_search_fts + JOIN entries AS se ON se.rowid = session_search_fts.rowid + JOIN sessions AS s ON s.id = se.session_id + LEFT JOIN facts AS name_fact + ON name_fact.session_id = s.id + AND name_fact.kind = 'name' + AND name_fact.key IS NULL + AND name_fact.seq = ( + SELECT MAX(f.seq) + FROM facts AS f + WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL + ) + WHERE ${predicates.join(" AND ")} + ORDER BY score + LIMIT ?`, ) - WHERE session_search_fts MATCH ${query} AND (${cwd} IS NULL OR s.cwd = ${cwd}) - ORDER BY score`.all(db); + .iterate( + ...params, + options.limit ?? -1, + ); const path = await this.getDatabasePath(); - return rows.map((row) => ({ - metadata: decodeSessionMetadata(row, path), - entryId: row.entry_id, - timestamp: row.timestamp, - score: row.score, - })); + for (const row of rows) { + throwIfAborted(options.signal); + yield { + sessionId: row.id, + metadata: decodeSessionMetadata(row, path), + entryId: row.entry_id, + timestamp: row.timestamp, + score: row.score, + }; + } } finally { db.close(); } } } -export function createSqliteSessionSearch(options: SqliteSessionSearchOptions): SessionSearch { +export function createSqliteSessionSearch(options: SqliteSessionSearchOptions): SessionSearch { return new SqliteSessionSearch(options); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/sql.ts b/packages/session-backends/sqlite-node/src/sqlite/sql.ts index 4eaec7f44db..82c1d44f24a 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/sql.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/sql.ts @@ -28,6 +28,10 @@ export class SqlQuery { all(db: SqliteDatabase): TRow[] { return db.prepare(this.queryText).all(...this.params); } + + iterate(db: SqliteDatabase): Iterable { + return db.prepare(this.queryText).iterate(...this.params); + } } /** Builds a parameterized query. Nested queries are inlined; other interpolations become `?` parameters. */ diff --git a/packages/session-backends/sqlite-node/src/sqlite/types.ts b/packages/session-backends/sqlite-node/src/sqlite/types.ts index 349a09d3d26..6afadfd679f 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/types.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/types.ts @@ -13,6 +13,7 @@ export interface SqliteStatement { run(...params: unknown[]): SqliteRunResult; get(...params: unknown[]): TRow | undefined; all(...params: unknown[]): TRow[]; + iterate(...params: unknown[]): Iterable; } /** SQLite database capability used by the SQLite session backend. */ diff --git a/packages/session-backends/sqlite-node/test/repository.test.ts b/packages/session-backends/sqlite-node/test/repository.test.ts index 1d179c684ec..151c0a42a8c 100644 --- a/packages/session-backends/sqlite-node/test/repository.test.ts +++ b/packages/session-backends/sqlite-node/test/repository.test.ts @@ -37,6 +37,8 @@ class ThrowingStatement implements SqliteStatement { all(..._params: unknown[]): TRow[] { return []; } + + *iterate(..._params: unknown[]): Iterable {} } class CountingDatabase implements SqliteDatabase { diff --git a/packages/session-backends/sqlite-node/test/search.test.ts b/packages/session-backends/sqlite-node/test/search.test.ts index 497a5582c1f..bebffd6afbe 100644 --- a/packages/session-backends/sqlite-node/test/search.test.ts +++ b/packages/session-backends/sqlite-node/test/search.test.ts @@ -13,8 +13,14 @@ function createSqliteFixture(options: ConstructorParameters(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) items.push(item); + return items; +} + describe("SQLite FTS5 session search", () => { - it("matches trigrams within one cwd", async () => { + it("matches trigrams", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); @@ -25,23 +31,36 @@ describe("SQLite FTS5 session search", () => { const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" }); const entryId = await included.appendMessage(createUserMessage("Find the auth defect")); await included.setName("Canonical name"); - await excluded.appendMessage(createUserMessage("Find the auth defect")); + const excludedEntryId = await excluded.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth", cwd: root })).resolves.toEqual([ - expect.objectContaining({ - entryId, - timestamp: expect.any(Number), - metadata: expect.objectContaining({ - id: "included", - createdAt: expect.any(Number), - name: "Canonical name", - metadata: { name: "application-owned" }, + const authHits = await collect(search.search("auth")); + expect(authHits).toHaveLength(2); + expect(authHits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "included", + entryId, + timestamp: expect.any(Number), + metadata: expect.objectContaining({ + id: "included", + createdAt: expect.any(Number), + name: "Canonical name", + metadata: { name: "application-owned" }, + }), }), - }), - ]); - await expect(search.search({ text: "uth", cwd: root })).resolves.toEqual([ - expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }), - ]); + expect.objectContaining({ sessionId: "excluded", entryId: excludedEntryId }), + ]), + ); + expect(await collect(search.search("uth"))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "included", + entryId, + metadata: expect.objectContaining({ id: "included" }), + }), + expect.objectContaining({ sessionId: "excluded", entryId: excludedEntryId }), + ]), + ); }); it("omits a cleared session name from search metadata", async () => { @@ -56,8 +75,8 @@ describe("SQLite FTS5 session search", () => { await session.setName("Temporary"); await session.setName(undefined); - const [result] = await search.search({ text: "auth" }); - expect(result).toMatchObject({ entryId, metadata: { id: "session-1" } }); + const [result] = await collect(search.search("auth")); + expect(result).toMatchObject({ sessionId: "session-1", entryId, metadata: { id: "session-1" } }); expect(result?.metadata).not.toHaveProperty("name"); }); @@ -70,7 +89,63 @@ describe("SQLite FTS5 session search", () => { }); const { search } = fixture; - await expect(search.search({ text: 'missing "phrase"' })).resolves.toEqual([]); + expect(await collect(search.search('missing "phrase"'))).toEqual([]); + }); + + it("rebuilds existing entries when FTS is first initialized", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); + + expect(await collect(search.search("auth"))).toEqual([ + expect.objectContaining({ sessionId: "session-1", entryId }), + ]); + }); + + it("honors entry type filters", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const messageEntryId = await session.appendMessage(createUserMessage("Find the auth defect")); + await session.appendCustomEntry("note", { text: "Find the auth custom entry" }); + + expect(await collect(search.search("auth", { entryTypes: ["message"] }))).toEqual([ + expect.objectContaining({ sessionId: "session-1", entryId: messageEntryId }), + ]); + }); + + it("honors result limits", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const first = await repository.create({ cwd: root, id: "session-1" }); + const second = await repository.create({ cwd: root, id: "session-2" }); + await first.appendMessage(createUserMessage("Find the auth defect")); + await second.appendMessage(createUserMessage("Find the auth defect too")); + + expect(await collect(search.search("auth", { limit: 1 }))).toHaveLength(1); + expect(await collect(search.search("auth", { limit: 0 }))).toEqual([]); }); it("removes deleted session entries from the index", async () => { @@ -85,11 +160,52 @@ describe("SQLite FTS5 session search", () => { const { repository, search } = fixture; const session = await repository.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth" })).resolves.toHaveLength(1); + expect(await collect(search.search("auth"))).toHaveLength(1); + + await repository.delete(await session.getMetadata()); + + expect(await collect(search.search("auth"))).toEqual([]); + }); + + it("indexes and removes session entries through triggers after FTS initialization", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + expect(await collect(search.search("auth"))).toEqual([]); + const session = await repository.create({ cwd: root, id: "session-1" }); + await session.appendMessage(createUserMessage("Find the auth defect")); + expect(await collect(search.search("auth"))).toHaveLength(1); await repository.delete(await session.getMetadata()); - await expect(search.search({ text: "auth" })).resolves.toEqual([]); + expect(await collect(search.search("auth"))).toEqual([]); + }); + + it("removes deleted entries from FTS through triggers", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ env, sqlite, databasePath }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); + expect(await collect(search.search("auth"))).toHaveLength(1); + + const db = await sqlite.open(databasePath); + try { + await db.prepare("DELETE FROM entries WHERE session_id = ? AND id = ?").run("session-1", entryId); + } finally { + await db.close(); + } + + expect(await collect(search.search("auth"))).toEqual([]); }); it("does not initialize FTS for canonical writes or blank searches", async () => { @@ -99,7 +215,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await expect(search.search({ text: " " })).resolves.toEqual([]); + expect(await collect(search.search(" "))).toEqual([]); const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); @@ -121,7 +237,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await search.search({ text: "initialize" }); + await collect(search.search("initialize")); const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); @@ -142,7 +258,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await search.search({ text: "initialize" }); + await collect(search.search("initialize")); const session = await repo.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("must remain")); const metadata = await session.getMetadata(); @@ -168,7 +284,7 @@ describe("SQLite FTS5 session search", () => { databasePath: join(root, "sessions.sqlite"), }); - await expect(search.search({ text: "auth" })).rejects.toThrow("setup failed"); + await expect(collect(search.search("auth"))).rejects.toThrow("setup failed"); expect(counts.closes).toBe(1); }); @@ -182,12 +298,16 @@ describe("SQLite FTS5 session search", () => { }); const { repository: repo, search } = fixture; - await expect(search.search({ text: "auth" })).resolves.toEqual([]); + expect(await collect(search.search("auth"))).toEqual([]); const session = await repo.create({ cwd: root, id: "session-1" }); const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth" })).resolves.toEqual([ - expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "session-1" }) }), + expect(await collect(search.search("auth"))).toEqual([ + expect.objectContaining({ + sessionId: "session-1", + entryId, + metadata: expect.objectContaining({ id: "session-1" }), + }), ]); await expect(session.appendMessage(createUserMessage("Still writable"))).resolves.toBeTypeOf("string"); }); From f98629b3991aa1c7938f21249f9d9f0f320fd432 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:35:45 +0200 Subject: [PATCH 119/284] docs(agent): fix broken code fence in crash example --- packages/agent/docs/harness-v3.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 149a3d3351f..96d65c7f5ea 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -125,7 +125,9 @@ On restart the harness reads one register and finds `calls[0].status = "effect_p ```text TX[ insert entry n3 (synthetic "interrupted" result), upsert lane.leaf = n3, upsert op.state/O = call 0 completed ] -``` The conversation stays coherent — every tool call has a result — and nothing ran twice. +``` + +The conversation stays coherent — every tool call has a result — and nothing ran twice. Had the tool declared `replay: "safe"` (a read, a query), the harness would have re-executed it with the persisted arguments instead. From 638ac112398e4934182138247776809ac81085d9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:49:37 +0200 Subject: [PATCH 120/284] docs(agent): resolve providers and tools at dispatch, drop lease machinery --- packages/agent/docs/harness-v3.md | 71 ++++++++++--------------------- 1 file changed, 22 insertions(+), 49 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 96d65c7f5ea..f143317e9f9 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -164,22 +164,9 @@ type SettledAssistantMessage = AssistantMessage & { stopReason: Exclude; }; -/** Added to packages/ai: a synchronous registry lease that captures the exact - provider/model and Models auth resolver without resolving auth yet. */ -interface ModelRequestLease { - readonly model: Model; - // Generic parameters elided; the landed pi-ai API picks concrete generics - // (Model has no default Api parameter today). - stream(context: Context, options?: ModelsApiStreamOptions): - AssistantMessageEventStream; - streamSimple(context: Context, options?: ModelsSimpleStreamOptions): - AssistantMessageEventStream; - fetchDeferred(handle: DeferredHandle, options?: ModelsDeferredFetchOptions): - Promise; - cancelDeferred(handle: DeferredHandle, options?: ModelsDeferredCancelOptions): - Promise; -} -// Models.lease(provider: string, modelId: string): ModelRequestLease | undefined +// Provider dispatch resolves the durable { provider, modelId } identity +// through Models at request time, which also applies auth. A missing or +// swapped registry entry fails the request in-band, like an unknown tool. ``` There are no orchestration "records" in this system. Every durable thing is an **entry**, a **register**, or a **usage row**. @@ -1410,7 +1397,7 @@ The invariant this section carries (restated in Part 9): `op.*` registers and op ## 4.1 The interpreter -The runtime plans from total durable state plus a small process-local scheduler. Entries and stable register values named by the state are batch-loaded before planning. The driver also snapshots current settings revision and registry leases (`Models.lease` and active tool definitions) into `RuntimeSnapshot`; this performs no provider request. When a tool batch first becomes current, the driver resolves `toolContext` once, binds the batch's definitions, and retains them in `DriveState.toolBatches` for every sequential/parallel call in that batch. `nextAction` is then pure over those inputs. Pre-intent hook plans retain the exact lease used for lookup, preparation, schema validation, and eventual dispatch. +The runtime plans from total durable state plus a small process-local scheduler. Entries and stable register values named by the state are batch-loaded before planning. The driver also snapshots the current settings revision into `RuntimeSnapshot`; this performs no provider request. Providers and tools are resolved from their registries **at dispatch time** by the durable identities captured in state — a missing or replaced entry fails that dispatch in-band (synthetic error settlement), exactly like an unknown tool. When a tool batch first becomes current, the driver resolves `toolContext` once and retains it in `DriveState.toolBatches` for every sequential/parallel call in that batch. `nextAction` is then pure over those inputs. ```ts interface CurrentOperation { @@ -1427,22 +1414,14 @@ interface CurrentOperation { type EffectKey = string; // deterministic from durable step/attempt or assistant/sourceIndex -/** Process-local leases captured before intent; never persisted or exposed. */ -type RuntimeProviderLease = ModelRequestLease; -/** The context-bound AgentHarnessTool adapter, exposed as a plain AgentTool. */ -interface RuntimeToolLease { tool: AgentTool } -interface RuntimeAssistantLease { - provider: RuntimeProviderLease; - activeTools: AgentTool[]; -} - interface LiveEffect { plan: EffectPlan; promise: Promise } interface DriveState { deferredPollsRemaining: 0 | 1; running: Map; /** One context/tool-definition snapshot per live or restored batch. */ - toolBatches: Map>; // key: assistantEntryId + /** toolContext resolved once per batch; key: assistantEntryId. */ + toolBatches: Map; /** Process-local best-effort attempts; reopen may attempt again. */ deferredCancellations: Set; } @@ -1450,22 +1429,19 @@ interface DriveState { type EffectPlan = { telemetryContext: TelemetryContext } & ( | { kind: "assistant"; key: EffectKey; generation: Extract; - streamOptions: AgentHarnessStreamOptions; identity: RuntimeAssistantLease } + streamOptions: AgentHarnessStreamOptions } | { kind: "summary"; key: EffectKey; - generation: Extract; - identity: RuntimeProviderLease } + generation: Extract } | { kind: "tool"; key: EffectKey; assistantEntryId: string; sourceIndex: number; /** Full op.tool_args register key: {opId}:{stepId}:{sourceIndex} (§3.8). */ - argsKey: string; identity: RuntimeToolLease } + argsKey: string } | { kind: "deferred"; key: EffectKey; deferred: Extract; - streamOptions: AgentHarnessStreamOptions; identity: RuntimeProviderLease } + streamOptions: AgentHarnessStreamOptions } | { kind: "cancel_deferred"; key: EffectKey; sourceEntryId: string; - handle: DeferredHandle; identity: RuntimeProviderLease } - | { kind: "hook"; key: EffectKey; name: keyof HookMap; event: unknown; - /** Pre-intent hooks carry the exact lease used to prepare their event. */ - identity?: RuntimeProviderLease | RuntimeAssistantLease | RuntimeToolLease } + handle: DeferredHandle } + | { kind: "hook"; key: EffectKey; name: keyof HookMap; event: unknown } ); type SummaryAttemptOutcome = @@ -1500,8 +1476,6 @@ interface RuntimeSnapshot { settingsRevision: number; streamOptions: AgentHarnessStreamOptions; retryPolicy: NormalizedRetryPolicy; - providerLeases: ReadonlyMap; - toolLeases: ReadonlyMap; } type PlannerInputs = { @@ -1609,7 +1583,7 @@ async function drive(current: CurrentOperation, live: DriveState): Promise; settleSummaryRequest(current: CurrentOperation, @@ -1783,7 +1757,7 @@ Same shape for tools (replay only if the captured **and** current declarations s ### Missing identities -Admission resolves configured identities and returns `Err(MissingIdentities)` before writing when any are absent. Each later assistant, deferred, tool, or whole-summary-attempt preparation snapshots process-local provider/tool leases before its pre-intent hook. That registry/settings-line snapshot is the step-start order: lookup, `prepareArguments`, schema validation, hook event, intent, and dispatch all retain the same lease even if the registry is replaced while the hook runs. Both split-summary requests share the attempt's lease. If resolution fails while state is still safely dispatchable (`ready`, `planned`, or between summary requests), the accepted call resolves `Ok({kind:"suspended", reason:"missing_identities", ...})`; state is unchanged and the operation stays open. A later `resume()` precheck returns `Err(MissingIdentities)` on the same condition. Registering missing pieces does not auto-drive. Because the captured configuration is inline, restore reports exactly what is missing without resolving anything. Restored `effect_pending` has no lease and follows unknown-effect recovery rather than claiming the effect never started. Synthetic settlement, usage repair, queue application, finish, and non-replay reconciliation need no identities. +Admission resolves configured identities and returns `Err(MissingIdentities)` before writing when any are absent. After that, dispatch trusts the environment: providers and tools are looked up by their captured durable identities at use time, and a lookup that fails settles in-band as an error — the same contract as an unknown tool. If resolution fails while state is still safely dispatchable (`ready`, `planned`, or between summary requests), the accepted call resolves `Ok({kind:"suspended", reason:"missing_identities", ...})` instead of burning an attempt; state is unchanged and the operation stays open. A later `resume()` precheck returns `Err(MissingIdentities)` on the same condition. Registering missing pieces does not auto-drive. Because the captured configuration is inline, restore reports exactly what is missing without resolving anything. Restored `effect_pending` follows unknown-effect recovery rather than claiming the effect never started. Synthetic settlement, usage repair, queue application, finish, and non-replay reconciliation need no identities. ## 4.5 Crash positions and recovery policy @@ -1822,7 +1796,7 @@ Abort is not a phase. It is `control`. **Signal ownership makes `aborted` unambiguous.** Provider implementations must set `stopReason: "aborted"` if and only if the signal they were given was pulled, and the harness owns that signal exclusively (§4.2). Since `abort()` commits `control` before pulling it, a settled `aborted` response always has cancellation already durable. Timeouts, transport failures, malformed streams, and provider-side refusals all settle as `error` and take the ordinary retry path — which is correct, because those should retry and a user abort should not. An `aborted` response with `control.status === "running"` is unreachable; if one exists, the session is corrupt (Part 9). -On a deferred source, the `abort()` lane job registers the newest persisted handle/lease as a process-local cancellation target and immediately installs `EffectPlan{kind:"cancel_deferred"}` in `DriveState.running`, even when the drive is awaiting a live fetch. It is the one external action permitted to start under cancelled control, remains valid if fetch settlement advances the durable phase, crosses normal manual gating and `pi.ai.request`, calls the leased `cancelDeferred`, converts success/failure to an in-band output, and never writes operation state. Cancellation reconciliation awaits/removes that live plan before terminal finish. Failure is telemetry only and never blocks finish. `deferredCancellations` prevents repetition in one process; crash/reopen during reconciliation may retry. Missing provider identity skips cancellation but not durable reconciliation. +On a deferred source, the `abort()` lane job registers the newest persisted handle as a process-local cancellation target and immediately installs `EffectPlan{kind:"cancel_deferred"}` in `DriveState.running`, even when the drive is awaiting a live fetch. It is the one external action permitted to start under cancelled control, remains valid if fetch settlement advances the durable phase, crosses normal manual gating and `pi.ai.request`, calls `Models.cancelDeferred` with the captured identity, converts success/failure to an in-band output, and never writes operation state. Cancellation reconciliation awaits/removes that live plan before terminal finish. Failure is telemetry only and never blocks finish. `deferredCancellations` prevents repetition in one process; crash/reopen during reconciliation may retry. Missing provider identity skips cancellation but not durable reconciliation. There is no universal assistant closure. The harness never starts a request or appends an assistant message solely to manufacture one. An abort between steps, during tool work, or while suspended can therefore produce no abort-specific assistant event at all. @@ -2513,7 +2487,7 @@ interface StreamAssistantConfig { transformContext?: (messages: AgentMessage[], signal: AbortSignal) => Promise; toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise; - requests: ModelRequestLease; // no registry re-resolution + models: Models; // resolves identity + auth per request streamOptions?: AgentHarnessStreamOptions; /** Harness-owned before_payload adapter; undefined keeps the payload. */ transformPayload?: (payload: unknown, model: Model) => @@ -2530,8 +2504,7 @@ function streamAssistant(messages: AgentMessage[], config: StreamAssistantConfig emit: AgentEventSink): Promise; // The implementation converts curated streamOptions to provider options and // installs harness-owned payload/response callbacks; callers cannot replace them. -// Existing summary helpers gain ModelRequestLease overloads and use the same -// bound request path for every split request. +// Existing summary helpers keep their Models-based request path. type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; tool: AgentTool; args: Record }; @@ -2687,14 +2660,14 @@ If implementation exposes a design contradiction, missing transition, or materia | 1 | **Single-session Storage** | Write-once entries/usage, registers with first-class set/delete, atomic transactions, UUIDv7 id generator with follower minting, runtime entry/register/custom-message schemas, stats projection, Memory backend, shared conformance helpers, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, close. | | 2 | **JSONL v4 and format 3** | Single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | | 3 | **Tree and repositories** | Entries with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle with the `storageVersion` gate at open, coherent branch/tree forks. | Placement, divergence, filters/cursors/stops, custom entries with and without data, context, fork before first attachment, configured fork snapshots/facts/zero ledger. | -| 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, `Models.lease` and runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), identity leases, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | -| 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance with pending-capture placement, captured request lease/options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | +| 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), dispatch-time identity resolution, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | +| 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance with pending-capture placement, captured request options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | | 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | | 7 | **Tools** | Refactor existing loop into three phases, bind `AgentHarnessTool` context, durable complete plans, `op.tool_args/{opId}:{stepId}:{i}` registers with batch-completion deletion, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage. | Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, tool-args register lifecycle including crash-leak prefix cleanup, safe/unsafe replay, ordering, termination, abort-ready states. | | 8 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pending.entry` registers, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation answering `not_found`, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | | 9 | **Abort, close, and failure drain** | Orthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers, and the external-finalization stop on absent operation registers (§4.9). | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, drained-register survival and terminal deletion, close races, an externally finalized operation stopping the drive without writes and resolving from `lastResult`, failure revived only by projecting input. | -| 10 | **Deferred provider redemption** | One poll per resume, copied configuration/options inline, leased request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of slice 9 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | -| 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, leased nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | +| 10 | **Deferred provider redemption** | One poll per resume, copied configuration/options inline, per-poll request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of slice 9 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | +| 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | | 12 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | | 13 | **Navigation** | Validation, summarized decision/generation, and one final transaction combining move/summary/leaf/label with the terminal writes; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup. | | 14 | **SQLite** | Rework the current unfinished schema/backend directly to entries/registers/usage-ledger tables, transactions, stats, leases, catalog `storageVersion`, repository operations, segmented branch cache, entry-id-keyed FTS search projection, and explicit repair. No values table, no `slot_history`, no `getLog`, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, placed-only search, forks/search/stats/repair. | From bcd056a8cdbd980a793d271d3ecbc4d05e679695 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:50:06 +0200 Subject: [PATCH 121/284] docs(agent): drop historical placement-payload rationale --- packages/agent/docs/harness-v3.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index f143317e9f9..4812f74516d 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -217,8 +217,6 @@ interface UsageRow { } ``` -**Why placement and payload are one row.** The superseded design split content ("values") from placement ("nodes") because they can have different birth times: queued input has content at enqueue and placement much later; an assistant response needs its id fixed *before* the content exists. The split is gone; the differing birth times remain, and two reservation regimes cover them (§2.2). Content that is durable before placement is *current mutable state* and waits in a `pending.entry` register keyed by its reserved entry id; the placement transaction writes the complete entry and deletes the register. An id that must exist before its content — an assistant response, a tool result — is just a minted string inside `op.state`, and settlement inserts the complete entry. Every read returns the whole entry with no join, no `valueId`, and no way for content to exist without an owner. - **Registers hold values, not pointers.** A register's value is the current typed state itself, never an id pointing at an immutable state value. Overwriting a register discards the previous value; nothing accumulates and there is no history to fold (§1.8). Deleting a register removes the key entirely and is a first-class write, distinct from storing JSON `null`, which remains a legal value where a namespace's type permits it (`lane.leaf` at the root, `fact.custom`). ## 1.2 Identity From 01701fd372f4e73aa1275f93a809f48fb529df94 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:51:13 +0200 Subject: [PATCH 122/284] docs(agent): clarify register references vs immutable snapshots --- packages/agent/docs/harness-v3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 4812f74516d..1716b54737d 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -217,7 +217,7 @@ interface UsageRow { } ``` -**Registers hold values, not pointers.** A register's value is the current typed state itself, never an id pointing at an immutable state value. Overwriting a register discards the previous value; nothing accumulates and there is no history to fold (§1.8). Deleting a register removes the key entirely and is a first-class write, distinct from storing JSON `null`, which remains a legal value where a namespace's type permits it (`lane.leaf` at the root, `fact.custom`). +**Registers hold values, not snapshots.** A register's value is the current typed state itself — never an id pointing at an immutable state row, the superseded pattern that left a garbage value behind every update. Overwriting a register discards the previous value; nothing accumulates and there is no history to fold (§1.8). Registers may reference *other registers* by deterministic namespace/key — `op.state` names its `op.tool_args`/`op.preparation` keys, queue lists hold the ids that key `pending.entry` registers — and every such reference couples two pieces of current state whose lifecycles the same transactions manage together. Deleting a register removes the key entirely and is a first-class write, distinct from storing JSON `null`, which remains a legal value where a namespace's type permits it (`lane.leaf` at the root, `fact.custom`). ## 1.2 Identity From b8349db8543242b33d5d0f5051e0aec667afba59 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:53:33 +0200 Subject: [PATCH 123/284] docs(agent): re-mint legacy ids at import, drop redundant register paragraph --- packages/agent/docs/harness-v3.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 1716b54737d..7c567a87b1b 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -217,11 +217,9 @@ interface UsageRow { } ``` -**Registers hold values, not snapshots.** A register's value is the current typed state itself — never an id pointing at an immutable state row, the superseded pattern that left a garbage value behind every update. Overwriting a register discards the previous value; nothing accumulates and there is no history to fold (§1.8). Registers may reference *other registers* by deterministic namespace/key — `op.state` names its `op.tool_args`/`op.preparation` keys, queue lists hold the ids that key `pending.entry` registers — and every such reference couples two pieces of current state whose lifecycles the same transactions manage together. Deleting a register removes the key entirely and is a first-class write, distinct from storing JSON `null`, which remains a legal value where a namespace's type permits it (`lane.leaf` at the root, `fact.custom`). - ## 1.2 Identity -Every id storage stores — entry ids, usage ids, and every reserved id that will become one — is a **UUIDv7**, minted through the session's id generator (§2.8); the sole exception is imported legacy-format ids, preserved verbatim (Appendix C). A UUIDv7 begins with 48 bits of Unix milliseconds: the first 12 hex characters of the id *are* its mint time. Every reference is therefore self-describing and time-sortable — a `parentId`, a `lane.leaf` value, a `fact.label` key, an id inside `op.state` JSON all carry their creation time with no lookup. The one cost: ids leak their creation time to applications. Accepted. (A future partitioned Postgres backend would build its retention design on this prefix; that sketch is the informative Part 6. Memory, JSONL, and SQLite never partition.) +Every id storage stores — entry ids, usage ids, and every reserved id that will become one — is a **UUIDv7**, minted through the session's id generator (§2.8). Legacy-format imports re-mint their ids to conform (Appendix C). A UUIDv7 begins with 48 bits of Unix milliseconds: the first 12 hex characters of the id *are* its mint time. Every reference is therefore self-describing and time-sortable — a `parentId`, a `lane.leaf` value, a `fact.label` key, an id inside `op.state` JSON all carry their creation time with no lookup. The one cost: ids leak their creation time to applications. Accepted. (A future partitioned Postgres backend would build its retention design on this prefix; that sketch is the informative Part 6. Memory, JSONL, and SQLite never partition.) Minting rules: @@ -2828,7 +2826,7 @@ The interpreter, effects boundary, hooks, events, classifier, abort/close semant - v3 ISO timestamps convert to Unix milliseconds. - A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`. - On first format-4 write, append one aggregate adjustment usage row with `details: { source: "v3-import" }`, summing v3 node usage so ledger-derived totals remain unchanged. -- Legacy v3 ids are preserved verbatim and are not UUIDv7s. This is sound on the shipping backends, which never partition and treat ids as opaque strings (§1.2). A future partitioned backend (Part 6) requires time-prefixed ids; moving such a session there goes through the precise rewrite (§2.9), which is where legacy sessions acquire UUIDv7 ids. +- Legacy v3 ids are re-minted at import: each entry gets a UUIDv7 whose prefix is the legacy entry's own timestamp (random tail for uniqueness), preserving time order and §1.2's every-id-is-time-prefixed property. All references the format knows are remapped — parent chains, `main`'s leaf, label keys, `fromId`, usage `entryId`. Ids embedded in opaque payloads (custom entry data, `details`, message text) are not rewritten; the opaque-payload contract (§1.2) already covers them. Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current `storageVersion` (§7.3). A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. From e1b444ba626a46c3fae0a7510e451871a9fbd551 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 13:55:39 +0200 Subject: [PATCH 124/284] docs(agent): tighten identity section --- packages/agent/docs/harness-v3.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 7c567a87b1b..6e18aaeaaae 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -219,17 +219,17 @@ interface UsageRow { ## 1.2 Identity -Every id storage stores — entry ids, usage ids, and every reserved id that will become one — is a **UUIDv7**, minted through the session's id generator (§2.8). Legacy-format imports re-mint their ids to conform (Appendix C). A UUIDv7 begins with 48 bits of Unix milliseconds: the first 12 hex characters of the id *are* its mint time. Every reference is therefore self-describing and time-sortable — a `parentId`, a `lane.leaf` value, a `fact.label` key, an id inside `op.state` JSON all carry their creation time with no lookup. The one cost: ids leak their creation time to applications. Accepted. (A future partitioned Postgres backend would build its retention design on this prefix; that sketch is the informative Part 6. Memory, JSONL, and SQLite never partition.) +Every id — entry, usage, and every reserved id — is a **UUIDv7** from the session's id generator (§2.8); legacy imports re-mint to conform (Appendix C). The first 48 bits are the mint time, so every reference is self-describing and time-sortable. Cost accepted: ids leak creation time. (A future partitioned Postgres backend would build on this prefix — informative Part 6.) Minting rules: -1. An id is minted with `now()` **at reservation**. For direct appends and prompt entries, reservation and placement are the same transaction, so the prefix equals the placement date; assistant responses and tool results mint at the intent commit and place at settlement, so their prefixes trail placement by at most the request duration. -2. **Group cohesion: followers inherit the leader's timestamp.** Tool-result ids are minted with their assistant entry id's 48-bit timestamp — `idGenerator.next(timestampMs?)` (§2.8); fresh random bits keep them unique — so an assistant and its tool results form one time-cohesive group under id order, even across a midnight boundary. This is a deliberate, documented deviation from "UUID timestamp = wall clock": a call/result exchange is one unit — a tool result whose assistant call is missing heads a context every provider rejects — and its ids say so. -3. **Synthetic settlement needs no special case.** Crash recovery writes under already-reserved ids (§4.5), so synthetic responses and results carry exactly the ids their intents promised. +1. Ids are minted with `now()` **at reservation**. Direct appends place in the same transaction; assistant/tool ids trail placement by at most the request duration. +2. **Tool-result ids inherit their assistant id's timestamp** (`idGenerator.next(timestampMs?)`, fresh random tail), so a call-and-results group is time-cohesive under id order even across a midnight boundary. +3. Synthetic settlements write under already-reserved ids (§4.5) — no special case. -**The opaque-payload contract.** Application- and model-visible content — custom entry `data`, `details` fields, `fact.custom` values, message text, hook `resumeData` — may embed entry ids. The harness never tracks such references: they are opaque payload, invisible to validation and recovery, and a retention mechanism may leave them dangling. Content that must remain resolvable is copied into the payload rather than referenced by id. +**Opaque payloads** — custom entry `data`, `details`, `fact.custom` values, message text, hook `resumeData` — may embed entry ids. The harness never tracks those references and they may go stale; copy content, don't reference it. -**Absolutes.** Within a session, entries and usage rows, once committed, are never deleted; the administrative precise rewrite (§2.9) is the sole sanctioned exception (repository-level `delete()` removes whole sessions, §2.8). A missing parent is always corruption — loud, never a clean stop. +**Absolutes.** Within a session, entries and usage rows are never deleted — the precise rewrite (§2.9) is the sole exception. A missing parent is always corruption. ## 1.3 Register namespaces From 2862eb4ed1463421d1d532041b7848d307508da7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 14:01:40 +0200 Subject: [PATCH 125/284] docs(agent): reword cancellation triage note --- packages/agent/docs/harness-v3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 6e18aaeaaae..fae2cb7b0ec 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -298,7 +298,7 @@ pending.entry lives until its content is placed or cancelled - Operation-owned `pending.entry` registers still unconsumed at the end (remaining inbox items and abort-drained items) are deleted by the terminal transaction — a consumed item's register dies in its placement transaction; lane-owned ones (`pendingNextRun`) outlive operations and die when consumed or cancelled (§3.11). - `lane.lastResult` is written only by terminal transactions and overwritten by the next one on its lane — one bounded register per lane, forever. Recovery never reads it; it exists so an application that accepted an operation, crashed, and reopened can still learn its outcome (§3.13). - Deleting a fact removes its register. Storing JSON `null` in `fact.custom` is a different, legal state; there are no tombstones. -- There is no `queue.disposition` namespace. It existed solely so a repeated `cancelQueued` could answer `already_cleared`, at the cost of one immortal register per cancelled item. Triage is now: pending → `cancelled`; entry exists → `already_consumed`; else → `not_found` (§3.11). Clients that retry a lost cancel treat `not_found` as success. +- Cancellations leave no trace: `cancelQueued` triages as pending → `cancelled`, entry exists → `already_consumed`, else → `not_found` (§3.11). A client retrying a lost cancel treats `not_found` as success. ## 1.4 Transactions From a348560b1582540ff525926bd1036b1d8ac2f650 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 14:03:00 +0200 Subject: [PATCH 126/284] docs(agent): drop historical changes appendix --- packages/agent/docs/harness-v3.md | 45 ++++++------------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index fae2cb7b0ec..5b89240d856 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -219,7 +219,7 @@ interface UsageRow { ## 1.2 Identity -Every id — entry, usage, and every reserved id — is a **UUIDv7** from the session's id generator (§2.8); legacy imports re-mint to conform (Appendix C). The first 48 bits are the mint time, so every reference is self-describing and time-sortable. Cost accepted: ids leak creation time. (A future partitioned Postgres backend would build on this prefix — informative Part 6.) +Every id — entry, usage, and every reserved id — is a **UUIDv7** from the session's id generator (§2.8); legacy imports re-mint to conform (Appendix B). The first 48 bits are the mint time, so every reference is self-describing and time-sortable. Cost accepted: ids leak creation time. (A future partitioned Postgres backend would build on this prefix — informative Part 6.) Minting rules: @@ -382,7 +382,7 @@ Every settled provider attempt writes one `UsageRow` — successful, failed, ret ``` - `entryId` names the entry the cost belongs to, when there is one. Structural (summary) attempts that fail before producing an entry, and standalone adjustments, have none. -- `adjustment: true` marks a caller-supplied reconciliation (`recordUsage`, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix C). +- `adjustment: true` marks a caller-supplied reconciliation (`recordUsage`, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix B). - Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, hook-supplied compaction/navigation usage rows (§3.9, §3.10), and import aggregates mint their ids at commit; nothing reserves them. - `getStats()` is a maintained projection over the ledger and the message-entry count — `messageCount` counts `message` entries only, not compactions, summaries, or custom entries. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). There is no ledger scan: totals come from the projection, and individual rows reach the application through the `usage` event at commit time (§5.5). @@ -416,7 +416,7 @@ The file is not the state; it is the **replay recipe** for the Memory maps above {"kind":"register","op":"delete","seq":131,"namespace":"op.state","key":"op_9"} ``` -- This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix C). +- This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix B). - Open replays lines in order into the Memory maps: entries and usage rows accumulate; a later register `set` overwrites the key, `delete` removes it. That is *decoding*, not recovery logic. Open verifies persisted sequence monotonicity — strictly increasing, gaps legal (§1.4) — and timestamps, and never regenerates committed timestamps. All queries then run in RAM. - **A torn final line is discarded whole**, including every element of an array, and is truncated before new writes are admitted. This is what makes "no crash prefix inside a transaction" true here. - A malformed *interior* line, or a complete-but-invalid transaction, is corruption. The one exception: superseded old-shape register lines from before a schema migration decode leniently as keyed raw JSON during replay (Part 7); compaction retires them. @@ -797,13 +797,13 @@ interface Session extends SessionTr Repository constructors accept `SessionCodecOptions`. Every declaration-merged custom `AgentMessage` must have a string `role` and a registered runtime schema; unknown custom roles are rejected before persistence and on decode. A new repository session creates `main` with null leaf and an empty `LaneState`, but no configuration; first harness attachment writes its seed configuration. -`open()` compares the stored `storageVersion` with the binary's: equal proceeds; older runs chained migrations under the writer lease before returning (Part 7); newer refuses to open. Old coding-agent v3 JSONL sessions open through the same repository and normalize on load (Appendix C — "v3" there names the legacy JSONL session format, not this document). +`open()` compares the stored `storageVersion` with the binary's: equal proceeds; older runs chained migrations under the writer lease before returning (Part 7); newer refuses to open. Old coding-agent v3 JSONL sessions open through the same repository and normalize on load (Appendix B — "v3" there names the legacy JSONL session format, not this document). Repository implementations resolve `fork(source, ...)` to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read transaction. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session `Storage` contract. ## 2.9 The precise rewrite -Entries and usage rows are never deleted (§1.2). The sole sanctioned exception is the **precise rewrite**: an administrative repository operation that copies the retained set — entries, usage rows, facts, lane registers — into a fresh session store over a coherent snapshot, exactly as a fork does (§2.8), then atomically swaps it for the old store. Its keep-predicate can express what no runtime mechanism may: compliance-grade erasure (including content copied forward into `retainedTail`s and summaries), pruning abandoned branches, and re-minting legacy-format ids (Appendix C). It is tooling above the harness — no harness surface exposes it, and no core rule depends on it. +Entries and usage rows are never deleted (§1.2). The sole sanctioned exception is the **precise rewrite**: an administrative repository operation that copies the retained set — entries, usage rows, facts, lane registers — into a fresh session store over a coherent snapshot, exactly as a fork does (§2.8), then atomically swaps it for the old store. Its keep-predicate can express what no runtime mechanism may: compliance-grade erasure (including content copied forward into `retainedTail`s and summaries), pruning abandoned branches, and re-minting legacy-format ids (Appendix B). It is tooling above the harness — no harness surface exposes it, and no core rule depends on it. # Part 3 — The operation state machine @@ -2613,7 +2613,7 @@ Chained migrations run under the writer lease before `open()` returns (§2.8). E JSONL has one wrinkle in each direction. Replay must decode superseded old-shape register lines leniently — as keyed raw JSON, overwrite-by-key only — because pre-migration bytes remain in the file (§1.7). And a migration must trigger snapshot compaction, whose temp-file-and-rename both persists the new header version atomically and retires the old-shape bytes. Between crash and compaction, lenient replay plus idempotent conversion make the intermediate state harmless. -Legacy coding-agent format 3 predates `storageVersion` entirely; it normalizes through Appendix C on load and receives the current version with its first format-4 write. +Legacy coding-agent format 3 predates `storageVersion` entirely; it normalizes through Appendix B on load and receives the current version with its first format-4 write. ## 7.4 Migrations are total @@ -2783,36 +2783,7 @@ One corruption assertion constructs an `aborted` response with running control d | **External finalization** | A terminal transaction committed from outside the live drive; the drive detects absent registers, stops without writing, and resolves from `lane.lastResult` (§4.9). | | **Precise rewrite** | The administrative copy-retained-and-swap rebuild of a session store — the sole sanctioned path that removes entries or usage rows (§2.9). | -# Appendix B — Changes from agent-harness-spec.md - -| Change | Reason | -|---|---| -| Entries replace the value/node split; placement and payload are one row | The differing birth times that motivated the split are covered by two reservation regimes (§2.2); removes the join, `valueId`, value GC, and the old-value-under-new-partition hazard | -| Registers hold values directly, with first-class delete; `slot_history` and `getLog` removed | Recovery reads only current state; durable write history was pure overhead. Tier B's oracle is an instrumented-storage decorator | -| `FinishedState` removed; terminal transactions delete `op.*` and operation-owned pending registers; `lane.lastResult` added | A finished session holds exactly the conversation, the ledger, and lane/fact registers — nothing to collect — while outcomes stay observable after a crash | -| Queue items are single entry ids; `pending.entry` registers hold unplaced payloads | `{ nodeId, valueId }` collapses to one string; cancellation deletes content outright; the one deliberate double write is paid only by queued items | -| Usage is a first-class append-only store (`UsageRow`) | Billing is decoupled from orchestration and survives terminal cleanup and aborts | -| Ids are UUIDv7 with the mint time as prefix; follower minting for call/result group cohesion | Every reference is self-describing and time-sortable; call/result exchanges stay id-cohesive (§1.2) | -| Transaction seqs are strictly increasing with legal gaps (was: consecutive) | JSONL snapshot compaction leaves gaps; continuity bought nothing (§1.4) | -| `queue.disposition` removed; `cancelQueued` triage is `cancelled`/`already_consumed`/`not_found`; `UnknownQueueItem` and `already_cleared` dropped | One immortal register per cancelled item bought only a rarely needed distinction; `not_found` is retry-safe | -| Fact deletion is real register deletion; no tombstones | Delete is a first-class write; JSON `null` stays a legal custom value | -| CAS tokens are register seqs (`operationStateSeq`, `laneStateSeq`, expected `lane.config` seq) | State values no longer exist; the linearization is unchanged, only the token | -| Configuration, stream options, and retry policy are inline in operation-state contexts | No values table to point into; restore reports missing identities without resolving anything | -| `op.tool_args/{opId}:{stepId}:{i}` and `op.preparation/{opId}:{taskId}` registers replace args/preparation value ids | Deterministic keys; deleted at batch completion and by the terminal prefix scan, which also catches crash-leaked keys | -| Abort-drained pending registers survive until the terminal transaction | `AbortResult` and post-crash `SuspendedOperation.aborting` dereference the drained payloads; snapshot queues exclude them | -| `RecordUsageResult { usageId }`; the `usage` event carries the ledger row | Value ids are gone; the row already carries its durable `seq` | -| `entry_added`, `findEntries`, `getEntry`, `appendCustomEntry`, `EntryProjector` renames | jot part 9 nomenclature: one concept, one continuity name | -| `getLastResult()` and the `lane.lastResult` read path | Post-crash outcome reconciliation, including outcomes the tree cannot reconstruct | -| Restore validates idle lanes too (leaf plus `pendingNextRun` registers) | Idle lane state is current state; corruption there must not wait for the next operation to surface | -| `PendingEntry.payload` optional; tool-reported usage ids mint at commit | Custom entries may carry no data; nothing reserves a tool usage id | -| Entries and usage rows are never deleted; the administrative precise rewrite (§2.9) is the sole sanctioned exception; partitioned retention reduced to an informative future-Postgres sketch (Part 6) | Retention machinery bought no correctness on the shipping backends; the absolutes are simpler, and the partition bridge is crossed when that backend is real | -| External finalization (§4.9) | Admin force-kill tooling — and a future repairer — can finalize an open operation; the drive stops cleanly instead of racing it | -| Schema evolution specified (Part 7): `storageVersion`, migrate-on-open, total migrations | In-flight state must never brick a session: every state-machine change ships the mapping for its own states | -| JSONL snapshot compaction | Register overwrites append in a log-structured file; physical reclamation is a rewrite | - -The interpreter, effects boundary, hooks, events, classifier, abort/close semantics, context projection, race catalog, and format-3 normalization carry from the base spec with mechanical renames; they are restated in full so this specification is self-contained with the named source types. - -# Appendix C — Coding-agent v3-format compatibility +# Appendix B — Coding-agent v3-format compatibility "v3" in this appendix names the legacy coding-agent JSONL session format, not this document. Old coding-agent v3 JSONL files must open unchanged and restore idle. Normalization on load: @@ -2830,7 +2801,7 @@ The interpreter, effects boundary, hooks, events, classifier, abort/close semant Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current `storageVersion` (§7.3). A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. -# Appendix D — Open questions +# Appendix C — Open questions 1. **Repairing a missing model captured inside an open operation.** Registering the same provider/model identity unblocks it without changing state. Replacing it with a different durable identity needs an explicit repair API and is not silently performed by `setModel`. 2. **Overflow detection remains heuristic.** The normalization specified in §3.7 is authoritative. Preserve the original reason in `errorMessage` for diagnosis. From e33e74450aa34d758c3f5bbac396f628142947bb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 14:11:14 +0200 Subject: [PATCH 127/284] docs(agent): replace historical rationale with direct statements --- packages/agent/docs/harness-v3.md | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 5b89240d856..26b9fc703f1 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -137,7 +137,7 @@ Had the tool declared `replay: "safe"` (a read, a query), the harness would have - **Provider stream resumption.** Partial streams are process-local, never persisted. A settled response is persisted *completely* before anything classifies it. - **Multiple writers.** One process per session. The serving layer routes accordingly, and the SQLite backend enforces it with a fenced lease (§1.7). Lanes cover the workload that looks like multi-writer. - **Replication.** A session lives in one place. -- **Durable write history.** Registers hold only current values: an overwritten register is gone, and there is no `getLog` or history table. Order-of-write assertions in tests use an instrumented storage decorator around `commit()` (Part 9); production auditing belongs to the telemetry layer (§5.8). +- **Durable write history.** Registers hold only current values: an overwritten register is gone, and no API or table exposes write history. Order-of-write assertions in tests use an instrumented storage decorator around `commit()` (Part 9); production auditing belongs to the telemetry layer (§5.8). - **Deletion as a runtime feature.** Entries and usage rows are never deleted: compaction changes provider context, not storage, and terminal cleanup deletes only registers. Note that `retainedTail` copies old messages forward into newer compaction entries and summaries derive from old content, so compaction is not erasure either. Compliance-grade "erase this" is the administrative precise rewrite (§2.9), the sole sanctioned exception. ## 0.7 Notation and source types @@ -169,8 +169,6 @@ type SettledAssistantMessage = AssistantMessage & { // swapped registry entry fails the request in-band, like an unknown tool. ``` -There are no orchestration "records" in this system. Every durable thing is an **entry**, a **register**, or a **usage row**. - --- # Part 1 — Storage @@ -571,7 +569,7 @@ Rules: - Tool-result entries carry `terminate?: true`. It is orchestration state that `ToolResultMessage` has no field for. - Every compaction and branch summary carries `fromHook`: `true` for hook output, `false` for generated. - Every compaction stores a complete `retainedTail` (`[]` when empty). **Context never reads past a compaction.** This is what makes a compaction a self-contained checkpoint rather than a pointer into history. -- A custom entry may carry no `data`. There is no payload-compatibility table to check: an entry either decodes against its type's runtime schema or is corruption. +- A custom entry may carry no `data`. An entry either decodes against its type's runtime schema or is corruption. - Payloads are inline, so two entries never share stored content; there is no deduplication layer. ## 2.2 Placement @@ -589,7 +587,7 @@ TX[ insert e_a4 = { parent: e_q1, type: "message", message: upsert lane.leaf/main = "e_a4" ] ``` -**Content first, placement later** — queued input (`steer`, `followUp`, `nextRun`) and deferred tree writes. The entry id is minted at enqueue and doubles as the register key; queue state references content by that one id — the old `{ nodeId, valueId }` pair collapses to a single string. Two transactions, possibly far apart: +**Content first, placement later** — queued input (`steer`, `followUp`, `nextRun`) and deferred tree writes. The entry id is minted at enqueue and doubles as the register key; queue state references content by that one id. Two transactions, possibly far apart: ``` t0 TX[ upsert pending.entry/e_q1 = { type: "message", payload: <200KB message> }, @@ -684,7 +682,7 @@ Semantics: take the path from `start` toward the root, order it (default `newest 4. Run custom entries through `entryProjectors`. An unprojected custom entry never enters context. 5. Run `transform_context`, then `toProviderMessages`. -There is no rule for omitting an overflow response, and no link anywhere pointing at one. An overflow response is committed with stop reason `error` (§3.7) and is therefore dropped by rule 3 like any other error, and by any downstream `transformMessages` that filters the same way. +An overflow response needs no dedicated omission rule: it is committed with stop reason `error` (§3.7) and is therefore dropped by rule 3 like any other error, and by any downstream `transformMessages` that filters the same way. **Append-only context invariant.** Across the requests of one lane, provider context must only grow at the tail. An insertion before the previous request's tail invalidates the provider's KV cache and multiplies cost. This is *why* mid-run writes defer to checkpoints, where they append at the tail. Compaction is the one deliberate cache invalidation, and it trades that for a smaller context. @@ -893,7 +891,7 @@ interface Inbox { interface OperationError { code: string; message: string; details?: JsonValue } ``` -The old `QueuedInput { nodeId, valueId }` and `PendingWrite` pairs are gone: a queue item is one entry id, and everything else about it — payload, write type, `customType` — is dereferenced from its `pending.entry` register. +A queue item is one entry id; everything else about it — payload, write type, `customType` — is dereferenced from its `pending.entry` register. `latestAssistantEntryId` updates in the same settlement transaction as every assistant generation or deferred-fetch response. It lets finish and resume construct results/events without a branch scan. A tool batch retains its producing turn id while tool work remains active. @@ -926,7 +924,7 @@ type Generation = notBefore: number; errorMessage: string }; ``` -The context snapshots configuration, stream options, and retry policy **inline** — there is no configuration value to point at, and `LaneConfiguration` is small. Recovery can therefore report exactly what is missing without resolving anything (§4.4). For each attempt, `before_request` runs from generation `ready` (an elapsed retry wait first returns to `ready`). Its curated patch is composed with the context's captured base stream options, then `intendedOutputLimit` and `contextWindow` are calculated and persisted in the `effect_pending` intent before dispatch. A pre-intent crash may rerun the hook. Harness-owned `before_payload`/`after_response` callbacks are mounted only after intent and cannot be replaced through stream options. +The context snapshots configuration, stream options, and retry policy **inline**; `LaneConfiguration` is small. Recovery can therefore report exactly what is missing without resolving anything (§4.4). For each attempt, `before_request` runs from generation `ready` (an elapsed retry wait first returns to `ready`). Its curated patch is composed with the context's captured base stream options, then `intendedOutputLimit` and `contextWindow` are calculated and persisted in the `effect_pending` intent before dispatch. A pre-intent crash may rerun the hook. Harness-owned `before_payload`/`after_response` callbacks are mounted only after intent and cannot be replaced through stream options. ### Tool batch @@ -1155,7 +1153,7 @@ Pure, computed in memory before the settlement transaction. First match wins. Two normalizations happen at commit, and both are deliberate. A cancelled response commits as `aborted`. An overflow-classified response commits as `error`. In both cases the original stop reason is overwritten and the reason is preserved in human-readable form in `errorMessage`. -The overflow normalization is what removes every link from this design. Because the committed response is `error`, §2.5 rule 3 drops it from context automatically — no superseded-response id on the compaction, none in the operation state, and no omission rule of its own. The response stays in the tree as durable history, because a provider request happened and was billed. +Because the committed response is `error`, §2.5 rule 3 drops it from context automatically — the compaction and the operation state carry no reference to it, and no dedicated omission rule exists. The response stays in the tree as durable history, because a provider request happened and was billed. **Overflow detection is a heuristic and must be labelled as one.** Three sources, in decreasing reliability: @@ -2581,7 +2579,7 @@ Full durability means snapshotting in-flight state, and in-flight state has the ## 7.2 Why this design shrinks the problem -Migration cost is proportional to what must be converted. The superseded value/history design would have had to convert — or version-read forever — years of dead operation-state values and history rows. This design deleted all of that (§1.8): +Migration cost is proportional to what must be converted, and this design keeps the convertible surface small (§1.8): ```text what exists at upgrade time migration burden @@ -2593,7 +2591,7 @@ pending.entry registers open-operation inbox items plus lane-owned queued nextRun items ``` -Deleting history is what makes migrate-on-open tractable at all: the entire mutable surface is a few dozen current registers. And the fenced single-writer lease (§1.7) means the opening process owns the session exclusively — migration has no concurrency story to solve. +Because no history is retained, the entire mutable surface is a few dozen current registers — which is what makes migrate-on-open tractable at all. And the fenced single-writer lease (§1.7) means the opening process owns the session exclusively — migration has no concurrency story to solve. ## 7.3 The mechanism: storage version plus migrate-on-open @@ -2713,8 +2711,6 @@ Operations: 20. Current-state validation (§3.3) runs on every decoded latest lane/operation state before execution — idle lanes included (§4.4). `lane.lastResult` never determines an open operation's next action. 21. At most one terminal transaction ever commits per operation. A drive whose conditional commit or reload finds its operation's registers absent stops without writing and resolves from `lane.lastResult` (§4.9). -Everything that used to require a bounded historical validity audit is now either unrepresentable in the types, deleted by the terminal transaction, or covered by one of the above. - ## 9.2 Race catalog Each race has exactly two durable histories. Test both, in manual drive, in both orders. From b3edf017021f802af9b76ab4d95cb555c5427352 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 11 Aug 2026 14:25:53 +0200 Subject: [PATCH 128/284] fix(ai): bound Copilot policy update concurrency --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth/oauth/github-copilot.ts | 13 ++-- packages/ai/test/github-copilot-oauth.test.ts | 62 +++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 316fc08a191..2a884888afb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Fixed GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). - Fixed upstream request buffer limit failures to trigger automatic assistant retries. - Fixed OpenAI Responses function and custom tool calls to preserve namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). - Fixed built-in and custom DeepSeek API models to send output limits through the supported `max_tokens` field. diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index df38ee45e37..1c9aabc3c50 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -16,6 +16,7 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; const COPILOT_API_VERSION = "2026-06-01"; +const COPILOT_POLICY_CONCURRENCY = 4; type DeviceCodeResponse = { device_code: string; @@ -344,11 +345,13 @@ async function enableAllGitHubCopilotModels( signal: AbortSignal, ): Promise { const models = Object.values(GITHUB_COPILOT_MODELS); - await Promise.all( - models.map(async (model) => { - await enableGitHubCopilotModel(token, model.id, enterpriseDomain, signal); - }), - ); + for (let index = 0; index < models.length; index += COPILOT_POLICY_CONCURRENCY) { + await Promise.all( + models.slice(index, index + COPILOT_POLICY_CONCURRENCY).map(async (model) => { + await enableGitHubCopilotModel(token, model.id, enterpriseDomain, signal); + }), + ); + } } async function loginGitHubCopilot(interaction: ProviderAuthInteraction): Promise { diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 38934347a8f..ae8ae32568b 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -239,6 +239,68 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); + it("limits concurrent model policy updates during login", async () => { + vi.useFakeTimers(); + + let activePolicyRequests = 0; + let maxActivePolicyRequests = 0; + let policyRequestCount = 0; + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 1, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.endsWith("/models")) { + return jsonResponse({ data: [] }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + policyRequestCount += 1; + activePolicyRequests += 1; + maxActivePolicyRequests = Math.max(maxActivePolicyRequests, activePolicyRequests); + await new Promise((resolve) => setTimeout(resolve, 10)); + activePolicyRequests -= 1; + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const loginPromise = loginGitHubCopilotForTest({ + onDeviceCode: () => {}, + onPrompt: async () => "", + }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; + + expect(policyRequestCount).toBeGreaterThan(4); + expect(maxActivePolicyRequests).toBe(4); + }); + it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { // A malicious enterprise OAuth server could return a verification_uri that // the browser launcher would otherwise hand to the OS. Ensure such values diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 887f8e665f1..95010b1fa66 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed +- Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. - Fixed inherited required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). From 2545585395aa02b48b3fb2fb406757893904cb5d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 14:32:09 +0200 Subject: [PATCH 129/284] docs(agent): one sqlite file per session, search becomes external --- packages/agent/docs/harness-v3.md | 89 ++++++++++++++----------------- 1 file changed, 40 insertions(+), 49 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 26b9fc703f1..ae405255075 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -433,37 +433,41 @@ When to compact: on open when the dead-bytes ratio crosses a threshold; optional ### SQLite +**One database file per session.** The file is the session, exactly as a JSONL +file is. Corruption is confined to one session, deletion is unlinking a file, and +SQLite's one-writer-per-file rule coincides with the design's +one-writer-per-session rule by construction. + ```sql -entries(session_id, id TEXT, parent_id TEXT, seq INTEGER, type TEXT, custom_type TEXT, - timestamp INTEGER, payload TEXT, PRIMARY KEY (session_id, id)) WITHOUT ROWID; -CREATE INDEX ix_entry_parent ON entries(session_id, parent_id); -CREATE INDEX ix_entry_seq ON entries(session_id, seq, type); +entries(id TEXT PRIMARY KEY, parent_id TEXT, seq INTEGER, type TEXT, + custom_type TEXT, timestamp INTEGER, payload TEXT) WITHOUT ROWID; +CREATE INDEX ix_entry_parent ON entries(parent_id); +CREATE INDEX ix_entry_seq ON entries(seq, type); -registers(session_id, namespace TEXT, key TEXT, seq INTEGER, value TEXT, - PRIMARY KEY (session_id, namespace, key)); +registers(namespace TEXT, key TEXT, seq INTEGER, value TEXT, + PRIMARY KEY (namespace, key)); -usage_ledger(session_id, id TEXT, seq INTEGER, entry_id TEXT, adjustment INTEGER, - usage TEXT, details TEXT, PRIMARY KEY (session_id, id)) WITHOUT ROWID; -CREATE INDEX ix_usage_seq ON usage_ledger(session_id, seq); +usage_ledger(id TEXT PRIMARY KEY, seq INTEGER, entry_id TEXT, adjustment INTEGER, + usage TEXT, details TEXT) WITHOUT ROWID; +CREATE INDEX ix_usage_seq ON usage_ledger(seq); -- Private branch index (§2.6). Not registers; no equivalent in the other backends. -branch_entries(session_id, branch_id TEXT, entry_id TEXT, entry_seq INTEGER, entry_type TEXT, - PRIMARY KEY (session_id, branch_id, entry_id)) WITHOUT ROWID; +branch_entries(branch_id TEXT, entry_id TEXT, entry_seq INTEGER, entry_type TEXT, + PRIMARY KEY (branch_id, entry_id)) WITHOUT ROWID; -- Ordered scans. entry_seq must follow branch_id directly or ORDER BY needs a -- temp b-tree; entry_id and entry_type trail so the index covers id-only reads. -CREATE INDEX ix_be_seq ON branch_entries(session_id, branch_id, entry_seq, entry_id, entry_type); +CREATE INDEX ix_be_seq ON branch_entries(branch_id, entry_seq, entry_id, entry_type); -- Type-filtered scans. -CREATE INDEX ix_be_type ON branch_entries(session_id, branch_id, entry_type, entry_seq, entry_id); -CREATE INDEX ix_be_entry ON branch_entries(session_id, entry_id); -branch_meta(session_id, branch_id TEXT, tip_entry_id TEXT, tip_seq INTEGER, - base_branch_id TEXT, base_seq INTEGER, - PRIMARY KEY (session_id, branch_id)); -CREATE UNIQUE INDEX ix_bm_tip ON branch_meta(session_id, tip_entry_id); +CREATE INDEX ix_be_type ON branch_entries(branch_id, entry_type, entry_seq, entry_id); +CREATE INDEX ix_be_entry ON branch_entries(entry_id); +branch_meta(branch_id TEXT PRIMARY KEY, tip_entry_id TEXT, tip_seq INTEGER, + base_branch_id TEXT, base_seq INTEGER); +CREATE UNIQUE INDEX ix_bm_tip ON branch_meta(tip_entry_id); -sessions(session_id, created_at, parent_session_id, storage_version, metadata); -session_stats(session_id, message_count, usage_payload); -session_sequences(session_id, next_seq); -writer_leases(session_id, owner_id TEXT, fence INTEGER, expires_at_ms INTEGER); +-- One row each: the file is the session. +session(created_at, parent_session_id, storage_version, metadata, + message_count, usage_payload, next_seq); +writer_lease(owner_id TEXT, fence INTEGER, expires_at_ms INTEGER); ``` One `commit()` is one SQL transaction: insert entries, insert ledger rows, upsert or delete registers, maintain the branch index, bump `session_stats`. Never an UPDATE or DELETE on an entry or ledger row; mutability is confined to registers, the branch index (`branch_meta` tips and bases), stats, sequences, the session catalog row, and leases. @@ -475,13 +479,15 @@ lock; if another writer committed in between, SQLite fails that upgrade — and stale snapshot. The only recovery is rollback and full retry. Every commit has this shape, not just a few. Allocating the sequence range reads -`session_sequences.next_seq` and then writes it, so a read precedes a write in every +the session row's `next_seq` and then writes it, so a read precedes a write in every transaction the system performs. Branch creation (§2.6) adds a second instance, reading the newest compaction before inserting. `BEGIN IMMEDIATE` takes the write lock up front and avoids an unrecoverable stale-snapshot upgrade, so there is no case where a deferred `BEGIN` is the right choice here. -**`writer_leases` enforces the single-writer rule.** Expiring fenced ownership: +**`writer_lease` enforces the single-writer rule.** WAL happily lets two +processes alternate writes to one file, which is exactly the interleaving the +design forbids — so per-session files do not remove the need for the lease. Expiring fenced ownership: `open()` acquires the claim, storage renews it on appends and while idle, and close stops renewal after the queue drains and deletes only its matching `(owner_id, fence)` pair — so a stale owner cannot release the replacement that succeeded it. @@ -490,15 +496,6 @@ a convention the serving layer is trusted to uphold. Memory and JSONL have no equivalent and rely on process ownership; a JSONL session opened twice is corrupt and undetected. -**Writer scope is per database file, not per session.** WAL mode permits exactly one -writer per file. Because these tables are keyed by `session_id`, several sessions may -share a file, and the design's one-writer-per-session rule does not by itself make -writes uncontended. Choose deliberately: - -- *One file per session* — the single-writer claim becomes literally true, and there - is no cross-session contention. Preferred unless something forces otherwise. -- *One file for many sessions* — correct, but all sessions share SQLite's one-writer queue. Use only when that contention is acceptable. - Atomicity itself needs no special handling. A multi-write transaction is all-or-none by the file format: WAL frames become visible only when the commit record lands, so a concurrent reader observes either none of a transaction's writes or all of them. @@ -508,8 +505,8 @@ Each physical segment of `scanBranch` uses one JOIN; §2.6 combines segment rang ```sql SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload FROM branch_entries b -CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id -WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ? +CROSS JOIN entries e ON e.id = b.entry_id +WHERE b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ? ORDER BY b.entry_seq; ``` @@ -518,8 +515,8 @@ to itself the planner may drive from `entries`, scan the table, and sort through temporary b-tree. Assert the plan in a test: ``` -SEARCH b USING COVERING INDEX ix_be_seq (session_id=? AND branch_id=? AND entry_seq>?) -SEARCH e USING PRIMARY KEY (session_id=? AND id=?) +SEARCH b USING COVERING INDEX ix_be_seq (branch_id=? AND entry_seq>?) +SEARCH e USING PRIMARY KEY (id=?) ``` Any plan containing `USE TEMP B-TREE FOR ORDER BY` or a scan of `entries` is a @@ -527,7 +524,7 @@ regression. `scanBranchStructure` is the same query without the payload column. `getEntries` is a primary-key lookup keyed by `e.id IN (...)`. -The repository's existing `SessionSearch` surface remains. SQLite replaces its rowid-dependent index with an FTS projection keyed by stored `session_id` and `entry_id`; searchable text is the JSON serialization of the entry, matching the scanning fallback. The transaction that places an entry also inserts its projection after validation. Pending content is not searchable before placement. Fork import populates the same projection, and session deletion removes its rows. Search never depends on `entries.rowid`. +Because the file is the session, the precise rewrite (§2.9) and forks are file operations: build a fresh database (`VACUUM INTO` or row copy over one read snapshot) and, for the rewrite, atomically swap it over the old path — the same shape JSONL uses. ## 1.8 Why write-once plus registers @@ -756,14 +753,6 @@ interface SessionCodecOptions { customMessageSchemas?: Record; // keyed by custom `role` } -interface SessionSearchOptions { text: string; cwd?: string } -interface SessionSearchHit { - metadata: M; entryId: string; timestamp: number; snippet?: string; score?: number; -} -interface SessionSearch { - search(options: SessionSearchOptions): Promise[]>; -} - interface SessionRepo; ALTER TABLE … DETACH PARTITION p; COMMIT;` — plain `DETACH`, not `CONCURRENTLY`, precisely because it is transactional under the lock; the `DROP TABLE` happens later, unhurried. The barrier makes repair-plus-detach one linearization point: every commit sees either the fully attached period or a fully repaired store without it. - **The default partition.** A `DEFAULT` partition absorbs stray inserts whose ids predate every attached partition — an ancient `pendingNextRun` item consumed years after its mint still places under its reserved id and simply lands there. Nothing errors and nothing is lost; the default partition stays small and is never dropped. @@ -2664,7 +2655,7 @@ If implementation exposes a design contradiction, missing transition, or materia | 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | | 12 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | | 13 | **Navigation** | Validation, summarized decision/generation, and one final transaction combining move/summary/leaf/label with the terminal writes; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup. | -| 14 | **SQLite** | Rework the current unfinished schema/backend directly to entries/registers/usage-ledger tables, transactions, stats, leases, catalog `storageVersion`, repository operations, segmented branch cache, entry-id-keyed FTS search projection, and explicit repair. No values table, no `slot_history`, no `getLog`, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, placed-only search, forks/search/stats/repair. | +| 14 | **SQLite** | Rework the current unfinished schema/backend to one database file per session: entries/registers/usage-ledger tables, one-row session/lease rows, transactions, `storageVersion`, repository operations, segmented branch cache, `VACUUM INTO`-based rewrite/fork, and explicit repair. No values table, no `slot_history`, no `getLog`, no search projection, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, forks/stats/repair. | | 15 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, migration registry with total register mappings — open operations' `op.meta`/`op.state` included (§7.4), JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer. | Version gate (equal/older/newer), chained idempotent migrations across crash, an open-operation state mapped across a state-machine change and resuming correctly, lenient replay of superseded shapes, compaction retiring old bytes. | | 16 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | From 47b021a6596e7d0b5d3338ff9679d8e90474114e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 14:43:47 +0200 Subject: [PATCH 130/284] docs(agent): add seq-ranged usage ledger scan --- packages/agent/docs/harness-v3.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index ae405255075..748427810c0 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -349,6 +349,7 @@ interface Storage { scanBranch(q: BranchScan): Promise; // §2.5 scanBranchStructure(q: BranchScan): Promise; scanEntries(q: EntryScan): Promise; // session-wide tree inventory + scanUsage(q: UsageScan): Promise; // seq-ranged ledger read (§1.6) getStats(): Promise; // maintained projection (§1.6) close(): Promise; @@ -362,9 +363,14 @@ interface EntryScan { fromSeq?: number; toSeq?: number; order?: "asc" | "desc"; limit?: number; } + +interface UsageScan { + fromSeq?: number; toSeq?: number; + order?: "asc" | "desc"; limit?: number; +} ``` -There is deliberately no cross-namespace register scan, no ledger scan, and no durable write log. Restore, facts, forks, and execution follow exact ids and keys; entry inventory uses `scanEntries`; totals use the stats projection (§1.6); test-order assertions wrap `commit()` with the instrumented-storage decorator (Part 9); production auditing belongs to telemetry (§5.8). +There is deliberately no cross-namespace register scan and no durable write log. Restore, facts, forks, and execution follow exact ids and keys; entry inventory uses `scanEntries`; ledger reads use `scanUsage`; totals use the stats projection (§1.6); test-order assertions wrap `commit()` with the instrumented-storage decorator (Part 9); production auditing belongs to telemetry (§5.8). Recovery and execution reads must be index-driven and bounded. They may not infer state from an absent value, and there is no register history to fold. Exact dereference is allowed: one current state may name a bounded set of entries and registers, fetched in one batch without order-dependent reduction. Public inventory and debugging APIs may intentionally read more than a hot path; their `limit`/pagination behavior is explicit at the `SessionTree` layer. @@ -382,7 +388,7 @@ Every settled provider attempt writes one `UsageRow` — successful, failed, ret - `entryId` names the entry the cost belongs to, when there is one. Structural (summary) attempts that fail before producing an entry, and standalone adjustments, have none. - `adjustment: true` marks a caller-supplied reconciliation (`recordUsage`, §5.1) rather than a provider report. The format-3 import writes one aggregate adjustment row (Appendix B). - Provider-attempt usage ids are UUIDv7s reserved in the intent commit (§1.2), so a settlement writes under exactly the id its intent promised. Adjustment rows, tool-reported usage rows, hook-supplied compaction/navigation usage rows (§3.9, §3.10), and import aggregates mint their ids at commit; nothing reserves them. -- `getStats()` is a maintained projection over the ledger and the message-entry count — `messageCount` counts `message` entries only, not compactions, summaries, or custom entries. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). There is no ledger scan: totals come from the projection, and individual rows reach the application through the `usage` event at commit time (§5.5). +- `getStats()` is a maintained projection over the ledger and the message-entry count — `messageCount` counts `message` entries only, not compactions, summaries, or custom entries. After every commit it equals the ledger sum; the conformance suite asserts this (Part 9). Individual rows reach the application through the `usage` event at commit time (§5.5), and `scanUsage` (§1.5) reads them back by seq range — a consumer that persists the greatest event `seq` it applied catches up after downtime with `scanUsage({ fromSeq })`. Recovery never reads the ledger. ## 1.7 Backends From 8d8c60da709b83f434f529c7b54ed224b2089cc1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 14:53:28 +0200 Subject: [PATCH 131/284] docs(agent): specify optional pull-based search service --- packages/agent/docs/harness-v3.md | 49 +++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 748427810c0..4d68cd9a000 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -794,7 +794,52 @@ Repository constructors accept `SessionCodecOptions`. Every declaration-merged c Repository implementations resolve `fork(source, ...)` to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read snapshot of the session's file. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session `Storage` contract. -How a repository organizes its sessions is its own choice, constrained only by the storage backend: JSONL and SQLite storage are one file per session, so their repositories are file-based; a Postgres storage could hold every session in one database. Cross-session search is **not** a repository or storage concern: it is an external projection — typically a service subscribing to harness events (§5.5) with its own store — and no conformance test covers it. +How a repository organizes its sessions is its own choice, constrained only by the storage backend: JSONL and SQLite storage are one file per session, so their repositories are file-based; a Postgres storage could hold every session in one database. + +### Search + +Search is an **optional collaborator** injected into the repository, with its own store. Storage knows nothing about it; no conformance test covers it; a repository without a service simply has no search. + +```ts +interface SessionSearchService { + /** Sessions ranked by best match. Required. */ + searchSessions(query: SearchQuery): Promise; + /** Entries ranked by match. Optional capability. */ + searchEntries?(query: SearchQuery): Promise; + + sync(): Promise; // enumerate sessions, catch up all cursors + notify(sessionId: string): void; // freshness hint; debounced single-session pull + remove(sessionId: string): Promise; + close(): Promise; +} + +interface SearchQuery { text: string; limit?: number } // limit counts the method's unit + +interface SessionSearchHit { + sessionId: string; + score?: number; + top?: { entryId: string; snippet?: string; timestamp: number }; // best match, for display +} + +interface EntrySearchHit { + sessionId: string; entryId: string; timestamp: number; + snippet?: string; score?: number; +} +``` + +The repository, when constructed with a service, calls `sync()` at startup, `notify(sessionId)` when it observes entry placement on a session it opened, and `remove(sessionId)` on `delete()`; it exposes both query forms enriched with the metadata it owns: + +```ts +searchSessions(q): Promise>; +searchEntries(q): Promise>; +``` + +**Indexing is pull-based; events are only hints.** The service keeps a durable cursor per session — the highest entry `seq` it has indexed. `sync()` enumerates sessions via the repository (old, new, and files that arrived by copy alike), reads `scanEntries({ fromSeq: cursor + 1 })` on each, indexes message-entry text idempotently per `(sessionId, entryId)`, and advances the cursor. A crash mid-batch re-indexes a few rows into the same state; a service deployed against years of existing sessions starts empty and catches up with the same loop. `notify()` never carries content — it is a poke that triggers a debounced pull of one session; a lost poke is caught by the next sweep. The index is a rebuildable projection with zero authority: indexing failures never affect the harness or commits. + +Two mechanical notes. Reading a session another process is writing is legal — the writer lease gates writers, and WAL gives cross-process snapshot reads — but a sweep may skip lease-held sessions as an optimization, since `notify()` covers the hot ones. The precise rewrite (§2.9) swaps a session's store and may renumber seqs, so cursors key on `(sessionId, storeGeneration)`; the rewrite bumps a generation counter in metadata and a mismatch triggers a full re-index of that session. + +The reference implementation is one standalone SQLite database — an FTS5 table over `(session_id, entry_id, text)` plus the cursor table — and works unchanged over JSONL session files. Several processes may share it under the usual discipline (WAL, `busy_timeout`, `BEGIN IMMEDIATE`, idempotent rows, monotonic cursor updates); writers serialize. ## 2.9 The precise rewrite @@ -2650,7 +2695,7 @@ If implementation exposes a design contradiction, missing transition, or materia |---|---|---|---| | 1 | **Single-session Storage** | Write-once entries/usage, registers with first-class set/delete, atomic transactions, UUIDv7 id generator with follower minting, runtime entry/register/custom-message schemas, stats projection, Memory backend, shared conformance helpers, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, close. | | 2 | **JSONL v4 and format 3** | Single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | -| 3 | **Tree and repositories** | Entries with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle with the `storageVersion` gate at open, coherent branch/tree forks. | Placement, divergence, filters/cursors/stops, custom entries with and without data, context, fork before first attachment, configured fork snapshots/facts/zero ledger. | +| 3 | **Tree and repositories** | Entries with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle with the `storageVersion` gate at open, coherent branch/tree forks, optional search-service wiring plus the reference SQLite FTS implementation (§2.8). | Placement, divergence, filters/cursors/stops, custom entries with and without data, context, fork before first attachment, configured fork snapshots/facts/zero ledger, search sync/notify/remove and cursor catch-up. | | 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), dispatch-time identity resolution, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | | 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance with pending-capture placement, captured request options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | | 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | From b6557f43ec3cc93b5808a073e44a7c2ded75978d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 14:58:58 +0200 Subject: [PATCH 132/284] docs(agent): make search a standalone service, open metadata-filter question --- packages/agent/docs/harness-v3.md | 41 ++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 4d68cd9a000..2ffc3162b6d 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -798,7 +798,15 @@ How a repository organizes its sessions is its own choice, constrained only by t ### Search -Search is an **optional collaborator** injected into the repository, with its own store. Storage knows nothing about it; no conformance test covers it; a repository without a service simply has no search. +Search is a **standalone service over the repository**, with its own store. The dependency points one way: the service consumes `repo.list()` and read-only session opens; the repository knows nothing about search and exposes no search methods, and no conformance test covers any of this. An application that wants search constructs the service and queries it directly: + +```ts +const search = createSqliteSearchService({ repo, dbPath }); // reference impl +await search.sync(); // catch up cursors +events.on("entry_added", (e) => search.notify(e.sessionId)); // optional freshness + +const hits = await search.searchSessions({ text: "auth migration", limit: 10 }); +``` ```ts interface SessionSearchService { @@ -827,13 +835,7 @@ interface EntrySearchHit { } ``` -The repository, when constructed with a service, calls `sync()` at startup, `notify(sessionId)` when it observes entry placement on a session it opened, and `remove(sessionId)` on `delete()`; it exposes both query forms enriched with the metadata it owns: - -```ts -searchSessions(q): Promise>; -searchEntries(q): Promise>; -``` +The application owns the lifecycle: `sync()` at startup or on a schedule, `notify()` wired to its event stream when it wants freshness, `remove()` alongside `repo.delete()` (or left to the next `sync()`, which reconciles against `repo.list()`). Hits carry `sessionId`; callers join metadata through the repository they already hold. **Indexing is pull-based; events are only hints.** The service keeps a durable cursor per session — the highest entry `seq` it has indexed. `sync()` enumerates sessions via the repository (old, new, and files that arrived by copy alike), reads `scanEntries({ fromSeq: cursor + 1 })` on each, indexes message-entry text idempotently per `(sessionId, entryId)`, and advances the cursor. A crash mid-batch re-indexes a few rows into the same state; a service deployed against years of existing sessions starts empty and catches up with the same loop. `notify()` never carries content — it is a poke that triggers a debounced pull of one session; a lost poke is caught by the next sweep. The index is a rebuildable projection with zero authority: indexing failures never affect the harness or commits. @@ -841,6 +843,27 @@ Two mechanical notes. Reading a session another process is writing is legal — The reference implementation is one standalone SQLite database — an FTS5 table over `(session_id, entry_id, text)` plus the cursor table — and works unchanged over JSONL session files. Several processes may share it under the usual discipline (WAL, `busy_timeout`, `BEGIN IMMEDIATE`, idempotent rows, monotonic cursor updates); writers serialize. +**Open question — metadata filtering.** Coding-agent's resume flow filters sessions by `cwd`; other repositories have no cwd concept at all. Repositories already model implementation-specific listing through their `L` options generic (`list(options?: L)`), but `SearchQuery` is deliberately generic — how does a repo-specific filter reach the index? Candidates, to be settled by the people who will fight over it: + +```ts +// (a) typed filter passthrough — service becomes generic over a filter type +await search.searchSessions({ text: "auth", filter: { cwd: "/repo" } }); + +// (b) pre-restrict via the repo's own listing; pass the candidate id set +const local = await repo.list({ cwd: "/repo" }); +await search.searchSessions({ text: "auth", within: local.map((m) => m.id) }); + +// (c) post-filter in the app — breaks ranking: limit applies before the filter +const all = await search.searchSessions({ text: "auth", limit: 10 }); +const hits = all.filter((h) => byId.get(h.sessionId)?.cwd === "/repo"); + +// (d) index chosen metadata fields at sync time; filter natively in the index +createSqliteSearchService({ repo, dbPath, metadataFields: ["cwd"] }); +await search.searchSessions({ text: "auth", where: { cwd: "/repo" } }); +``` + +(a) keeps one round trip but makes the service generic over each repo's filter vocabulary; (b) composes with any repo unchanged but ships a possibly huge id set into the query; (c) is unsound as shown — filtering after `limit` drops results; (d) is what the index does best but couples the service to the metadata fields chosen at sync time and needs re-`sync` when they change. + ## 2.9 The precise rewrite Entries and usage rows are never deleted (§1.2). The sole sanctioned exception is the **precise rewrite**: an administrative repository operation that copies the retained set — entries, usage rows, facts, lane registers — into a fresh session store over a coherent snapshot, exactly as a fork does (§2.8), then atomically swaps it for the old store. Its keep-predicate can express what no runtime mechanism may: compliance-grade erasure (including content copied forward into `retainedTail`s and summaries), pruning abandoned branches, and re-minting legacy-format ids (Appendix B). It is tooling above the harness — no harness surface exposes it, and no core rule depends on it. @@ -2695,7 +2718,7 @@ If implementation exposes a design contradiction, missing transition, or materia |---|---|---|---| | 1 | **Single-session Storage** | Write-once entries/usage, registers with first-class set/delete, atomic transactions, UUIDv7 id generator with follower minting, runtime entry/register/custom-message schemas, stats projection, Memory backend, shared conformance helpers, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, close. | | 2 | **JSONL v4 and format 3** | Single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | -| 3 | **Tree and repositories** | Entries with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle with the `storageVersion` gate at open, coherent branch/tree forks, optional search-service wiring plus the reference SQLite FTS implementation (§2.8). | Placement, divergence, filters/cursors/stops, custom entries with and without data, context, fork before first attachment, configured fork snapshots/facts/zero ledger, search sync/notify/remove and cursor catch-up. | +| 3 | **Tree and repositories** | Entries with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle with the `storageVersion` gate at open, coherent branch/tree forks, the standalone reference SQLite FTS search service (§2.8). | Placement, divergence, filters/cursors/stops, custom entries with and without data, context, fork before first attachment, configured fork snapshots/facts/zero ledger, search sync/notify/remove and cursor catch-up. | | 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), dispatch-time identity resolution, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | | 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance with pending-capture placement, captured request options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | | 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | From 51b45bc5bcecc37a3538af5b0eb3710cc1e2c3f7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 15:27:21 +0200 Subject: [PATCH 133/284] docs(agent): restructure build order into parallel storage and runtime tracks --- packages/agent/docs/harness-v3.md | 51 +++++++++++++++---------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness-v3.md index 2ffc3162b6d..b169dd30c4d 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness-v3.md @@ -2708,39 +2708,38 @@ The design conclusion: the volatile part of the system — orchestration — was # Part 8 — Build order -Build the following vertical slices in order, except SQLite work may proceed after the tree contract stabilizes. Each slice implements the named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. Passing those tests and `npm run check` is its acceptance criterion. +One shared slice lands the complete type surface; everything after it splits into two independent tracks. **Track S** (storage, search, dev TUI) parallelizes across owners — its slices depend only on slices 1–2 and never on each other. **Track R** (runtime) is sequential, runs entirely against the Memory backend, and never waits on Track S. The tracks cannot block each other. -The current source tree is a work-in-progress implementation of the superseded record-log design. Replace its durable shapes rather than supporting both. Each slice updates or removes incompatible consumers/tests immediately so the repository compiles and `npm run check` passes after every merge; there is no compile-only legacy quarantine. Reuse existing behavior and tests where still valid: compaction preparation/split-turn generation, agent-loop streaming/tool behavior, event buffering, telemetry contracts, repository lifecycle, `BEGIN IMMEDIATE`, and fenced SQLite leases. - -If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it to the user for review. Do not silently improvise a new durable contract inside a slice. +Each slice implements its named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. Passing those tests and `npm run check` is its acceptance criterion. If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it for review — do not silently improvise a new durable contract inside a slice. | # | Slice | Implement | Required focused tests | |---|---|---|---| -| 1 | **Single-session Storage** | Write-once entries/usage, registers with first-class set/delete, atomic transactions, UUIDv7 id generator with follower minting, runtime entry/register/custom-message schemas, stats projection, Memory backend, shared conformance helpers, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, close. | -| 2 | **JSONL v4 and format 3** | Single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | -| 3 | **Tree and repositories** | Entries with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle with the `storageVersion` gate at open, coherent branch/tree forks, the standalone reference SQLite FTS search service (§2.8). | Placement, divergence, filters/cursors/stops, custom entries with and without data, context, fork before first attachment, configured fork snapshots/facts/zero ledger, search sync/notify/remove and cursor catch-up. | -| 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), dispatch-time identity resolution, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | -| 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance with pending-capture placement, captured request options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | -| 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | -| 7 | **Tools** | Refactor existing loop into three phases, bind `AgentHarnessTool` context, durable complete plans, `op.tool_args/{opId}:{stepId}:{i}` registers with batch-completion deletion, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage. | Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, tool-args register lifecycle including crash-leak prefix cleanup, safe/unsafe replay, ordering, termination, abort-ready states. | -| 8 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pending.entry` registers, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation answering `not_found`, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | -| 9 | **Abort, close, and failure drain** | Orthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers, and the external-finalization stop on absent operation registers (§4.9). | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, drained-register survival and terminal deletion, close races, an externally finalized operation stopping the drive without writes and resolving from `lastResult`, failure revived only by projecting input. | -| 10 | **Deferred provider redemption** | One poll per resume, copied configuration/options inline, per-poll request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of slice 9 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | -| 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | -| 12 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | -| 13 | **Navigation** | Validation, summarized decision/generation, and one final transaction combining move/summary/leaf/label with the terminal writes; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup. | -| 14 | **SQLite** | Rework the current unfinished schema/backend to one database file per session: entries/registers/usage-ledger tables, one-row session/lease rows, transactions, `storageVersion`, repository operations, segmented branch cache, `VACUUM INTO`-based rewrite/fork, and explicit repair. No values table, no `slot_history`, no `getLog`, no search projection, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, forks/stats/repair. | -| 15 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, migration registry with total register mappings — open operations' `op.meta`/`op.state` included (§7.4), JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer. | Version gate (equal/older/newer), chained idempotent migrations across crash, an open-operation state mapped across a state-machine change and resuming correctly, lenient replay of superseded shapes, compaction retiring old bytes. | -| 16 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | +| 1 | **Types** | The complete shared type surface, behavior-free: `Entry`/`Register`/`UsageRow` and `RegisterValues` including the full Part 3 state tree, `Write`/`Transaction`/`Storage`/`Session`/`SessionTree`/`SessionRepo`, scans, the id-generator and `SessionSearchService` interfaces, `storageVersion`, and the Part 5 surface types (results, errors, events, snapshots, hooks). Delete `packages/agent/src/harness/**` and its tests outright; patch remaining consumers. The repo may not compile mid-slice; it compiles again — `npm run check` clean — at the end. | Type-level only; no behavior. | +| 2 | **Session layer, Memory, conformance** | Entry materialization with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`/views, codec plus runtime entry/register/custom-message schemas, UUIDv7 generator with follower minting, stats projection, the Memory backend with repository lifecycle/forks and the `storageVersion` gate at open, the backend conformance suite, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, placement, divergence, filters/cursors/stops, custom entries with and without data, context projection, fork before first attachment, configured fork snapshots/facts/zero ledger, close. | +| S1 | **JSONL** | Format 4: single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), the file-based repository, format-3 read normalization and first-write temp/rename conversion with id re-minting (Appendix B). Replace the unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule including id re-minting and reference remapping, resolved/unresolved parent paths, aggregate imported usage adjustment. | +| S2 | **SQLite** | One database file per session: entries/registers/usage-ledger tables, one-row session/lease rows, transactions, `storageVersion`, the file-based repository, segmented branch cache, `VACUUM INTO`-based rewrite/fork, and explicit repair. No values table, no `slot_history`, no `getLog`, no search projection, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, forks/stats/repair. | +| S3 | **Search** | The standalone `SessionSearchService` (§2.8): durable per-session cursors, `sync()` enumeration and catch-up, debounced `notify()`, `remove()`/reconciliation, `(sessionId, storeGeneration)` cursor keys, and the reference SQLite FTS5 implementation working over any backend's repository. | Cursor catch-up from empty against existing sessions, idempotent re-index after crash mid-batch, notify/sweep equivalence, sessions-vs-entries queries and ranking, removal and reconciliation, shared-index multi-process discipline. | +| S4 | **Dev TUI and Client** | A minimal `AgentClient` over one lane — `LaneSnapshot` plus `watch()` events, `prompt`/`steer`/`followUp`/`abort`/`resume`/`cancelQueued`, `lane.lastResult` read — and a throwaway alt-screen TUI on `packages/tui`: transcript from snapshot and events, input box, status/queue display, abort key. Built first against a scripted fake client on the slice-1 types; binds to the real harness as Track R lands. Not final. | Compiles; fake-client smoke test. No durability obligations. | +| R1 | **Runtime shell** | Lane/settings mutation lines, total-state validation (idle lanes included), register-seq CAS tokens, runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory (five register reads plus bounded hydration), dispatch-time identity resolution, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, seq-token settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads, idle-lane validation. | +| R2 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance with pending-capture placement, captured request options/thinking inline, payload/response hooks, one generation intent/effect/settlement, usage, the terminal transaction (register cleanup plus `lane.lastResult`), results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, terminal cleanup completeness and `lastResult`, automatic/manual identical state, close at every boundary. | +| R3 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until R9. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | +| R4 | **Tools** | Refactor the existing loop into three phases, bind `AgentHarnessTool` context, durable complete plans, `op.tool_args/{opId}:{stepId}:{i}` registers with batch-completion deletion, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage. | Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, tool-args register lifecycle including crash-leak prefix cleanup, safe/unsafe replay, ordering, termination, abort-ready states. | +| R5 | **Inbox, configuration, and writes** | `nextRun`/steer/follow-up via `pending.entry` registers, `cancelQueued` triage (`not_found`), durable drain markers, checkpoint consumption with register deletion, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation answering `not_found`, one-at-a-time crash after one drain, register/entry exclusivity at every boundary, custom-write continuation, config-step race, writes surviving reopen. | +| R6 | **Abort, close, and failure drain** | Orthogonal control, drained ids in control with surviving pending registers, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close, terminal deletion of inbox-and-drained registers, and the external-finalization stop on absent operation registers (§4.9). | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, drained-register survival and terminal deletion, close races, an externally finalized operation stopping the drive without writes and resolving from `lastResult`, failure revived only by projecting input. | +| R7 | **Deferred provider redemption** | One poll per resume, copied configuration/options inline, per-poll request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of R6 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | +| R8 | **Manual compaction** | Reserved-lane admission, the `op.preparation/{opId}:{taskId}` register, total structural state, hook/generated sources, nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | +| R9 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | +| R10 | **Navigation** | Validation, summarized decision/generation, and one final transaction combining move/summary/leaf/label with the terminal writes; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication including register cleanup. | +| R11 | **Schema version and migrations** | Chained migrate-on-open under the writer lease, migration registry with total register mappings — open operations' `op.meta`/`op.state` included (§7.4), JSONL lenient old-shape replay and mandatory post-migration compaction, refuse-newer. | Version gate (equal/older/newer), chained idempotent migrations across crash, an open-operation state mapped across a state-machine change and resuming correctly, lenient replay of superseded shapes, compaction retiring old bytes. | +| R12 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code — including the S4 fake client. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | Existing source guidance: -- `packages/agent/src/harness/session/**` and the old record reducer/tests: slices 1–3. Remove incompatible reducer code as soon as slice 1 replaces its inputs; do not preserve both durable models. -- `packages/agent/src/harness/agent-harness.ts` and new small transition/effects modules: slices 4–13 and 15–16. -- `packages/agent/src/agent-loop.ts`: preserve behavior while slice 7 extracts phases. -- `packages/agent/src/harness/compaction/**`: adapt, do not rewrite gratuitously, in slices 11–13. -- `packages/session-backends/sqlite-node`: slice 14; retain working transaction and lease primitives. -- Existing tests are evidence, not authority. Keep those that assert unchanged behavior and replace those tied to the record-log format. +- `packages/agent/src/harness/**` and all of its tests are **deletable outright** in slice 1 — no obligation to adapt anything. Salvaging pieces (the compaction preparation/split-turn algorithms for R8–R9, session/codec fragments) is optional and never required. +- `packages/agent/src/agent-loop.ts`: preserve behavior; R4 extracts its phases. +- `packages/session-backends/sqlite-node`: S2 may keep the working transaction and lease primitives or start clean. +- Telemetry contracts (`packages/telemetry`, the agent-owned schemas) remain authoritative. +- Existing tests are evidence, not authority. Keep those that assert unchanged behavior; delete the rest with the code they tested. # Part 9 — Invariants and tests From 85a2060811a23f1580c13ab59a210b1409092837 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 15:29:18 +0200 Subject: [PATCH 134/284] docs(agent): consolidate harness spec into harness.md --- packages/agent/docs/agent-harness-spec.md | 2524 --------- packages/agent/docs/harness-v2-test-matrix.md | 195 - packages/agent/docs/harness-v2.md | 4612 ----------------- .../agent/docs/{harness-v3.md => harness.md} | 4 +- 4 files changed, 1 insertion(+), 7334 deletions(-) delete mode 100644 packages/agent/docs/agent-harness-spec.md delete mode 100644 packages/agent/docs/harness-v2-test-matrix.md delete mode 100644 packages/agent/docs/harness-v2.md rename packages/agent/docs/{harness-v3.md => harness.md} (99%) diff --git a/packages/agent/docs/agent-harness-spec.md b/packages/agent/docs/agent-harness-spec.md deleted file mode 100644 index 3612098d1e6..00000000000 --- a/packages/agent/docs/agent-harness-spec.md +++ /dev/null @@ -1,2524 +0,0 @@ -# AgentHarness — implementation specification - -**Status:** supersedes `harness-v2.md` in full. Where the two disagree, this document wins. Appendix B lists the substantive changes and why. - -**Audience:** an engineer who has never seen this system and has to build it from this document and the existing source tree. Part 0 explains what it is. Parts 1–5 specify it. Part 6 is the build order. Part 7 is what must be true when you are done. Types owned by this design are defined here; existing agent, provider, compaction, and telemetry types are named with their source paths in §0.7. - ---- - -# Part 0 — Orientation - -## 0.1 What this is - -A durable runtime for agent conversations. You hand it a prompt; it talks to a language model, runs tools, and produces a response. The difference from an ordinary agent loop is that **the process can die at any instant** — mid-stream, between a tool call and its result, halfway through a summary — and a new process picks up exactly where the old one stopped, without repeating durable work and without losing anything that had committed. - -It is a library, not a server. One process owns one session at a time. - -## 0.2 Three concepts - -### Session — the conversation - -A session is one conversation, stored as a **tree** rather than a list. - -``` -a ── b ── c ── d - └── e ── f -``` - -A tree, because three features need history that does not move: branching (explore an approach, back out, keep the record), compaction (replace a long prefix with a summary while the original stays queryable), and forking (copy a prefix into a new session). Nodes are appended and never modified or deleted. - -A session also holds **facts** (session name, node labels, application key-value state — latest write wins, not part of the tree) and a **usage ledger** (every token and cost event, append-only). - -### Lane — a cursor into the conversation - -A lane is a **name plus a leaf**: the node that new work extends. Every session has `main`. Applications create more. - -A lane owns its leaf, its configuration (model, thinking level, active tools), its queues, and at most one operation in flight. Lanes run in parallel and share nothing except the tree beneath them. - -Why lanes exist: a Slack channel is one session, and each thread is a lane. Threads share the channel's history but take turns independently. Two lanes can sit on the same node and diverge on their next append — the tree handles that, and no coordination is needed. - -**Lane vs fork:** a lane *shares* history; a fork *copies* it for isolation. Use a lane for a thread in a shared conversation, a fork for a subagent, an export, or a what-if. - -### Harness — what runs a lane - -The harness is the API surface. Per lane: `prompt`, `steer`, `followUp`, `nextRun`, `abort`, `resume`, `compact`, `navigateTree`, plus configuration getters and setters and a tree view. Harness-wide: lane management, tool and resource registries, hooks, events. - -An **operation** is one accepted unit of work on a lane — a `run` (prompt to final answer, including all tool calls), a `compaction`, or a `navigation`. One per lane at a time. - -## 0.3 Worked example — a Slack thread - -A user posts in a channel that already has 400 nodes of history. The application creates a lane for the thread, anchored at the channel's current leaf. - -``` -harness.createLane("slack:1719432.0021", at: "n_400") -lane.prompt("what changed in auth last week?") -``` - -What happens, in order: - -1. **Acceptance.** The harness validates, runs the `before_run` hook, and commits one transaction: the user message, the operation, and the operation's first state — *"I am at a checkpoint, and I need an assistant response."* -2. **Intent.** It commits a second transaction: *"I am about to make a provider request. The response will be node `n_401` and the usage record will be `u_1`."* Nothing has been sent yet. -3. **The request.** Streaming happens. This is the only part that is not durable. -4. **Settlement.** One transaction commits the response, its usage, and the next state: *"the response has tool calls; here is the batch plan, with result ids already assigned."* -5. Tool calls follow the same intent → effect → settlement shape, one pair of commits each. -6. When the model stops without tool calls, a final transaction records the terminal state and clears the lane's current operation. - -Kill the process between any two of those transactions and restart. The harness reads the lane's state, sees exactly which of those sentences was the last one committed, and continues. If it died in step 3, it knows a request may have been billed and may or may not have produced output — that is the one genuinely uncertain window in the whole system, and there is a stated policy for it. - -Meanwhile a second thread in the same channel is running its own lane, over the same 400 nodes of shared history, with no coordination between them. - -## 0.4 Worked example — a crash mid-tool - -``` -lane.prompt("delete the stale migrations and run the test suite") -``` - -The model returns two tool calls. The harness commits the batch plan, then commits `call 0 is about to execute, with these exact arguments, and it declares itself unsafe to replay`. The tool starts deleting files. The process is killed. - -On restart the harness reads one value and finds `calls[0].status = "effect_pending", replay = "never"`. It does not re-run the deletion. It appends a synthetic error result under the result id that was reserved before the effect started, marks the call complete, and continues to call 1. The conversation stays coherent — every tool call has a result — and nothing ran twice. - -Had the tool declared `replay: "safe"` (a read, a query), the harness would have re-executed it with the persisted arguments instead. - -## 0.5 The four ideas - -Everything in Parts 1–5 follows from these. - -**1. Write-once values and nodes, mutable slots.** Every durable value and node is created once and never modified. A **slot** is a namespaced key whose target is a value id, node id, or null. `lane.leaf/main` is a slot. Moving a lane updates that slot. Recovery reads three slots. - -**2. Atomic transactions.** A transaction is a set of value/node creations plus slot updates, committed all-or-none with consecutive sequence numbers. There is no crash state inside a transaction. This is the only write primitive. - -**3. The durable program counter.** After every step, the harness writes one value holding the *complete* current state of the operation, and points `op.state/{id}` at it. Recovery does not replay a journal or infer position from what is missing; it reads the state and switches on it. The state is *total* — it never depends on a previous state — but its fields are mostly **ids**, not copied payloads. - -**4. The effect sandwich.** Provider requests and real tool calls are wrapped in two commits: - -``` -commit: "about to do X; its output will use ids R and U" ← intent - do X ← the uncertain part -commit: output + usage + next state ← settlement -``` - -Hooks follow their replay contract instead: a result becomes durable in the transaction that consumes it, and a crash before that transaction may rerun the hook. Thus every external effect can still happen without durable settlement. Provider/tool intents make that uncertainty explicit where replay policy depends on it; idempotent hooks accept it as a non-goal. - -## 0.6 Non-goals - -- **Exactly-once external effects.** See above. Hooks with their own side effects must be idempotent, keyed by operation id. -- **Provider stream resumption.** Partial streams are process-local, never persisted. A settled response is persisted *completely* before anything classifies it. -- **Multiple writers.** One process per session. The serving layer routes accordingly, and the SQLite backend enforces it with a fenced lease (§1.6). Lanes cover the workload that looks like multi-writer. -- **Replication.** A session lives in one place. - -## 0.7 Notation - -- `TX[ a, b, c ]` — one atomic commit containing writes `a`, `b`, `c` in that order. -- `v_*` value ids (internal), `n_*` node ids (public), `u_*` usage ids. -- `S(next)` — write the new operation-state value and update `op.state`. `L(next)` — the same for lane state. -- **must / must not** are normative. Everything else is explanation. - -Source type provenance: - -- `AgentMessage`, `AgentTool`, `AgentToolResult`, `AgentEventSink`, `QueueMode`, and `ThinkingLevel`: `packages/agent/src/types.ts`. -- `Skill`, `PromptTemplate`, `AgentHarnessResources` (`Resources` below), `AgentHarnessTool`, `AgentHarnessStreamOptions`, and `AgentHarnessStreamOptionsPatch`: `packages/agent/src/harness/types.ts`. -- `Model`, `Models`, `Usage`, `RetryPolicy`, `StopReason`, `AssistantMessage`, `ImageContent`, provider messages, stream options, and deferred handles: `packages/ai`. -- `CompactionSettings`, `CompactionPreparation`, `CompactResult`, `BranchPreparation`, and `BranchSummaryResult`: `packages/agent/src/harness/compaction/`. Existing preparation and split-turn algorithms remain the implementation starting point unless this document explicitly changes them. -- `TelemetryContext` and typed schema helpers: `packages/telemetry`; the agent-owned schemas remain in `packages/agent/src/harness/telemetry.ts`. -- `TSchema` for durable custom-message registration: `typebox`. - -The public `QueueMode` remains `"all" | "one-at-a-time"`. Public `RetryPolicy` remains the pi-ai shape `{ enabled, maxRetries, baseDelayMs }`; operation state stores its normalized `{ maxAttempts, baseDelayMs }` equivalent. `maxRetries` and `baseDelayMs` must be finite non-negative safe integers and `maxRetries + 1` must remain safe; disabled retry normalizes to one attempt. Exponential delay and `notBefore` arithmetic saturate at `Number.MAX_SAFE_INTEGER`. Public `CompactionSettings` remains `{ enabled, reserveTokens, keepRecentTokens }`; both token counts must be finite non-negative safe integers. Constructors and setters reject invalid settings before publication. This design adds `deferred?: boolean | { window?: "15m" | "1h" | "24h" }` to `AgentHarnessStreamOptions` and its patch type; structural requests always force it to false. - -```ts -type SettledAssistantMessage = AssistantMessage & { - stopReason: Exclude; -}; - -/** Added to packages/ai: a synchronous registry lease that captures the exact - provider/model and Models auth resolver without resolving auth yet. */ -interface ModelRequestLease { - readonly model: Model; - stream(context: Context, options?: ModelsApiStreamOptions): - AssistantMessageEventStream; - streamSimple(context: Context, options?: ModelsSimpleStreamOptions): - AssistantMessageEventStream; - fetchDeferred(handle: DeferredHandle, options?: ModelsDeferredFetchOptions): - Promise; - cancelDeferred(handle: DeferredHandle, options?: ModelsDeferredCancelOptions): - Promise; -} -// Models.lease(provider: string, modelId: string): ModelRequestLease | undefined -``` - -There are no orchestration "records" in this system. Every durable thing is a **value**, a **node**, or a **slot**. - ---- - -# Part 1 — Storage substrate - -Storage knows nothing about agents, lanes, or conversations. It stores values and nodes, updates slots, and answers a small fixed set of queries. Parts 2–4 are built entirely on this. - -## 1.1 The model - -```ts -type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; - -/** Write-once. Created in exactly one transaction, never modified or deleted. */ -type StoredValue = { - [P in K]: { - id: string; // globally unique within the session - kind: P; - seq: number; // storage-assigned at commit - payload: ValuePayloads[P]; - } -}[K]; - -/** An unmaterialized conversation-tree node. Write-once, like every value. */ -interface StoredNode { - id: string; // the public "node id" - parentId: string | null; - seq: number; // storage-assigned at commit - type: NodeType; - customType?: string; // when type === "custom" - valueId: string | null; // the content; null for a custom node with no data - timestamp: number; // Unix ms, storage-assigned -} - -type NodeType = "message" | "compaction" | "branch_summary" | "custom"; - -/** The only mutable thing. A namespaced key whose target can change. */ -interface Slot { - namespace: N; - key: string; - targetId: string | null; // null = explicitly unbound (a tombstone) - seq: number; -} -``` - -**Why nodes and values are separate.** Content and placement have different birth times. A queued message has content at enqueue and placement much later. An assistant response needs its id fixed *before* the content exists. Splitting them lets both be write-once: a value is created when its content exists; a node is created when placement happens; neither is ever updated. Reserving an id costs nothing, because a reserved id is just a string in a state value — there is no placeholder row. - -## 1.2 Value kinds - -```ts -interface ValuePayloads { - message: { message: AgentMessage; terminate?: true }; - compaction: CompactionPayload; - branch_summary: BranchSummaryPayload; - custom_data: JsonValue; - lane_config: LaneConfiguration; - lane_state: LaneState; - operation: Operation; - operation_state: OperationState; - usage: UsageValue; - tool_args: Record; - structural_preparation: DurableStructuralPreparation; - queue_disposition: { nodeId: string; disposition: "cancelled" | "cleared_by_abort" }; - fact_value: { value: JsonValue }; -} -type ValueKind = keyof ValuePayloads; - -interface CompactionPayload { - summary: string; retainedTail: AgentMessage[]; tokensBefore: number; - details?: JsonValue; usage?: Usage; fromHook: boolean; -} -interface BranchSummaryPayload { - fromId: string; summary: string; - details?: JsonValue; usage?: Usage; fromHook: boolean; -} -interface DurableFileOperations { - read: string[]; written: string[]; edited: string[]; -} -type DurableStructuralPreparation = - | { kind: "compaction"; messagesToSummarize: AgentMessage[]; - turnPrefixMessages: AgentMessage[]; retainedTail: AgentMessage[]; - isSplitTurn: boolean; tokensBefore: number; previousSummary?: string; - fileOps: DurableFileOperations; settings: CompactionSettings } - | { kind: "branch_summary"; messages: AgentMessage[]; - fileOps: DurableFileOperations; totalTokens: number }; - -interface UsageValue { - usage: Usage; - nodeId?: string; // the node this cost belongs to, when there is one - adjustment: boolean; // true = caller-supplied reconciliation, not a provider report - details?: JsonValue; -} -``` - -## 1.3 Transactions - -```ts -type Write = - | { kind: "value"; id: string; valueKind: ValueKind; payload: JsonValue } - | { kind: "node"; id: string; parentId: string | null; type: NodeType; - customType?: string; valueId: string | null } - | { kind: "slot"; namespace: SlotNamespace; key: string; targetId: string | null }; - -interface Transaction { writes: Write[] } - -interface CommitResult { firstSeq: number; seqs: number[]; timestamp: number } -``` - -Rules: - -1. A transaction commits **all-or-none**. There is no observable state in which some of its writes exist and others do not. -2. Writes receive **consecutive** `seq` values in the order given. `seq` is monotonic session-wide across all lanes and all write kinds. -3. Within a transaction, writes apply in order: a node may reference a value created earlier in the same transaction; a slot may target a value or node created earlier in the same transaction. -4. Value and node ids share one session-wide namespace. Writing either kind under any existing value/node id is **corruption**, not an update. -5. A slot write with the same `(namespace, key)` replaces the current target. History is retained for `getLog`, but only the latest slot value is live. -6. Transactions on one session are **serialized**. There is one writer and one queue. - -Session validates the complete transaction, including JSON serialization and runtime schemas, before storage admission. A failed admitted commit **faults the harness**: all effects stop, all calls reject, and the process must be restarted. A partially applied transaction is not tolerated. - -## 1.4 Queries - -One `Storage` instance serves one session. Repository discovery and lifecycle are outside this interface (§2.8). - -```ts -interface Storage { - commit(tx: Transaction): Promise; - - getValues(ids: string[]): Promise>; - /** Stored node joined to its value. */ - getNodes(ids: string[]): Promise>; - - getSlot(namespace: N, key: string): Promise | undefined>; - listSlots(namespace: N): Promise[]>; - - scanBranch(q: BranchScan): Promise; - scanBranchStructure(q: BranchScan): Promise; - scanNodes(q: NodeScan): Promise; // session-wide tree inventory - getStats(): Promise; // maintained projection - - /** Debug/audit history. Hot-path recovery never calls this. */ - getLog(fromSeq?: number, limit?: number): Promise; - close(): Promise; -} - -interface NodeScan { - type?: NodeType; customType?: string; - fromSeq?: number; toSeq?: number; - order?: "asc" | "desc"; limit?: number; -} - -type LogItem = - | { kind: "value"; seq: number; value: StoredValue } - | { kind: "node"; seq: number; node: StoredNode } - | { kind: "slot"; seq: number; slot: Slot }; -``` - -There is deliberately no value scan and no denormalized lane/operation ownership on values. Restore, facts, forks, and execution follow exact ids; node inventory uses `scanNodes`; stats use their projection; debugging uses `getLog`. - -Recovery and execution reads must be index-driven and bounded. They may not fold history or infer state from an absent value. Exact dereference is allowed: one current state may name a bounded set of immutable payload values, fetched in one batch without order-dependent reduction. Public inventory and debugging APIs may intentionally read more than a hot path; their `limit`/pagination behavior is explicit at the `SessionTree` layer. - -`close()` is idempotent. It seals admission, rejects later reads/commits on that instance, drains commits admitted before the seal, then releases resources and the writer claim. Durable data is reopened through the repository. - -## 1.5 Slot namespaces - -```ts -type SlotNamespace = - | "lane.leaf" | "lane.config" | "lane.state" - | "op.state" | "queue.disposition" - | "fact.name" | "fact.label" | "fact.custom"; -``` - -| Namespace | Key | Target | Meaning | -|---|---|---|---| -| `lane.leaf` | lane name | node id or null | where this lane appends next | -| `lane.config` | lane name | value id | total `LaneConfiguration` | -| `lane.state` | lane name | value id | total `LaneState` (§3.3) | -| `op.state` | operation id | value id | total `OperationState` (§3.2) — **the program counter** | -| `queue.disposition` | queued node id | value id | terminal cancellation/abort disposition; never used by restore | -| `fact.name` | `""` | value id or null | session name | -| `fact.label` | node id | value id or null | node label | -| `fact.custom` | application key | value id or null | application state | - -That is the complete set. A slot bound to `null` is a **tombstone**: explicitly absent, which differs from never bound. Deleting a label sets a tombstone. `queue.disposition` is written only when a pending item is cancelled or cleared by abort; it is an exact public-API lookup, not current orchestration state. - -## 1.6 Backends - -Three encodings of one model. All three pass the same conformance suite (§7.3). - -### Memory - -```ts -values: Map -nodes: Map -slots: Map // key: `${namespace}\u0000${key}` -children: Map // parentId → node ids, for tree walks -log: LogItem[] -``` - -One queue serializes commits. A commit validates and applies writes to temporary transactional state, then publishes the maps and log together. Reads are map lookups; `scanBranch` walks `parentId` and joins in RAM. `getLog` returns a slice of `log`. - -### JSONL - -One physical line per `commit()`. Storage assigns sequence/timestamp fields first, then encodes one committed log item as a JSON object line or several as one **array line**. - -```jsonl -{"v":4,"kind":"header","id":"s_1","createdAt":1700000000000,"cwd":"..."} -[{"kind":"value","seq":1,"id":"v_7","valueKind":"message","payload":{"message":{"role":"user","content":[...]}}}, - {"kind":"value","seq":2,"id":"op_1","valueKind":"operation","payload":{...}}, - {"kind":"node","seq":3,"timestamp":1700000000000,"id":"n_1","parentId":null,"type":"message","valueId":"v_7"}, - {"kind":"value","seq":4,"id":"v_9","valueKind":"operation_state","payload":{...}}, - {"kind":"slot","seq":5,"namespace":"lane.leaf","key":"main","targetId":"n_1"}, - {"kind":"slot","seq":6,"namespace":"op.state","key":"op_1","targetId":"v_9"}] -``` - -- This is format 4. The incompatible format-4 code currently in the source tree is unfinished and is replaced in place; no migration for it is required. Coding-agent format 3 remains supported (Appendix C). -- Open verifies persisted sequence continuity and timestamps while replaying into the Memory projections above. It never regenerates committed timestamps. All queries then run in RAM. -- **A torn final line is discarded whole**, including every element of an array, and is truncated before new writes are admitted. This is what makes "no crash prefix inside a transaction" true here. -- A malformed *interior* line, or a complete-but-invalid transaction, is corruption. -- Durability is process-crash level: a resolved `commit()` survives process death. No fsync promise. -- `getLog` reproduces the file's logical order, expanding arrays. -- Optional: retain `(offset, length)` per value and load payloads lazily, keeping only node/slot structure resident. Do this only if profiling demands it. - -### SQLite - -```sql --- `values` is a SQLite keyword; the physical table is `stored_values`. -stored_values(session_id, id TEXT, kind TEXT, seq INTEGER, payload TEXT, - PRIMARY KEY (session_id, id)) WITHOUT ROWID; -CREATE INDEX ix_value_seq ON stored_values(session_id, seq); - -nodes(session_id, id TEXT, parent_id TEXT, seq INTEGER, type TEXT, custom_type TEXT, - value_id TEXT, timestamp INTEGER, PRIMARY KEY (session_id, id)) WITHOUT ROWID; -CREATE INDEX ix_node_parent ON nodes(session_id, parent_id); -CREATE INDEX ix_node_seq ON nodes(session_id, seq, type); - -slots(session_id, namespace TEXT, key TEXT, target_id TEXT, seq INTEGER, - PRIMARY KEY (session_id, namespace, key)); -slot_history(session_id, seq INTEGER, namespace, key, target_id, - PRIMARY KEY (session_id, seq)); - --- Private branch index (§2.6). Not slots; no equivalent in the other backends. -branch_nodes(session_id, branch_id TEXT, node_id TEXT, node_seq INTEGER, node_type TEXT, - PRIMARY KEY (session_id, branch_id, node_id)) WITHOUT ROWID; --- Ordered scans. node_seq must follow branch_id directly or ORDER BY needs a --- temp b-tree; node_id and node_type trail so the index covers id-only reads. -CREATE INDEX ix_bn_seq ON branch_nodes(session_id, branch_id, node_seq, node_id, node_type); --- Type-filtered scans. -CREATE INDEX ix_bn_type ON branch_nodes(session_id, branch_id, node_type, node_seq, node_id); -CREATE INDEX ix_bn_node ON branch_nodes(session_id, node_id); -branch_meta(session_id, branch_id TEXT, tip_node_id TEXT, tip_seq INTEGER, - base_branch_id TEXT, base_seq INTEGER, - PRIMARY KEY (session_id, branch_id)); -CREATE UNIQUE INDEX ix_bm_tip ON branch_meta(session_id, tip_node_id); - -sessions(session_id, created_at, parent_session_id, metadata); -session_stats(session_id, message_count, usage_payload); -session_sequences(session_id, next_seq); -writer_leases(session_id, owner_id TEXT, fence INTEGER, expires_at_ms INTEGER); -``` - -One `commit()` is one SQL transaction: insert values, insert nodes, upsert slots plus append `slot_history`, maintain the branch index, bump `session_stats`. Never an UPDATE to a value or node row. - -**Every transaction must open with `BEGIN IMMEDIATE`.** A deferred `BEGIN` that -reads before it writes takes a read snapshot and must later upgrade to the write -lock; if another writer committed in between, SQLite fails that upgrade — and -`busy_timeout` does **not** rescue it, because no amount of waiting can refresh a -stale snapshot. The only recovery is rollback and full retry. - -Every commit has this shape, not just a few. Allocating the sequence range reads -`session_sequences.next_seq` and then writes it, so a read precedes a write in every -transaction the system performs. Branch creation (§2.6) adds a second instance, -reading the newest compaction before inserting. `BEGIN IMMEDIATE` takes the write -lock up front and avoids an unrecoverable stale-snapshot upgrade, so there is no case -where a deferred `BEGIN` is the right choice here. - -**`writer_leases` enforces the single-writer rule.** Expiring fenced ownership: -`open()` acquires the claim, storage renews it on appends and while idle, and close -stops renewal after the queue drains and deletes only its matching `(owner_id, -fence)` pair — so a stale owner cannot release the replacement that succeeded it. -This is what makes "one process owns one session" an enforced property rather than -a convention the serving layer is trusted to uphold. Memory and JSONL have no -equivalent and rely on process ownership; a JSONL session opened twice is corrupt -and undetected. - -**Writer scope is per database file, not per session.** WAL mode permits exactly one -writer per file. Because these tables are keyed by `session_id`, several sessions may -share a file, and the design's one-writer-per-session rule does not by itself make -writes uncontended. Choose deliberately: - -- *One file per session* — the single-writer claim becomes literally true, and there - is no cross-session contention. Preferred unless something forces otherwise. -- *One file for many sessions* — correct, but all sessions share SQLite's one-writer queue. Use only when that contention is acceptable. - -Atomicity itself needs no special handling. A multi-write transaction is all-or-none -by the file format: WAL frames become visible only when the commit record lands, so a -concurrent reader observes either none of a transaction's writes or all of them. - -Each physical segment of `scanBranch` uses one JOIN; §2.6 combines segment ranges: - -```sql -SELECT n.id, n.parent_id, n.seq, n.type, n.custom_type, n.value_id, n.timestamp, v.kind AS value_kind, v.payload -FROM branch_nodes b -CROSS JOIN nodes n ON n.session_id = b.session_id AND n.id = b.node_id -LEFT JOIN stored_values v ON v.session_id = n.session_id AND v.id = n.value_id -WHERE b.session_id = ? AND b.branch_id = ? AND b.node_seq > ? AND b.node_seq <= ? -ORDER BY b.node_seq; -``` - -`CROSS JOIN` is load-bearing: it forces `branch_nodes` to be the outer loop. Left -to itself the planner may drive from `nodes`, scan the table, and sort through a -temporary b-tree. Assert the plan in a test: - -``` -SEARCH b USING COVERING INDEX ix_bn_seq (session_id=? AND branch_id=? AND node_seq>?) -SEARCH n USING PRIMARY KEY (session_id=? AND id=?) -SEARCH v USING PRIMARY KEY (session_id=? AND id=?) LEFT-JOIN -``` - -Any plan containing `USE TEMP B-TREE FOR ORDER BY` or a scan of `nodes` is a -regression. - -`scanBranchStructure` is the same without the `stored_values` join. `getNodes` is the same JOIN keyed by `n.id IN (...)`. `getLog` is a three-way `UNION ALL` over `stored_values`, `nodes`, and `slot_history`, ordered by `seq` — which is the whole reason `slot_history` exists. - -The repository's existing `SessionSearch` surface remains. SQLite replaces its rowid-dependent node index with an FTS projection keyed by stored `session_id` and `node_id`; searchable text is the JSON serialization of the materialized node, matching the scanning fallback. The transaction that places a node also inserts its projection after validating the node/value pair. Queued values are not searchable before placement. Fork import populates the same projection, and session deletion removes its rows. Search never depends on `nodes.rowid`. - -## 1.7 Why write-once is worth the discipline - -- **Recovery is a read.** Three slots, then point lookups. No reducer exists to have a bug. -- **Crash states are enumerable.** Between transactions, never inside one. -- **No repair-by-rewrite.** Recovery only ever *appends*, so recovery is itself crash-safe: interrupt it and rerun it and you get the same result. -- **Concurrency is trivial.** Readers never see partial state; there is nothing to lock. -- **Content is stored once.** A queued message is serialized at enqueue and referenced thereafter. - ---- - -# Part 2 — The conversation tree - -## 2.1 Nodes - -A **node** is what the application sees: a stored node joined to its value. - -```ts -interface NodeBase { id: string; seq: number; parentId: string | null; timestamp: number } - -interface MessageNode extends NodeBase { type: "message"; message: AgentMessage; - terminate?: true } -interface CompactionNode extends NodeBase { type: "compaction"; summary: string; - retainedTail: AgentMessage[]; tokensBefore: number; - details?: JsonValue; usage?: Usage; fromHook: boolean } -interface BranchSummaryNode extends NodeBase { type: "branch_summary"; fromId: string; - summary: string; details?: JsonValue; - usage?: Usage; fromHook: boolean } -interface CustomNode extends NodeBase { type: "custom"; customType: string; data?: JsonValue } - -type Node = MessageNode | CompactionNode | BranchSummaryNode | CustomNode; -``` - -Materialization is mechanical, and happens inside storage: - -```ts -function toNode(node: StoredNode, value: StoredValue | undefined): Node { - const base = { id: node.id, seq: node.seq, parentId: node.parentId, - timestamp: node.timestamp }; - switch (node.type) { - case "message": return { ...base, type: "message", ...value!.payload }; - case "compaction": return { ...base, type: "compaction", ...value!.payload }; - case "branch_summary": return { ...base, type: "branch_summary", ...value!.payload }; - case "custom": return { ...base, type: "custom", customType: node.customType!, - data: value?.payload }; - } -} -``` - -Rules: - -- `type` and `customType` live on the **stored node**, not the value, because they are structural filters used by branch queries and denormalized into the branch index. -- Node/value compatibility is exact: `message → message`, `compaction → compaction`, `branch_summary → branch_summary`, and `custom → custom_data | null`. Every other pairing is corruption. -- Assistant nodes always contain a `SettledAssistantMessage`. Reject `pending` before writing. -- Tool-result nodes carry `terminate?: true` on the message value. It is orchestration state that `ToolResultMessage` has no field for. -- Every compaction and branch summary carries `fromHook`: `true` for hook output, `false` for generated. -- Every compaction stores a complete `retainedTail` (`[]` when empty). **Context never reads past a compaction.** This is what makes a compaction a self-contained checkpoint rather than a pointer into history. -- Values are never shared between nodes by the harness. Content-hash dedup is possible under this model and explicitly not built (Appendix D). - -## 2.2 Placement - -The tree's central rule: - -> A **value** is created when its content exists. A **stored node** is created when placement happens. They may land in the same transaction or in two, and neither is ever modified. - -Three cases, all mechanical: - -**Born placed** — assistant responses, tool results, direct appends to an idle lane. One transaction: - -``` -TX[ put v_8 = , - put node n_a4 = { parent: n_q1, type: "message", value: v_8 }, - setSlot lane.leaf/main → n_a4 ] -``` - -**Content first, placement later** — queued input (`steer`, `followUp`, `nextRun`) and deferred writes. Two transactions, possibly far apart: - -``` -t0 TX[ put v_7 = <200KB message>, - S(next){ ...inbox.steer += { nodeId: "n_q1", valueId: "v_7" } } ] - -t1 TX[ put node n_q1 = { parent: n_a3, type: "message", value: v_7 }, - setSlot lane.leaf/main → n_q1, - S(next){ ...inbox.steer -= that item } ] -``` - -The content is serialized **once**. The node references it. - -**Id reserved before content exists** — assistant responses and tool results. The id is a string in a state value; no row exists until settlement. Reserving costs nothing. - -Consequences to rely on: - -- A pending write is **invisible to tree queries** (no node) but **visible in snapshots** (the operation state names it, and its content can be dereferenced). -- "Has this been placed yet?" is answered by the operation state, which lists it as pending — never by the absence of a node. -- A node whose `valueId` names a nonexistent value is corruption. - -## 2.3 Lanes - -A configured lane is three slots and nothing else. Fresh or normalized-v3 `main` may temporarily lack `lane.config` until first harness attachment: - -``` -lane.leaf/{name} → node id or null -lane.config/{name} → value id (LaneConfiguration) // absent only for unconfigured main -lane.state/{name} → value id (LaneState) -``` - -```ts -interface LaneConfiguration { - model: { provider: string; modelId: string }; - thinkingLevel: ThinkingLevel; - activeToolNames: string[]; -} -``` - -- A lane's leaf moves in exactly two ways: the lane appends a node (leaf becomes that node), or the lane navigates (leaf jumps to an existing node). -- `LaneConfiguration` is **total**. A setter writes a whole new value and updates the slot; it is never a patch and never a tree node. -- Creating a lane copies no tree content, no history, and no configuration from its anchor: - -``` -TX[ put v_cfg = , - put v_ls = { currentOperationId: null, pendingNextRun: [] }, - setSlot lane.config/{name} → v_cfg, - setSlot lane.leaf/{name} → anchorNodeId, - setSlot lane.state/{name} → v_ls ] -``` - -- Lanes are never deleted or renamed. Names are permanent application keys. -- `main` exists in every session. -- Two lanes at the same leaf simply diverge on their next append. - -## 2.4 Facts - -Session-scoped, latest-wins, not part of the tree. - -``` -fact.name/"" → value id | null -fact.label/{nodeId} → value id | null -fact.custom/{key} → value id | null -``` - -Setting a value to `undefined` binds `null` (a tombstone). JSON `null` is a legitimate custom value, stored as `{ value: null }`. The built-in and custom namespaces never overlap. Fact writes commit immediately and never move a leaf. - -## 2.5 Branch queries and context - -```ts -interface BranchScan { - start?: string; // default: the view's lane leaf - stopAtType?: NodeType; // scan ends after the first match, inclusive - stopAtId?: string; - type?: NodeType; - customType?: string; - order?: "newestFirst" | "oldestFirst"; // default newestFirst - limit?: number; - cursor?: NodeCursor; -} -type NodeCursor = { seq: number }; -``` - -Semantics: take the path from `start` toward the root, order it (default `newestFirst`), stop **inclusively** at the first `stopAt` match, filter by `type`/`customType`, apply the exclusive cursor, then apply `limit`. For `newestFirst`, a cursor retains `seq < cursor.seq`; for `oldestFirst`, it retains `seq > cursor.seq`. A `stopAt` node is returned only if it also passes the filter. - -**Context projection** — how a provider request is built: - -1. `scanBranch({ start: leaf, order: "newestFirst", stopAtType: "compaction" })`. -2. Reverse to oldest-first. If a compaction terminated the scan, the context is: its `summary`, then its `retainedTail`, then every node after it. **Nothing earlier is read.** -3. Drop assistant responses whose stop reason is `error`, `aborted`, or `deferred`. Retain genuine output-limit `length`. -4. Run custom nodes through `nodeProjectors`. An unprojected custom node never enters context. -5. Run `transform_context`, then `toProviderMessages`. - -There is no rule for omitting an overflow response, and no link anywhere pointing at one. An overflow response is committed with stop reason `error` (§3.7) and is therefore dropped by rule 3 like any other error, and by any downstream `transformMessages` that filters the same way. - -**Append-only context invariant.** Across the requests of one lane, provider context must only grow at the tail. An insertion before the previous request's tail invalidates the provider's KV cache and multiplies cost. This is *why* mid-run writes defer to checkpoints, where they append at the tail. Compaction is the one deliberate cache invalidation, and it trades that for a smaller context. - -## 2.6 The branch index — SQLite only - -Memory and JSONL walk parent pointers in RAM. SQLite maintains a private segmented branch cache so a diverging append does not copy an unbounded root prefix. - -`branch_nodes` stores the nodes physically present in one segment. `branch_meta` stores its tip and optional `{ baseBranchId, baseSeq }`. A segment logically contains its own rows above `baseSeq` plus the referenced base prefix through `baseSeq`. - -Append: - -1. If a branch tip equals the lane leaf, append one row and move that tip. -2. Otherwise resolve a branch that actually covers the leaf, find the newest compaction at or below the leaf through the complete segment chain, copy only rows after that compaction through the leaf, and set the older prefix as the new segment's base. -3. Append the new node and make it the new segment tip. - -Read newest segment first. If the requested range crosses `baseSeq`, continue through the base chain with the upper bound capped at that boundary. Merge segment results into the requested order before filtering/limiting. - -Two correctness rules are mandatory: - -- The base branch must itself cover the leaf within its logical range; merely containing the leaf in an ancestor is insufficient. -- The newest compaction search must traverse the base chain; checking only the newest physical segment can miss it. - -The cache must preserve: - -- following a segment chain yields the exact root path with no gaps or duplicates; -- all chains containing a node agree below it; -- runtime reads never fall back to a table scan or parent walk; -- stale branches remain valid cache history; -- only an explicit repair operation rebuilds the cache from nodes. - -Tests assert these invariants and the required query plans. No wall-clock threshold is normative. - -## 2.7 Forks - -A fork is a repository operation over one coherent source-session snapshot. It copies selected nodes and values, latest facts, lane pointers, and total configuration; it never copies open operation state or usage ledger values. - -```ts -type ForkOptions = - | { scope?: "branch"; nodeId?: string; position?: "before" | "at" } - | { scope: "tree" }; -``` - -- Memory and JSONL obtain the snapshot as one job on the source storage queue. SQLite uses one read transaction. -- Branch scope copies one path and creates only destination `main`. Tree scope copies the whole tree and every lane pointer/configuration. -- The destination is idle and its token/cost ledger starts at zero. Node-local display usage remains on copied nodes. -- Facts follow the selected scope: name/custom facts always copy; labels copy only when their target copies unless tree scope copies all targets. -- Any message may be the fork point. Request construction heals orphaned tool calls. -- The destination metadata records `parentSessionId`. - -A source with only fresh/unconfigured `main`—new format 4 or read-only normalized v3—may have no configuration. Either fork scope then creates one unconfigured destination `main`, which first harness attachment seeds normally. Every configured format-4 lane copied by a fork keeps its current total configuration. - -## 2.8 Session and repository boundary - -`Storage` is deliberately one-session only. `Session` supplies typed validation, lane-bound views, and value/node/slot materialization. `SessionRepo` owns discovery and storage-instance lifecycle: - -```ts -interface SessionMetadata { - id: string; - createdAt: number; - parentSessionId?: string; - /** Only when a v3 parent path cannot be resolved to an available header id. */ - legacyParentSessionPath?: string; -} - -interface SessionCodecOptions { - /** Built-in provider-message roles are registered by default. */ - customMessageSchemas?: Record; // keyed by custom `role` -} - -interface SessionSearchOptions { text: string; cwd?: string } -interface SessionSearchHit { - metadata: M; nodeId: string; timestamp: string; snippet?: string; score?: number; -} -interface SessionSearch { - search(options: SessionSearchOptions): Promise[]>; -} - -interface SessionRepo { - create(options: C): Promise>; - open(metadata: M): Promise>; - list(options?: L): Promise; - delete(metadata: M): Promise; - fork(source: M, options: ForkOptions & C): Promise>; -} - -interface Session extends SessionTree { - readonly metadata: M; - readonly idGenerator: { next(): string }; - view(lane: string): SessionTree; - - /** Package-internal harness substrate; validates before delegating to Storage. */ - commit(tx: Transaction): Promise; - getValues(ids: string[]): Promise>; - getNodes(ids: string[]): Promise>; - getSlot(namespace: N, key: string): Promise | undefined>; - listSlots(namespace: N): Promise[]>; - getLog(fromSeq?: number, limit?: number): Promise; - - close(): Promise; -} -``` - -Repository constructors accept `SessionCodecOptions`. Every declaration-merged custom `AgentMessage` must have a string `role` and a registered runtime schema; unknown custom roles are rejected before persistence and on decode. A new repository session creates `main` with null leaf and an empty `LaneState`, but no configuration; first harness attachment writes its seed configuration. Repository implementations resolve `fork(source, ...)` to the source's serialized snapshot boundary: an active Memory/JSONL storage queues the snapshot with commits; an inactive JSONL file is read as one immutable prefix; SQLite uses one read transaction. Repositories may keep an active-storage registry by session id for this purpose. This is repository coordination, not part of the one-session `Storage` contract. - -# Part 3 — The operation state machine - -## 3.1 Operations - -```ts -interface Operation { - operationId: string; - lane: string; - sourceLeafId: string | null; - startedAt: number; - intent: - | { kind: "run"; promptValueIds: string[]; - systemPromptOverride?: string; resumeData?: Record } - | { kind: "compaction"; customInstructions?: string } - | { kind: "navigation"; targetId: string | null; summarize: boolean; - label?: string; customInstructions?: string }; -} -``` - -The operation value's stored id is exactly `operationId`. It is written once at acceptance. - -## 3.2 Operation state — the program counter - -`op.state/{operationId}` points at one total `operation_state` value: - -```ts -type OperationState = RunState | CompactionState | NavigationState | FinishedState; - -type Control = - | { status: "running" } - | { status: "cancel_requested"; requestedAt: number; - drainedSteer: QueuedInput[]; drainedFollowUp: QueuedInput[] }; - -interface RunState { - kind: "run"; - control: Control; - /** Captured atomically at acceptance; setters affect later operations. */ - settings: { - compaction: CompactionSettings; - steeringMode: QueueMode; - followUpMode: QueueMode; - toolExecution: "sequential" | "parallel"; - }; - phase: RunPhase; - inbox: Inbox; - /** Newest durable assistant generation/fetch response in this operation. */ - latestAssistantNodeId: string | null; -} - -interface CheckpointPhase { - kind: "checkpoint"; - continuation: Continuation; - /** Durable correlation source for the next generation step. */ - triggerNodeId: string; - /** Threshold compaction is attempted at most once per trigger boundary. */ - thresholdCheckedTriggerNodeId?: string; - /** Generate before draining another queued input after one-at-a-time drain. */ - skipInboxOnce?: boolean; -} - -type RunPhase = - | CheckpointPhase - | { kind: "assistant"; generation: Generation } - | { kind: "tools"; batch: ToolBatch } - | { kind: "compaction"; reason: "threshold" | "overflow"; - structural: StructuralDecision; resumeAfter: CheckpointPhase } - | { kind: "deferred"; deferred: Deferred } - | { kind: "failure_drain"; error: OperationError; provenance: - | { kind: "response"; nodeId: string } - | { kind: "structural"; taskId: string } }; - -type Continuation = - | { kind: "need_assistant"; overflowRecoveryUsed: boolean } - | { kind: "may_finish"; includeFinalAssistant: boolean }; - -interface Inbox { - steer: QueuedInput[]; - followUp: QueuedInput[]; - writes: PendingWrite[]; -} - -interface QueuedInput { nodeId: string; valueId: string } -interface PendingWrite { nodeId: string; valueId: string | null; - type: NodeType; customType?: string } -interface OperationError { code: string; message: string; details?: JsonValue } -``` - -`latestAssistantNodeId` updates in the same settlement transaction as every assistant generation or deferred-fetch response. It lets finish and resume construct results/events without a branch scan. A tool batch retains its producing turn id while tool work remains active. - -Any transition that appends conversational input or tool results and requires another assistant writes a checkpoint with `need_assistant(false)` and the appended node as `triggerNodeId`. An unprojected custom write preserves the current checkpoint, including trigger and overflow flag. Entering threshold compaction first copies the checkpoint to `resumeAfter` with `thresholdCheckedTriggerNodeId = triggerNodeId`; decline, empty preparation, success, and crash therefore cannot recheck the same boundary. - -### Generation - -```ts -interface NormalizedRetryPolicy { maxAttempts: number; baseDelayMs: number } - -interface GenerationContext { - stepId: string; - triggerNodeId: string; - configurationValueId: string; - streamOptions: AgentHarnessStreamOptions; - retryPolicy: NormalizedRetryPolicy; -} - -type Generation = - | { status: "ready"; context: GenerationContext; nextAttempt: number } - | { status: "effect_pending"; context: GenerationContext; attempt: number; - responseNodeId: string; responseValueId: string; usageValueId: string; - intendedOutputLimit: number; contextWindow: number } - | { status: "retry_wait"; context: GenerationContext; nextAttempt: number; - notBefore: number; errorMessage: string }; -``` - -For each attempt, `before_request` runs from generation `ready` (an elapsed retry wait first returns to `ready`). Its curated patch is composed with the context's captured base stream options, then `intendedOutputLimit` and `contextWindow` are calculated and persisted in the `effect_pending` intent before dispatch. A pre-intent crash may rerun the hook. Harness-owned `before_payload`/`after_response` callbacks are mounted only after intent and cannot be replaced through stream options. - -### Tool batch - -```ts -interface ToolBatch { - assistantNodeId: string; - /** Producing generation/fetch snapshot; active tool names come from here. */ - configurationValueId: string; - /** The assistant generation step id; recovered tool events use it as turnId. */ - turnId: string; - calls: ToolCall[]; -} - -type ToolCall = - | { status: "planned"; sourceIndex: number; resultNodeId: string } - | { status: "effect_pending"; sourceIndex: number; resultNodeId: string; - /** Always the effective post-prepare/post-hook arguments. */ - argsValueId: string; replay: "never" | "safe" } - | { status: "completed"; sourceIndex: number; resultNodeId: string; - terminate: boolean }; -``` - -The source call comes from `assistantNodeId` plus `sourceIndex`; large effective arguments live once in `tool_args`. Persist them unconditionally because `prepareArguments`, not only `before_tool`, may change them. Parallel calls may be effect-pending together; result nodes commit in source order. - -### Deferred - -```ts -type Deferred = - | { status: "suspended"; stepId: string; sourceNodeId: string; poll: number; - configurationValueId: string; streamOptions: AgentHarnessStreamOptions } - | { status: "effect_pending"; stepId: string; sourceNodeId: string; poll: number; - responseNodeId: string; responseValueId: string; usageValueId: string; - configurationValueId: string; streamOptions: AgentHarnessStreamOptions }; -``` - -One `resume()` performs at most one `fetchDeferred(handle, { wait: 0 })`. Suspended `poll` is the number of completed polls; a fresh intent uses `poll + 1`, and that 1-based value is `before_request.attempt` and the poll turn-id suffix. A poll starts from the original generation's copied base stream options, forces `deferred:false`, runs `before_request`, mounts `before_payload`/`after_response`, then commits its fresh intent and dispatches like assistant generation. Current global stream settings do not affect it. There is no polling retry cap, backoff, or internal loop. A pending response must have a completely equal handle and becomes the next source. A mismatched pending handle is normalized to a durable `error` response explaining the mismatch; response, usage, `latestAssistantNodeId`, and response-provenance `failure_drain` commit atomically. - -### Structural work - -```ts -type StructuralDecision = { taskId: string; preparationValueId: string } & ( - | { status: "deciding" } - | { status: "generating"; generation: SummaryGeneration } -); - -interface SummaryContext { - taskId: string; - resultNodeId: string; - kind: "compaction" | "branch_summary"; - configurationValueId: string; - streamOptions: AgentHarnessStreamOptions; - retryPolicy: NormalizedRetryPolicy; - reason?: "manual" | "threshold" | "overflow"; -} - -type SummaryGeneration = - | { status: "ready"; context: SummaryContext; nextAttempt: number } - | { status: "effect_pending"; context: SummaryContext; attempt: number; - /** Current nested request intent; absent between requests. */ - request?: { index: number; usageValueId: string }; - usageValueIds: string[] } - | { status: "retry_wait"; context: SummaryContext; nextAttempt: number; - notBefore: number; errorMessage: string }; - -interface CompactionState { - kind: "compaction"; - control: Control; - customInstructions?: string; - structural: StructuralDecision; -} - -type NavigationState = - | { kind: "navigation"; control: Control; targetId: string | null; label?: string; - summarize: false; phase: { kind: "ready_to_commit" } } - | { kind: "navigation"; control: Control; targetId: string; label?: string; - customInstructions?: string; summarize: true; - phase: { kind: "summary"; structural: StructuralDecision } }; - -type FinishedState = { - kind: "finished"; - control: Control; - leafId: string | null; - finalAssistantNodeId?: string; -} & ( - | { outcome: "failed"; error: OperationError; runCompletion?: never } - | { outcome: "completed"; error?: never; - runCompletion?: "assistant" | "terminated_tools" } - | { outcome: "declined" | "aborted"; error?: never; runCompletion?: never } -); -``` - -Structural preparation is built from the reserved source leaf and settings snapshot, normalized (`Set` file-operation fields become sorted arrays), and written once as `structural_preparation` before the decision hook. State carries only `preparationValueId`; hooks/generators hydrate arrays back to the source preparation types. Reopen never rebuilds it from current settings, so the provider sees the same summary input the hook approved. - -A normal run finish copies `RunState.latestAssistantNodeId` and records `runCompletion: "assistant"` when `may_finish.includeFinalAssistant` is true. An all-terminating tool batch records `runCompletion: "terminated_tools"` and omits the final assistant. Failed and aborted run outcomes always include the newest settled assistant when non-null and omit both final fields otherwise. Structural operations omit `runCompletion` and final assistant. One structural attempt may make one or two provider requests using the existing compaction implementation. Its request callback first commits `request:{index,usageValueId}`, then performs that provider request through a nested Effects action, then atomically writes usage and clears/advances the request field. Intermediate content remains process-local; any restored `effect_pending` attempt is treated as wholly uncertain and starts a later attempt under the captured policy rather than continuing request two. A durable `generating` decision prevents its decision hook from rerunning. - -## 3.3 Lane state and current-state validity - -```ts -interface LaneState { - currentOperationId: string | null; - pendingNextRun: QueuedInput[]; -} -``` - -Restore validates only current materialized state and values it directly names; it never audits historical states. Required checks: - -- `lane.state/{lane}` targets a `LaneState`; when it names operation O, value O is an `Operation` for that lane, and `op.state/O` targets an `OperationState` compatible with O's intent kind; -- every slot targets an existing value/node of its namespace's required kind; -- every referenced queue/configuration/args/assistant/preparation value exists and has the expected kind and valid JSON DTO; -- finished outcome/error/control combinations are valid for the operation kind, finished state is paired atomically with a cleared lane operation, and a completed run omits its final assistant only with `runCompletion:"terminated_tools"`; -- tool source indices are complete, ordered, unique, in range, and use unique result ids; completed result nodes match their source calls; -- reserved response/result/usage ids, if materialized, contain the intended kind and identity; -- cancellation, navigation source/target, and structural-source combinations satisfy the state discriminants. - -Runtime schemas validate every decoded value/state before publication. These bounded checks reject corrupted/imported state that TypeScript transition functions could not have produced. - -## 3.4 The atomic transition rule - -> Compute the next total state in memory, then atomically commit every value, node, and slot update that makes that state true. - -A transaction writing total `LaneState` rereads its latest value inside the lane mutation line and changes only the fields owned by that transition. In particular, finish clears `currentOperationId` while preserving concurrently accepted `pendingNextRun`. Every edge below is exactly one `commit()`. - -## 3.5 The graph - -```mermaid -stateDiagram-v2 - [*] --> idle - idle --> checkpoint : prompt() accepted - - checkpoint --> assistant : continuation = need_assistant - checkpoint --> compaction : context threshold - checkpoint --> checkpoint : apply write / consume steer / consume follow-up - checkpoint --> finished : may_finish + empty inbox - - assistant --> assistant : retryable error (retry_wait) - assistant --> tools : toolUse - assistant --> compaction : overflow (first time) - assistant --> deferred : stopReason deferred - assistant --> checkpoint : stop / genuine length - assistant --> failure_drain : terminal error / retries exhausted / 2nd overflow - - tools --> tools : per-call intent + settlement - tools --> checkpoint : batch complete - - compaction --> checkpoint : resumeAfter restored - compaction --> failure_drain : overflow compaction declined or failed - - deferred --> deferred : poll returns pending - deferred --> tools : ready response with calls - deferred --> checkpoint : ready response without calls - deferred --> failure_drain : provider error - - failure_drain --> checkpoint : new user-context input applied - failure_drain --> finished : inbox drained (failed) - - checkpoint --> finished : abort reconciled (aborted) - finished --> [*] -``` - -Standalone operations: - -``` -compaction: deciding ──hook declines───────────→ finished(declined) - ──hook supplies result────→ finished(completed) - ──hook selects generation─→ generating ──→ finished(completed|failed) - -navigation: ready_to_commit ───────────────────→ finished(completed) - summary.deciding ──→ generating ───→ finished(completed) -``` - -## 3.6 Acceptance - -| From | Trigger | Transaction | -|---|---|---| -| idle lane | `prompt()` after `before_run` | `TX[ put new message values (caller prompt and hook injections), put nodes for captured nextRun values and new messages in order, setSlot lane.leaf, put Operation, S(run{captured settings, checkpoint need_assistant(false), trigger=newest node, skipInboxOnce, empty inbox}), L({currentOperationId: O, captured nextRun removed}) ]` | -| reserved idle lane | `compact()` with non-empty preparation | `TX[ put preparation P, put Operation, S(compaction{deciding, preparationValueId:P}), L({currentOperationId: O}) ]` | -| idle lane | unsummarized `navigateTree()` after validation | `TX[ put Operation, S(navigation{ready_to_commit}), L ]` | -| reserved idle lane | summarized `navigateTree()` with preparation | `TX[ put preparation P, put Operation, S(navigation{summary.deciding, preparationValueId:P}), L ]` | - -Captured `nextRun` items already have values; acceptance places their nodes and removes them from `pendingNextRun`. Their content is not re-serialized. - -Manual compaction first allocates its operation id and takes a process-local lane admission reservation, then reads preparation. Summarized navigation uses the same reservation while collecting/building branch preparation; unsummarized navigation needs none because validation and acceptance share one lane-line job. While reserved, competing operations receive `LaneBusy` naming that provisional id/kind and idle tree writes wait; `nextRun` and configuration changes may still commit because they do not move the leaf. Empty compaction preparation releases the reservation and returns `NothingToCompact` with no operation write. Non-empty preparation is accepted only against the unchanged reserved source leaf. Process death drops the reservation and leaves the lane idle. - -Pre-acceptance rejections write **nothing**: `LaneBusy`, `NothingToCompact`, `InvalidNavigation` (target is the current leaf, label on the root target, or summarize from root), `UnknownTarget` (non-null target missing), `MissingIdentities` (model, provider, or an active tool name does not resolve). Prompt allocates its operation id before `before_run` so hook idempotency keys are stable. The hook still runs before acceptance; if a concurrent caller wins the lane, its output and provisional id are discarded and no operation exists. - -**Acceptance must observe `currentOperationId === null`.** Because acceptance is on the lane mutation line, this is validation, not compare-and-swap. - -## 3.7 Assistant generation - -| From | Trigger | Transaction | To | -|---|---|---|---| -| checkpoint `need_assistant` | drive | conditionally snapshot current lane config and normalized retry policy in `TX[ S(assistant{ready, nextAttempt:1}) ]` | ready | -| assistant `ready` | `before_request` aggregate completes | `TX[ S(assistant{effect_pending, attempt=nextAttempt, reserved R/U, intendedOutputLimit, contextWindow}) ]` | effect_pending | -| effect_pending | settles with tool calls | `TX[ put response value, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, tools{plan with reserved result ids}) ]` | tools | -| effect_pending | retryable error, attempts remain | `TX[ put response, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, assistant{retry_wait, nextAttempt k+1, notBefore}) ]` | retry_wait | -| effect_pending | first overflow, preparation non-empty | `TX[ put response **normalized to error**, node R, leaf slot, usage U, put preparation P, S(latestAssistantNodeId=R, compaction{reason:overflow, structural:{deciding, taskId, preparationValueId:P}, resumeAfter:{checkpoint, prior trigger, need_assistant(true)}}) ]` | compaction | -| effect_pending | first overflow, preparation empty | `TX[ put normalized response, node R, leaf slot, usage U, S(latestAssistantNodeId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | -| effect_pending | `stopReason: "deferred"` | `TX[ put response, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, deferred{suspended, sourceNodeId R, poll 0, config/options copied}) ]` | deferred | -| effect_pending | `stop` or genuine `length` | `TX[ put response, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, checkpoint{may_finish, includeFinalAssistant:true}) ]` | checkpoint | -| effect_pending | terminal error, retries exhausted, or 2nd overflow | `TX[ put response, put node R, setSlot lane.leaf, put usage U, S(latestAssistantNodeId=R, failure_drain{error, provenance:response R}) ]` | failure_drain | -| retry_wait | `notBefore` elapsed | `TX[ S(assistant{ready, nextAttempt:k+1}) ]` | ready | - -**There is never a durable "response without usage" or "response and usage without a decision."** All three land together or none do. - -### Classification order - -Pure, computed in memory before the settlement transaction. First match wins. - -| Condition | Result | -|---|---| -| `control.status === "cancel_requested"` | normalize stop reason to `aborted`; commit `checkpoint{may_finish, includeFinalAssistant:true}` under cancelled control, then reconcile writes/finish | -| overflow: adapter-reported, or `error` whose message matches the context-limit patterns, or `length` with output below `intendedOutputLimit` | **normalize stop reason to `error`**; compact (first time) or `failure_drain` (second) | -| `deferred` with a valid handle | deferred suspended | -| retryable `error`, attempts remain / otherwise | retry_wait / failure_drain | -| `toolUse`, or an accepted response carrying calls | tools | -| `stop` or genuine output-limit `length` | checkpoint `may_finish` | - -Two normalizations happen at commit, and both are deliberate. A cancelled response commits as `aborted`. An overflow-classified response commits as `error`. In both cases the original stop reason is overwritten and the reason is preserved in human-readable form in `errorMessage`. - -The overflow normalization is what removes every link from this design. Because the committed response is `error`, §2.5 rule 3 drops it from context automatically — no superseded-response id on the compaction, none in the operation state, and no omission rule of its own. The response stays in the tree as durable history, because a provider request happened and was billed. - -**Overflow detection is a heuristic and must be labelled as one.** Three sources, in decreasing reliability: - -1. **Adapter-reported.** A provider adapter that can compute `usage.input + usage.cacheRead > contextWindow` at settlement sets `stopReason: "error"` with a message matching the context-limit patterns. This requires no new stop reason and no change to any adapter's stop-reason mapping, which matters because those mappings typically throw on unknown values. An adapter doing this should also require negligible output, so a substantive answer that merely trips a counter is not discarded. -2. **Error-message matching.** Providers usually return a context-limit failure as an HTTP error, which arrives as `error` with a message. Matching it is string matching, and it is brittle wherever it lives. -3. **`length` below `intendedOutputLimit`.** Harness-side only. An adapter must not apply this rule, because it cannot distinguish an oversized request from a response truncated mid-thinking — and those need opposite treatment, since a genuine truncation must stay in context. - -Overflow is checked before retryable error, so an oversized request compacts rather than retrying unchanged. - -**`aborted` is not a classification input.** It means the harness's own abort signal fired (§4.6), and `abort()` commits `control` before signalling — so a settled `aborted` response always has `control.status === "cancel_requested"` and is caught by the first row. An `aborted` response with `control.status === "running"` is unreachable and is corruption (invariant 17). - -An overflow classification never produces a tool plan. A *genuine* `length` that carries tool calls does produce the full plan, executes nothing, and appends one `isError: true` result per call explaining that truncation may have corrupted the arguments — those results then require another assistant turn. - -## 3.8 Tools - -| From | Trigger | Transaction | To | -|---|---|---|---| -| call *i* `planned` | clearance passed (`before_tool`, lookup, arg validation) | `TX[ put effective tool_args value, S(call i = effect_pending, argsValueId, replay) ]` | dispatch | -| call *i* `effect_pending` | effect settled, `after_tool` applied | `TX[ put result value, put node, setSlot lane.leaf, put tool usage (if reported), S(call i = completed, terminate) ]` | tools or checkpoint | -| call *i* `planned` | unknown tool / invalid args / `before_tool` blocks or throws / control cancelled | `TX[ put synthetic error result value, put node, setSlot lane.leaf, S(call i = completed, terminate from an intentional block, otherwise false) ]` | tools | -| all calls completed | — | folded into the last settlement | checkpoint | - -The batch's completion transition is: - -- **every** completed call set `terminate: true` → `checkpoint{may_finish, includeFinalAssistant: false}` -- otherwise → `checkpoint{need_assistant(overflowRecoveryUsed: false)}` - -`terminate` exists so a tool can end the run without another provider turn. The motivating case is a "submit final result" tool used in place of structured output: the model calls it, the harness commits the result, and the run finishes with those tool results as its final nodes — `run_end` then carries no `finalMessage`. Without this, every such run would pay for one more model turn whose only job is to stop. - -Modes: - -- **Sequential** (option, or any called tool declares `executionMode: "sequential"`): clear → intent → execute → finalize → commit, one call at a time. -- **Parallel** (default): clearance and intent commits happen in source order; dispatch does not await earlier calls; effects settle concurrently; phase 3, result-message lifecycle, and result commits are awaited and finalized in source order. - -Blocked and invalid calls skip the intent commit and the effect, but still commit a result at their source position. - -Calls are tracked internally by `sourceIndex`. Hooks, events, and tool context see the provider `toolCallId` and tool name — never the index. - -## 3.9 Summary generation — compaction and navigation summaries - -Both operations generate a summary through the same `deciding → generating → result` machinery, which is why they are specified together. The axes: - -| | compaction | navigation | -|---|---|---| -| **standalone operation** | `lane.compact()` — reason `manual` | `lane.navigateTree(target)` | -| **phase inside a run** | reasons `threshold`, `overflow` | — | - -| reason | who asked | on hook decline | -|---|---|---| -| `manual` | the caller | operation finishes `declined` | -| `threshold` | context-size check at a checkpoint | back to the stored `resumeAfter` | -| `overflow` | a request that did not fit | `failure_drain` | - -"Auto compaction" is the in-run row: `threshold` and `overflow`. Non-empty preparation and the transition into `deciding` commit together (`put preparation P` plus the structural state and, for threshold, marked `resumeAfter`). Preparation returning `undefined` never creates `StructuralDecision`: threshold atomically marks the checkpoint checked and continues; overflow atomically enters response-provenance `failure_drain` using the normalized overflow response. Neither path emits structural lifecycle. Empty standalone preparation is rejected before acceptance. - -| From | Trigger | Transaction | -|---|---|---| -| deciding | hook declines | standalone: `TX[ S(finished{declined}), L({currentOperationId: null}) ]` · threshold: `TX[ S(restore marked resumeAfter) ]` · overflow: `TX[ S(failure_drain{error, provenance:structural taskId}) ]` | -| deciding | hook supplies compaction | standalone: `TX[ hook usage?, result value, node, leaf slot, S(finished), L(currentOperationId:null) ]`; in-run: same result-publication writes plus `S(resumeAfter)` | -| deciding | hook supplies navigation summary | use §3.10's final transaction with the hook usage/result | -| deciding | hook selects generation | conditionally snapshot current config/policy in `TX[ S(generating{ready}) ]` — **the decision hook will never run again** | -| generating ready / retry elapsed | drive | `TX[ S(effect_pending, attempt k) ]` | -| generating effect_pending | one nested request returns | `TX[ put usage under request.usageValueId, S(effect_pending, request cleared, usageValueIds += id) ]`; commit another request intent before request two | -| generating effect_pending | retryable attempt outcome | usage is already durable; `TX[ S(retry_wait) ]` | -| generating effect_pending | terminal or attempts exhausted | standalone: `TX[ S(finished{failed}), L ]` · in-run: `TX[ S(failure_drain{provenance:structural taskId}) ]` | -| generating effect_pending | compaction succeeded | standalone: `TX[ result value, node, leaf slot, S(finished), L(currentOperationId:null) ]`; in-run: result-publication writes plus `S(resumeAfter)` | - -Structural provider streams are internal: they emit **no** public assistant-message lifecycle. The existing summary generator is retained, but its one/two request callback uses the nested request intent/effect/usage boundaries from §3.2 and §4.2. Intermediate content is not persisted; a crash before the final transaction makes the whole attempt unknown, and a later numbered attempt starts only under the captured retry policy. Failed-attempt usage stays in the ledger regardless. - -### Worked example — overflow - -`n_40` is a tool result awaiting an assistant turn. The request does not fit. - -``` -… n_38 ── n_39 ── n_40 phase: assistant, effect_pending - continuation was need_assistant(false) -``` - -**1. Settlement.** Classification says overflow. Preparation is built against the would-be branch; because the known response is normalized to `error`, ordinary projection excludes it. Response and preparation then commit together: - -``` -TX[ put response value { stopReason: "error", errorMessage: "context window exceeded: …" }, - put node n_41, setSlot lane.leaf → n_41, put usage u_41, - put structural_preparation p_41, - S(compaction{ reason: overflow, - structural: { deciding, taskId, preparationValueId: p_41 }, - resumeAfter: { checkpoint, triggerNodeId: n_40, - continuation: need_assistant(true) } }) ] - -… n_38 ── n_39 ── n_40 ── n_41 -``` - -**2. Compaction.** The durable preparation was built by the ordinary rules in §2.5. `n_41` is an `error` response, so rule 3 dropped it — from the summary input and from `retainedTail` alike, with no special case: - -``` -… n_40 ── n_41 ── n_42 (compaction) - retainedTail: [n_39, n_40] ← n_41 absent by rule 3 -``` - -The tail ends on `n_40`, a tool result, which is the correct shape for a request that is about to ask for an assistant turn. - -**3. Resume.** `resumeAfter` restores `need_assistant(overflowRecoveryUsed: true)`. Context is now summary + tail + anything after `n_42`, which is small: - -``` -… n_41 ── n_42 ── n_43 the answer to n_40 - ✗ (error, out of context) -``` - -`n_41` remains in the tree forever as durable history — a request was made and billed. If the retry overflows *again*, `overflowRecoveryUsed` is already `true` and the run goes to `failure_drain` rather than compacting in a loop. Consuming new user input appends to the tree and resets the flag to `false`. - -## 3.10 Navigation - -Unsummarized and summarized both finish in **one** transaction: - -``` -TX[ put hook-reported usage (only for a hook-supplied summary), - setSlot lane.leaf → target, - put summary value + node with its display usage snapshot (when summarize; parent is target), - setSlot lane.leaf → summary node (when summarize), - put label fact value + setSlot fact.label (when a label is present), - S(finished{completed, leafId}), - L({currentOperationId: null}) ] -``` - -Writes apply in order inside the transaction. Generated provider usage was already written per request in §3.9 and is not written again here; the summary payload only snapshots its producing attempt's usage. The summary node explicitly names the target as parent, and the following slot write makes that summary the completed lane leaf. A crash sees either an untouched navigation still at its source, or a fully completed one. **No prepared-summary state and no post-move recovery state exist.** Abort before this transaction finishes `aborted` with no node; abort after it means the operation completed. - -## 3.11 Inbox, queues, deferred writes - -| Public input | Admitted when | Transaction | -|---|---|---| -| `nextRun(msg)` | any state, including idle | `TX[ put message value, L(pendingNextRun += {nodeId, valueId}) ]` — never starts a run | -| `steer(msg)` | active running run | `TX[ put message value, S(inbox.steer += item) ]` | -| `followUp(msg)` | active running run | `TX[ put message value, S(inbox.followUp += item) ]` | -| tree write, run active | including suspended and cancelling | `TX[ put value, S(inbox.writes += item) ]` — survives abort | -| tree write, lane idle | idle | `TX[ put value, put node, setSlot lane.leaf ]` | -| tree write, structural op open | — | wait for the operation to end, then re-evaluate | -| `cancelQueued(id)` | item still pending | `TX[ S or L with the item removed, put queue disposition, setSlot queue.disposition/{id} ]` | -| checkpoint consumes input | eligible | `TX[ put node(s), setSlot lane.leaf, S(items removed, continuation → need_assistant(false), triggerNodeId = newest node, skipInboxOnce = true) ]` | -| first `abort()` | run active | `TX[ S(control = cancel_requested, requestedAt, drainedSteer, drainedFollowUp, steer/followUp emptied), dispositions for every drained item ]` | -| finish | inbox empty, no required continuation | `TX[ S(finished), L({currentOperationId: null}) ]` | - -`cancelQueued` outcomes: pending → cancel and write its disposition; node exists → `already_consumed`; disposition slot exists → `already_cleared`; none of those → `UnknownQueueItem`. Dispositions are queried only by this exact public lookup and never participate in restore. - -Because acceptance, cancellation, consumption, abort, and finish all serialize on the lane mutation line, every race has exactly two possible histories, and **no item can be both pending and applied** in durable state. - -## 3.12 The checkpoint procedure - -Order matters. At each queue drain point, `"all"` consumes every currently eligible item in acceptance order; `"one-at-a-time"` consumes only the oldest and leaves the rest pending. Any projecting drain sets durable `skipInboxOnce`; on that next pass the planner skips steps 1–2, starts generation, and clears the flag in the ready-state transition. Thus a crash cannot turn one-at-a-time into an all-item drain. - -1. Unless `skipInboxOnce`, atomically apply accepted deferred writes. -2. Unless `skipInboxOnce`, atomically consume eligible steering, per the steering mode. -3. Run threshold compaction only when `thresholdCheckedTriggerNodeId !== triggerNodeId`, preserving the marked checkpoint in `resumeAfter`. -4. If the continuation is `need_assistant`, start generation and clear `skipInboxOnce`. -5. Once assistant and tool continuation are exhausted, atomically consume eligible follow-up. -6. If the continuation is `may_finish` and the inbox is empty, invoke `before_run_end`. -7. Conditionally finish. - -Consumed steer/follow-up and projecting message writes enter `need_assistant(false)`, set `triggerNodeId` to the newest appended node, and set `skipInboxOnce`. Tool results do the same unless every result terminates. An unprojected custom write is appended and removed from the inbox but preserves the prior continuation, failure provenance, and overflow flag. Under cancelled control, every deferred write is appended and removed without changing phase/continuation or starting work; reconciliation finishes aborted after writes drain. - -`before_run_end` may return a follow-up. It commits **only** if control is still running and the operation is still at the same finish boundary; otherwise the stale hook result is dropped. Its value, node, and the `need_assistant` state commit together. - -`failure_drain` applies accepted writes, then eligible steer and follow-up input in the same order. Projecting user-context input atomically enters `checkpoint{need_assistant(false)}` and clears the failure. Unprojected custom writes do not. With no such input, it finishes failed without `before_run_end` or another provider request. - ---- - -# Part 4 — Execution, recovery, abort, close - -## 4.1 The interpreter - -The runtime plans from total durable state plus a small process-local scheduler. Immutable payloads and context named by the state are batch-loaded before planning. The driver also snapshots current settings revision and registry leases (`Models.lease` and active tool definitions) into `RuntimeSnapshot`; this performs no provider request. When a tool batch first becomes current, the driver resolves `toolContext` once, binds the batch's definitions, and retains them in `DriveState.toolBatches` for every sequential/parallel call in that batch. `nextAction` is then pure over those inputs. Pre-intent hook plans retain the exact lease used for lookup, preparation, schema validation, and eventual dispatch. - -```ts -interface CurrentOperation { - operation: Operation; - operationStateValueId: string; - state: OperationState; - laneStateValueId: string; - laneState: LaneState; - leafId: string | null; - configurationValueId: string; -} - -type EffectKey = string; // deterministic from durable step/attempt or assistant/sourceIndex - -/** Process-local leases captured before intent; never persisted or exposed. */ -type RuntimeProviderLease = ModelRequestLease; -interface RuntimeToolLease { tool: AgentTool } -interface RuntimeAssistantLease { - provider: RuntimeProviderLease; - activeTools: AgentTool[]; -} - -interface LiveEffect { plan: EffectPlan; promise: Promise } - -interface DriveState { - deferredPollsRemaining: 0 | 1; - running: Map; - /** One context/tool-definition snapshot per live or restored batch. */ - toolBatches: Map>; - /** Process-local best-effort attempts; reopen may attempt again. */ - deferredCancellations: Set; -} - -type EffectPlan = { telemetryContext: TelemetryContext } & ( - | { kind: "assistant"; key: EffectKey; - generation: Extract; - streamOptions: AgentHarnessStreamOptions; identity: RuntimeAssistantLease } - | { kind: "summary"; key: EffectKey; - generation: Extract; - identity: RuntimeProviderLease } - | { kind: "tool"; key: EffectKey; assistantNodeId: string; - sourceIndex: number; argsValueId: string; identity: RuntimeToolLease } - | { kind: "deferred"; key: EffectKey; - deferred: Extract; - streamOptions: AgentHarnessStreamOptions; identity: RuntimeProviderLease } - | { kind: "cancel_deferred"; key: EffectKey; sourceNodeId: string; - handle: DeferredHandle; identity: RuntimeProviderLease } - | { kind: "hook"; key: EffectKey; name: keyof HookMap; event: unknown; - /** Pre-intent hooks carry the exact lease used to prepare their event. */ - identity?: RuntimeProviderLease | RuntimeAssistantLease | RuntimeToolLease } -); - -type SummaryAttemptOutcome = - | { kind: "success"; result: CompactResult | BranchSummaryResult } - | { kind: "retry" | "failure"; error: OperationError }; - -type EffectOutput = - | { kind: "not_started"; key: EffectKey } - | { kind: "assistant" | "deferred"; key: EffectKey; - message: SettledAssistantMessage } - | { kind: "summary"; key: EffectKey; outcome: SummaryAttemptOutcome } - | { kind: "tool_raw"; key: EffectKey; - result: AgentToolResult; isError: boolean } - | { kind: "hook"; key: EffectKey; result: unknown } - | { kind: "cancel_deferred"; key: EffectKey }; - -type SettlementOutput = Exclude | - { kind: "tool"; key: EffectKey; result: AgentToolResult; - isError: boolean; terminate: boolean }; - -interface SettlementResult { - current: CurrentOperation; - /** Immediate live dispatch prepared by a successful pre-intent hook. */ - dispatch?: EffectPlan; - /** Identity resolution failed while durable state was still safely dispatchable. */ - suspend?: OperationResult; - /** Poll intent committed; consume this resume invocation's sole permit. */ - consumeDeferredPoll?: true; -} - -interface RuntimeSnapshot { - settingsRevision: number; - streamOptions: AgentHarnessStreamOptions; - retryPolicy: NormalizedRetryPolicy; - providerLeases: ReadonlyMap; - toolLeases: ReadonlyMap; -} - -type PlannerInputs = { - /** Exact process-local plans; never reconstruct a live plan from durable ids. */ - running: ReadonlyMap; - deferredPollsRemaining: 0 | 1; - deferredCancellations: ReadonlySet; - immutable: ReadonlyMap; - runtime: RuntimeSnapshot; - context?: AgentMessage[]; - now: number; -}; - -type OperationResult = RunOutcome | CompactionOutcome | NavigationOutcome; - -type Action = - | { kind: "transition"; next: OperationState; telemetryContext: TelemetryContext; - /** Required when this transition snapshots current mutable request state. */ - expectedConfigurationValueId?: string; - expectedSettingsRevision?: number } - | { kind: "dispatch"; intent?: OperationState; effect: EffectPlan; - consumeDeferredPoll?: true } - | { kind: "await_effect"; key: EffectKey } - | { kind: "wait"; until: number; telemetryContext: TelemetryContext } - | { kind: "suspend"; result: OperationResult } - | { kind: "done"; result: OperationResult }; - -async function drive(current: CurrentOperation, live: DriveState): Promise { - while (true) { - const inputs = await loadPlannerInputs(current, live); // bounded immutable reads - const action = nextAction(current.state, inputs); // pure and exhaustive - - switch (action.kind) { - case "transition": { - const committed = await commitTransitionIfCurrent( - current, action.next, action.telemetryContext, - action.expectedConfigurationValueId, action.expectedSettingsRevision); - current = committed ?? await reloadCurrent(current.operation.operationId); - break; - } - - case "dispatch": { - if (action.intent) { - const committed = await commitTransitionIfCurrent( - current, action.intent, action.effect.telemetryContext); - if (!committed) { - current = await reloadCurrent(current.operation.operationId); - break; // a lane mutation won; do not dispatch - } - current = committed; - } - if (action.consumeDeferredPoll) live.deferredPollsRemaining = 0; - if (action.effect.kind === "cancel_deferred") - live.deferredCancellations.add(action.effect.sourceNodeId); - live.running.set(action.effect.key, - { plan: action.effect, promise: fx.run(action.effect) }); - break; // permits source-ordered parallel dispatch - } - - case "await_effect": { - const liveEffect = live.running.get(action.key); - if (!liveEffect) throw new Error("planned effect is not running"); - const { plan } = liveEffect; - const output = await liveEffect.promise; - live.running.delete(action.key); - if (plan.kind === "cancel_deferred") { - current = await reloadCurrent(current.operation.operationId); // no durable write - break; - } - let settlement: SettlementOutput; - if (output.kind === "tool_raw") { - if (plan.kind !== "tool") throw new Error("tool output/plan mismatch"); - settlement = await fx.finalizeTool(plan, output); // source-ordered after_tool - } else { - settlement = output; // not_started settles synthetically without hooks - } - const settled = await commitEffectSettlement( - current, plan, settlement, plan.telemetryContext); - current = settled.current; - if (settled.suspend) return settled.suspend; - if (settled.consumeDeferredPoll) live.deferredPollsRemaining = 0; - if (settled.dispatch) - live.running.set(settled.dispatch.key, - { plan: settled.dispatch, promise: fx.run(settled.dispatch) }); - break; - } - - case "wait": - await fx.sleep( - Math.max(0, action.until - Date.now()), action.telemetryContext); - current = await reloadCurrent(current.operation.operationId); - break; - - case "suspend": - case "done": - return action.result; - } - } -} -``` - -An intent/ordinary transition requires `op.state` still to target its expected source state value; otherwise it returns `undefined` and the loop replans without dispatch. A successful `before_request`/`before_tool` hook settlement uses its retained identities, atomically commits the effect intent (and effective tool args), and returns the complete process-local dispatch plan; the drive installs that promise immediately. A crash in the remaining process-only gap is conservatively the ordinary unknown-effect case. A transition that creates a generation/summary `ready` state also supplies the lane-config slot and harness-settings revision it read; the settings/lane commit requires both still match, giving setter-first or step-start-first ordering. The resulting context durably captures normalized retry and base stream options. Immediately before ordinary external execution, `fx.run` enters the lane mutation line once more: cancellation-first returns `not_started`, while start-first registers the live effect/controller so a later abort signals it. This check uses the already captured identity lease and never re-resolves the registry. Thus no effect starts in the gap after intent without belonging to one of the two serialized orders. Settlement reloads latest total state, verifies the same effect key remains pending, merges the output into that state, and applies current cancellation control. Thus steer/write acceptance, abort, and other parallel-tool intents cannot erase a live result or overwrite newer inbox/control state. - -Parallel tool calls dispatch phase two in source order into `DriveState.running`. The planner may dispatch later calls while earlier promises run, but it emits `await_effect` only for the first incomplete source position. That raw result then crosses source-ordered `fx.finalizeTool`/`after_tool` before settlement. A later settled raw promise remains process-local until its turn. After restart `running` is empty, so durable `effect_pending` follows recovery policy rather than being mistaken for a live effect. - -Recovery rules: - -- `not_started` under cancelled control settles assistant/fetch under reserved ids as `aborted`, settles a tool with its planned aborted result without `after_tool`, drops an uncommitted hook decision, discards structural work before finishing aborted, and drops a stale deferred-cancel action without settlement; -- ready generation/summary and cleared tools commit `effect_pending` before `dispatch`; -- restored generation/summary pending with no live key advances under captured retry policy or settles synthetically at the cap; -- restored tools replay only when persisted and current declarations are `safe`, otherwise settle interrupted; -- restored deferred pending normally suspends until an application `resume()` replaces it with one fresh poll intent; cancelled control instead settles the existing reserved response/usage ids synthetically as `aborted` before finishing; -- committing a deferred intent through its `before_request` settlement returns `consumeDeferredPoll:true`; the drive clears the invocation's sole permit before installing dispatch, so a pending response re-suspends rather than polling again; -- retry wait crosses `fx.sleep`, which is visible to manual drive and reloads cancellation afterward; -- structural decision hooks run from `deciding`; their consumer transaction either finishes the structure or records `generating`, so only a pre-commit crash reruns them. - -A fresh operation drive starts with zero deferred permits; `resume()` starts with one. Repairs and non-poll work do not consume it. - -## 4.2 The effects boundary - -Every operation-procedure commit, provider request, tool invocation, hook call, and timer crosses exactly one injected `Effects` (`fx`) method. Procedures receive `fx`, their telemetry context, and a read-only runtime view — never `Session`, `Models`, the tool registry, or the hook runner directly. Ungated lane-surface commits—acceptance, queue/configuration calls, facts, lane creation, and idle writes—use the same lane mutation line and typed `Session` transaction API directly. - -```ts -type SummaryRequestOutput = - | { kind: "response"; message: SettledAssistantMessage } - | { kind: "not_started" }; - -interface Effects { - commitTransition(current: CurrentOperation, next: OperationState, - telemetry: TelemetryContext, - expectedConfigurationValueId?: string, - expectedSettingsRevision?: number): - Promise; - commitEffectSettlement(current: CurrentOperation, plan: EffectPlan, - output: SettlementOutput, telemetry: TelemetryContext): - Promise; - /** Runs after_tool for the raw phase-two result selected in source order. */ - finalizeTool(plan: Extract, - output: Extract): - Promise>; - /** Composite summary plans use this reentrantly for each provider request. */ - runSummaryRequest(plan: { taskId: string; attempt: number; requestIndex: number; - usageValueId: string; configurationValueId: string; - messages: AgentMessage[]; identity: RuntimeProviderLease; - telemetryContext: TelemetryContext }): - Promise; - settleSummaryRequest(current: CurrentOperation, - plan: { taskId: string; attempt: number; requestIndex: number; - usageValueId: string }, - response: SettledAssistantMessage, - telemetry: TelemetryContext): Promise; - /** Revalidates/registers effect start on the lane mutation line before execution. */ - run(plan: EffectPlan): Promise; - sleep(delayMs: number, telemetry: TelemetryContext): Promise; -} -``` - -The commit helpers shown in §4.1 delegate to these methods. Expected provider, tool, structural, and deferred-cancel failures return in-band `EffectOutput` variants; `run` rejects only for close, harness fault, or invariant defects. `cancel_deferred` is the explicit exception to ordinary start/settlement: its start check requires the same open cancelled operation and the process-local source target registered by `abort()` (the durable phase may already have advanced), uses a close-only signal rather than the already-pulled operation signal, and its awaited output bypasses `commitEffectSettlement` with no durable write. Automatic effects execute directly; manual effects gate the same calls. Passive event-listener delivery is observation, not an interpreter effect: it is isolated and telemetry-wrapped after publication but never parked by manual drive. `sleep` resolves early when the harness signal is pulled, after which the loop reloads cancellation control. For split-turn summary work, request-intent `commitTransition`, `runSummaryRequest`, and usage/state `settleSummaryRequest` are three distinct nested gated actions. `runSummaryRequest` performs the same serialized start check as `run`; abort-first returns `not_started`, leaves no usage, and makes the outer summary plan return its own `not_started` settlement, which discards structural work under cancelled control. The outer summary orchestration action is only process-local composition; manual drive and crash tests still stop between each nested boundary. These methods are the complete procedure crash-site catalog; ungated public mutations are the race boundaries in §7.2. - -**The provider signal is harness-owned.** `fx` supplies the `AbortSignal` passed to every provider request. No caller can supply one: `signal` is absent from the options type at every public surface (§5.2), and the harness strips any signal from a `streamOptions` patch before dispatch. Only `abort()` and `close()` can pull it. This is what makes §4.6's guarantee hold. - -**Manual drive.** With `drive: "manual"` the harness parks before each effect and exposes one JSON-safe action at a time: - -```ts -peekAction(): Promise; // stable, side-effect free -executeAction(): Promise; // release exactly one -runToCompletion(): Promise; -``` - -Lane-surface calls—including operation acceptance, `steer`, `abort`, config setters, and tree writes—stay **ungated**, so a test can drive both orders of any race. In manual mode a `before_run` handler parks before acceptance; with no handler, acceptance commits immediately and the first parked action is the run's first procedure transition. The gate is reentrant: nested `fx` calls (notably request hooks inside a stream) park independently, and the driver releases them before their parent continues. Closing while an action is parked rejects it unexecuted; durable state is exactly the committed prefix. - -Enforced by construction and by a test: an operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked. - -## 4.3 The lane mutation line - -Every state-dependent mutation on a lane is linearized: validate, at most one atomic commit, and the in-memory update complete before the next mutation starts. Provider, tool, hook, and retry work never occupies the line. - -What serializes here: operation acceptance, queue enqueue and cancel, queue consumption, deferred-write acceptance and application, abort, lane-configuration setters, finish, lane creation. Harness-global stream/retry/compaction/queue settings use a second mutation line with a monotonically increasing process revision. Operation acceptance and generation/summary starts snapshot settings by taking the settings line before the lane line and conditionally committing both expected tokens; global setters take only the settings line. No code acquires them in the reverse order. - -Consequence: every race between two public calls has exactly **two** possible durable histories, and both must be tested (§7.2). - -## 4.4 Restore - -```ts -async function restore(lane: string): Promise< - { kind: "idle"; lane: string } | { kind: "suspended"; current: CurrentOperation } -> { - const configSlot = await storage.getSlot("lane.config", lane); - const stateSlot = await storage.getSlot("lane.state", lane); - const leafSlot = await storage.getSlot("lane.leaf", lane); - const slots = { configSlot, stateSlot, leafSlot }; - - const laneRoots = await storage.getValues([configSlot.targetId, stateSlot.targetId]); - const laneState = laneRoots.get(stateSlot.targetId)!.payload; - - let opRoots = new Map(); - if (laneState.currentOperationId) { - const opStateSlot = await storage.getSlot("op.state", laneState.currentOperationId); - opRoots = await storage.getValues([ - laneState.currentOperationId, opStateSlot.targetId - ]); - } - - const roots = merge(laneRoots, opRoots); - const valueIds = directValueIds(roots); // includes pendingNextRun content - const nodeIds = directMaterializedNodeIds(roots, leafSlot.targetId); - const [values, nodes] = await Promise.all([ - storage.getValues(valueIds), storage.getNodes(nodeIds) - ]); - validateCurrent(slots, roots, values, nodes); - - if (!laneState.currentOperationId) return idle(lane, laneRoots, nodes, laneState); - return suspended({ operation: ..., state: ... }); // drive() resumes it -} -``` - -That is the entire current-state restore path: three lane slots, the operation-state slot, root value lookup, then bounded batched value/node lookups for exactly the immutable values and nodes directly named by current lane/operation state. Restore performs §3.3's bounded validation over that set. It does not fold history, build provider context, probe for missing planned nodes, or audit completed operations. - -Restore already fetched directly referenced immutable values for validation. The driver reuses/caches them and lazily builds only derived provider context or additional branch projections needed by the next action; `nextAction` itself switches on scalars and supplied immutable maps. - -Admission resolves configured identities and returns `Err(MissingIdentities)` before writing when any are absent. Each later assistant, deferred, tool, or whole-summary-attempt preparation snapshots process-local provider/tool leases before its pre-intent hook. That registry/settings-line snapshot is the step-start order: lookup, `prepareArguments`, schema validation, hook event, intent, and dispatch all retain the same lease even if the registry is replaced while the hook runs. Both split-summary requests share the attempt's lease. If resolution fails while state is still safely dispatchable (`ready`, `planned`, or between summary requests), the accepted call resolves `Ok({kind:"suspended", reason:"missing_identities", ...})`; state is unchanged and the operation stays open. A later `resume()` precheck returns `Err(MissingIdentities)` on the same condition. Registering missing pieces does not auto-drive. Restored `effect_pending` has no lease and follows unknown-effect recovery rather than claiming the effect never started. Synthetic settlement, usage repair, queue application, finish, and non-replay reconciliation need no identities. - -## 4.5 Crash positions and recovery policy - -Atomic transactions have no internal prefix, so for any repeat-sensitive effect there are exactly these durable positions: - -| Crash point | What is durable | Recovery | -|---|---|---| -| before the intent commit | the previous state | plan the effect normally, as if nothing happened | -| after intent, before dispatch | `effect_pending`; the effect did not run, or you cannot tell | apply the policy below | -| during or after the effect, before settlement | `effect_pending`; the outcome is unknown | same | -| after the settlement commit | output + usage + next state | continue; never re-settle | -| before / after a queue-application commit | the item is fully pending / the node exists and the item is gone | apply later / never apply twice | -| before the final structural commit | source leaf intact, generated work uncommitted | recompute per the current state and policy | -| after the final structural commit | move + node + label + usage + finished state | done | -| after the first abort commit | cancellation and drained payloads durable | start no new ordinary effects; reconcile | -| after the terminal commit | finished state and cleared lane pointer | the lane is idle | - -**The one uncertain interval in the entire system is: intent durable, settlement absent.** Three policies cover it: - -| Restored state | Policy | -|---|---| -| generation `effect_pending` | start a later numbered attempt only if the **captured** retry policy allows. Otherwise persist a synthetic error under the already-reserved response id. If cancellation is durable, persist synthetic `aborted` under that id instead, and never retry. | -| tool `effect_pending` | re-execute the persisted `argsValueId` only if the stored declaration **and** the current tool declaration both say `safe`. Otherwise append a synthetic `interrupted` error under the reserved result id. | -| deferred `effect_pending` | with running control, wait for the application's next `resume()`, which reserves fresh poll/response/usage ids; with cancelled control, synthetically settle the existing reserved response/usage ids as `aborted`. No cap. | - -## 4.6 Abort - -Abort is not a phase. It is `control`. - -- **First `abort()`**: one commit sets `control = cancel_requested`, records `requestedAt`, stores the exact drained steer and follow-up payloads, and leaves `phase` untouched. After it commits, the harness pulls the signal and cancels unreleased gated effects. The call resolves once the marker is durable; reconciliation runs in the background (automatic drive) or parks at its next action (manual drive). -- **Later `abort()`** while the operation is open: appends nothing, signals nothing, returns the same drained payloads. After the terminal state: `NoActiveOperation`. -- **Still allowed after cancellation**: settling effects that were already intended, writing their usage, applying accepted deferred writes, committing configuration changes, and completing the cancellation. -- **Forbidden**: starting any new provider request, tool, decision hook, or retry. -- **Post-effect hooks**: abort and a not-yet-started `after_response`/`after_tool` serialize on the effect-start check. Abort-first skips the hook; assistant/fetch settlement uses the raw response then normalizes it to `aborted`, while a live tool keeps its raw result with `terminate:false`. Hook-first lets it finish and uses its transformed value. A hook already running is not forcibly interrupted. -- **Per-output reconciliation**: planned tool calls get an aborted error result; restored started calls get `interrupted`; live started calls keep their finalized or raw result as above; an assistant or fetch settlement after cancellation is stored under the reserved response id with stop reason `aborted` and moves to cancelled checkpoint state. - -**Signal ownership makes `aborted` unambiguous.** Provider implementations must set `stopReason: "aborted"` if and only if the signal they were given was pulled, and the harness owns that signal exclusively (§4.2). Since `abort()` commits `control` before pulling it, a settled `aborted` response always has cancellation already durable. Timeouts, transport failures, malformed streams, and provider-side refusals all settle as `error` and take the ordinary retry path — which is correct, because those should retry and a user abort should not. An `aborted` response with `control.status === "running"` is unreachable; if one exists, the session is corrupt (invariant 17). - -On a deferred source, the `abort()` lane job registers the newest persisted handle/lease as a process-local cancellation target and immediately installs `EffectPlan{kind:"cancel_deferred"}` in `DriveState.running`, even when the drive is awaiting a live fetch. It is the one external action permitted to start under cancelled control, remains valid if fetch settlement advances the durable phase, crosses normal manual gating and `pi.ai.request`, calls the leased `cancelDeferred`, converts success/failure to an in-band output, and never writes operation state. Cancellation reconciliation awaits/removes that live plan before terminal finish. Failure is telemetry only and never blocks finish. `deferredCancellations` prevents repetition in one process; crash/reopen during reconciliation may retry. Missing provider identity skips cancellation but not durable reconciliation. - -There is no universal assistant closure. The harness never starts a request or appends an assistant message solely to manufacture one. An abort between steps, during tool work, or while suspended can therefore produce no abort-specific assistant event at all. - -For structural operations the commit point decides the race: a marker committed first discards in-memory generated work and finishes `aborted`; if the structural commit won, the procedure completes that already-committed compaction or navigation and finishes `completed`. - -## 4.7 Close — a controlled crash - -**Close is not abort.** Close writes nothing: no cancellation, no terminal state, no settlement. - -``` -close() - → stop admitting new work - → pull the signal, so in-flight provider requests and cooperative tools stop - → reject parked manual actions and unresolved local promises - → let commits already accepted by storage drain - → close storage, release the writer lease (§1.6) -``` - -A harness-wide admission barrier linearizes close against every operation and surface commit. A commit that acquires admission first is allowed to finish and close waits for it; close that seals admission first prevents the commit from entering storage. A stream cut after sealing settles locally as `aborted`, but its settlement transaction is never admitted. Durable state therefore stops at `effect_pending`, exactly as after process death. - -So close needs no recovery machinery of its own: reopening finds `effect_pending` and applies the §4.5 policy — a later numbered attempt under the captured retry policy, or a synthetic error at the cap. Open operations remain open and resumable. - -This also keeps invariant 17 true. Close pulls the same signal as abort, but the sealed admission barrier prevents that locally aborted response from committing with running control. - -## 4.8 Faults - -A failed storage commit faults the whole harness. A faulted harness stops all effects and rejects pending and future calls with `HarnessFault`; it is never an `Err` result. `faulted: true` appears in snapshots obtained before the fault closes observation. After the cause is fixed, reopening restores each lane from its slots. Close likewise rejects already-accepted local operation promises with `HarnessClosed`; calls not yet accepted return `Err(Closed)`. Provider, tool, and isolated hook failures remain per-lane and in-band. A throw/rejection from a trusted deterministic application computation (`systemPrompt`, `toolContext`, `toProviderMessages`, or a `nodeProjector`) is an application defect and faults the harness; it never escapes as an undeclared operation error. `AgentTool.prepareArguments` is the deliberate exception handled by the tool pipeline as a synthetic tool error. - ---- - -# Part 5 — Public surface - -## 5.1 The lane surface - -Expected rejection returns `Result.err`. Accepted operations return `Result.ok`, including failed, aborted, and suspended outcomes. Storage faults, close during accepted work, and invariant defects reject the promise. - -```ts -interface AgentLane { - readonly name: string; - getLeafId(): Promise; - - prompt(text: string, images?: ImageContent[]): Promise; - prompt(message: AgentMessage | AgentMessage[]): Promise; - skill(name: string, additionalInstructions?: string): Promise; - promptFromTemplate(name: string, args?: string[]): Promise; - compact(options?: { customInstructions?: string }): Promise; - navigateTree(targetId: string | null, options?: NavigateOptions): Promise; - resume(): Promise; - abort(): Promise; - - steer(message: string | AgentMessage, images?: ImageContent[]): Promise; - followUp(message: string | AgentMessage, images?: ImageContent[]): Promise; - nextRun(message: string | AgentMessage, images?: ImageContent[]): Promise; - cancelQueued(nodeId: string): Promise; - - recordUsage(usage: Usage, options?: { nodeId?: string; details?: JsonValue }): - Promise; - waitForIdle(): Promise; - runWhenIdle(callback: () => void | Promise): Promise; - - peekAction(): Promise; - executeAction(): Promise; - runToCompletion(): Promise; - - /** Undefined when the durable provider/model identity is not registered. */ - getModel(): Promise; - setModel(model: Model): Promise; - getThinkingLevel(): Promise; setThinkingLevel(l: ThinkingLevel): Promise; - getActiveTools(): Promise; setActiveTools(names: string[]): Promise; - - session: SessionTree; - watch(): Promise>; -} - -interface NavigateOptions { summarize?: boolean; label?: string; customInstructions?: string } -interface ActionInfo { kind: string; description: string; details?: JsonValue } -interface WatchHandle { snapshot: T; start(listener: EventListener): void; unsubscribe(): void } -``` - -Skill/template expansion precedes storage. Prompt intent names only normalized caller messages, excluding captured `nextRun` and hook injections. - -`waitForIdle()` registers on the lane mutation line and resolves when all earlier admitted lane jobs have settled, `currentOperationId` is null, and no process-local operation/admission reservation is held. Later operations may start immediately after it resolves. Multiple waiters resolve together; close/fault rejects pending waiters. - -`runWhenIdle(callback)` waits by the same rule, then takes a process-local lane admission reservation for the callback. The reservation is released on return or throw; callback rejection propagates. The callback must not invoke a state-mutating method on the same lane, which would deadlock behind its own reservation. Close rejects callbacks not yet started and waits for an already-running callback, which cannot be forcibly interrupted. - -### Results and errors - -```ts -type Result = { ok: true; value: T } | { ok: false; error: E }; -type Tagged> = - Error & { readonly _tag: Tag } & Readonly

; - -type OptionalFinalAssistant = - | { finalNodeId: string; finalMessage: AssistantMessage } - | { finalNodeId?: never; finalMessage?: never }; - -type MissingIdentitySuspension = { - kind: "suspended"; reason: "missing_identities"; - missing: { tools: string[]; models: string[] }; -}; - -type RunOutcome = - | ({ kind: "completed"; leafId: string } & OptionalFinalAssistant) - | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) - | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) - | { kind: "suspended"; reason: "deferred"; leafId: string; - finalNodeId: string; deferred: DeferredHandle } - | (MissingIdentitySuspension & { leafId: string }); - -type CompactionOutcome = - | { kind: "completed"; leafId: string; node: CompactionNode } - | { kind: "declined" | "aborted"; leafId: string } - | { kind: "failed"; leafId: string; error: OperationError } - | (MissingIdentitySuspension & { leafId: string }); - -type NavigationOutcome = - | { kind: "completed"; oldLeafId: string | null; newLeafId: string | null; - summaryNode?: BranchSummaryNode } - | { kind: "declined" | "aborted"; leafId: string | null } - | { kind: "failed"; leafId: string | null; error: OperationError } - | (MissingIdentitySuspension & { leafId: string | null }); - -type ResumeOutcome = - | ({ operation: "run"; runId: string } & RunOutcome) - | ({ operation: "compaction"; runId: string } & CompactionOutcome) - | ({ operation: "navigation"; runId: string } & NavigationOutcome); -``` - -A completed run may omit final assistant fields when every finalized tool result terminates. The two fields are always both present or both absent. - -Expected errors use the existing `TaggedError` implementation in `harness/result.ts`: - -| tag | fields beyond `message` | -|---|---| -| `LaneBusy` | `lane`, `operationId`, `operationKind` | -| `MissingIdentities` | `lane`, `tools`, `models` | -| `NoActiveRun`, `NoActiveOperation`, `NothingToResume`, `NothingToCompact` | `lane` | -| `InvalidMessage`, `InvalidNavigation` | `lane`, `reason` | -| `UnknownSkill`, `UnknownTemplate` | `name` | -| `UnknownTarget` | `targetId` | -| `UnknownQueueItem` | `lane`, `nodeId` | -| `LaneExists`, `InvalidLane` | `lane` (`InvalidLane` also has `reason`) | -| `Closed` | none | - -```ts -type RunResult = Result<{ runId: string } & RunOutcome, - LaneBusy | MissingIdentities | InvalidMessage | UnknownSkill | UnknownTemplate | Closed>; -type CompactionResult = Result<{ runId: string } & CompactionOutcome, - LaneBusy | MissingIdentities | NothingToCompact | Closed>; -type NavigationResult = Result<{ runId: string } & NavigationOutcome, - LaneBusy | MissingIdentities | InvalidNavigation | UnknownTarget | Closed>; -type ResumeResult = Result; -type QueueResult = Result<{ nodeId: string }, NoActiveRun | InvalidMessage | Closed>; -type NextRunResult = Result<{ nodeId: string }, InvalidMessage | Closed>; -type CancelQueuedResult = Result< - { kind: "cancelled" | "already_consumed" | "already_cleared" }, UnknownQueueItem | Closed>; -type AbortResult = Result<{ runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, - NoActiveOperation | Closed>; -type RecordUsageResult = Result<{ valueId: string }, Closed>; - -class HarnessFault extends Error { - readonly cause: unknown; - constructor(message: string, cause: unknown) { super(message); this.cause = cause; } -} -class HarnessClosed extends Error {} -``` - -`runId` is the operation's durable `operationId`; the public name remains for compatibility. `HarnessFault` and `HarnessClosed` reject promises; they are not tagged expected errors and not members of these unions. - -## 5.2 The harness - -```ts -class AgentHarness - implements AgentLane { - /** Initializes an unconfigured main when needed, then restores every lane - without starting provider, tool, hook, or timer effects. One suspension - descriptor per lane with an open operation. */ - static create(options: AgentHarnessOptions): Promise<{ - harness: AgentHarness; - suspended: SuspendedOperation[]; - }>; - - lane(name: string): Promise; // lookup, never creates - createLane(name: string, at: string | null): Promise>; - lanes(): Promise; // always includes "main" - - // Harness-global. Tool implementations are code and cannot persist; active - // names live in each lane's configuration. setTools replaces only the registry. - getTools(): Promise[]>; - setTools(t: AgentHarnessTool[]): Promise; - getResources(): Promise; setResources(r: Resources): Promise; - getStreamOptions(): Promise; - setStreamOptions(o: AgentHarnessStreamOptions): Promise; - getRetryPolicy(): Promise; setRetryPolicy(p: RetryPolicy): Promise; - getCompactionSettings(): Promise; - setCompactionSettings(s: CompactionSettings): Promise; - getSteeringMode(): Promise; setSteeringMode(m: QueueMode): Promise; - getFollowUpMode(): Promise; setFollowUpMode(m: QueueMode): Promise; - - watchSession(): Promise<{ snapshot: SessionSnapshot; - start: (l: EventListener) => void; unsubscribe: () => void }>; - - hooks: Hooks; - events: Events; - - /** Detach cleanly (§4.7). Open operations stay resumable. */ - close(): Promise; -} - -interface LaneInfo { - name: string; - leafId: string | null; - operation: null | { id: string; kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "aborting" }; -} - -interface SuspendedOperation { - lane: string; operationId: string; - kind: "run" | "compaction" | "navigation"; - reason: "crash" | "deferred" | "missing_identities"; - startedAt: number; - prompt?: AgentMessage[]; - deferred?: DeferredHandle; - aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; - missing: { tools: string[]; models: string[] }; -} - -// QueueMode, RetryPolicy, and CompactionSettings use the source types named in §0.7. -``` - -### Options - -```ts -/** AgentHarnessStreamOptions is the curated source type from §0.7. It excludes - signal and provider lifecycle callbacks, which the harness owns. */ -interface AgentHarnessOptions { - session: Session; - models: Models; - - // Immutable lane seed captured at create(). Initializes main when the session - // is first attached, and every lane later created by this harness. Never a - // fallback for a lane that already has a configuration. - model: Model; - thinkingLevel?: ThinkingLevel; // default "off" - activeToolNames?: string[]; // default: initial tool names - - tools?: AgentHarnessTool[]; - toolContext?: TContext | (() => TContext | Promise); - systemPrompt?: string | ((ctx: TContext) => string | Promise); // per request - resources?: Resources; // skills, prompt templates - - streamOptions?: AgentHarnessStreamOptions; - retry?: RetryPolicy; - compaction?: CompactionSettings; - steeringMode?: QueueMode; - followUpMode?: QueueMode; - toolExecution?: "sequential" | "parallel"; // default parallel - drive?: "automatic" | "manual"; // default automatic - - toProviderMessages?: (m: AgentMessage[]) => Message[] | Promise; - nodeProjectors?: Record; - /** Existing typed telemetry contract; defaults to no-op. */ - telemetryContext?: TelemetryContext; -} - -type Resources = AgentHarnessResources; -type NodeProjector = (node: CustomNode) => - AgentMessage[] | undefined | Promise; -``` - -`create()` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Before restore, it commits that seed as the first `lane.config` for a fresh or normalized-v3 `main`. Existing lanes use only their current config; the seed never overrides them. A configuration-less lane in a format-4 session is corrupt. - -`createLane(name, at)` atomically writes its pointer and the original captured seed, regardless of later changes. Setters replace only their lane's value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through `setStreamOptions({ deferred: ... })` or initial `streamOptions`; `before_request` may patch the same curated field per attempt. - -Initial, replacement, and hook-patched stream options are normalized to detached JSON-safe values before publication because ready states persist them. Functions, symbols, bigint values, cycles, non-finite numbers, and unsupported prototypes in metadata reject construction/the setter without changing settings; an invalid hook patch is isolated as `handler_error` and ignored without changing operation state. Patch deletion semantics are applied before this validation. - -`systemPrompt`, `toolContext`, `toProviderMessages`, and `nodeProjectors` are deterministic/idempotent computation callbacks and may repeat after a crash; effectful interception belongs in hooks. `before_run` receives one preview evaluation of `systemPrompt`. A hook override is fixed in `Operation`; without one, the callback is evaluated again per provider request. - -## 5.3 SessionTree - -```ts -interface SessionTree { - getLeafId(): Promise; - getNode(id: string): Promise; - getStats(): Promise; - - // Global facts. Latest wins; not branch-scoped. undefined deletes; JSON null - // is a legitimate custom value. Custom keys cannot collide with name or labels. - getName(): Promise; - setName(name: string | undefined): Promise; - getLabel(targetId: string): Promise; - setLabel(targetId: string, label: string | undefined): Promise; - getCustomFact(key: string): Promise; - setCustomFact(key: string, value: JsonValue | undefined): Promise; - - /** Session-wide, all branches, sequence order. */ - findNodes(query?: NodeQuery): Promise; - findNode(query?: NodeQuery): Promise; - - /** Branch-scoped: the path from start toward root (§2.5). */ - findNodesOnBranch(query?: BranchScan): Promise; - findNodeOnBranch(query?: BranchScan): Promise; - - // Writes resolve on durable acceptance; the returned id is the node id, - // reserved when the write defers. - appendMessage(message: AgentMessage): Promise; - appendCustomNode(customType: string, data?: JsonValue): Promise; -} - -interface NodeQuery { type?: NodeType; customType?: string; - order?: "asc" | "desc"; limit?: number; cursor?: NodeCursor } -interface SessionStats { messageCount: number; usage: Usage } -``` - -Global queries filter first, then apply the exclusive cursor, then `limit`; default order is `"desc"`. A descending cursor retains `seq < cursor.seq`, and an ascending cursor retains `seq > cursor.seq`. - -Useful patterns: effective extension state is `findNodeOnBranch({ type: "custom", customType })`; a collection is `findNodesOnBranch(...)`; a global inventory is `findNodes(...)`. Note that extension-state lookups have **no** `stopAt` and therefore walk past compactions — which is exactly why §2.6 segments rather than truncates. - -`SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. Finders and `getNode` return only committed nodes: a deferred write is invisible here until applied, but appears in snapshots by its reserved id. - -## 5.4 Snapshots and subscription - -```ts -const { snapshot, start, unsubscribe } = await lane.watch(); -await send(client, { kind: "snapshot", snapshot }); // snapshot on the wire first -start((event) => send(client, event)); // flush buffer in order, then live -``` - -`watch()` atomically snapshots and begins buffering. `start(listener)` flushes in order, then delivers live; each event arrives once, in order, without sequence numbers or registration races. `unsubscribe()` drops the watcher and its buffer. A never-started watcher buffers without bound. - -```ts -interface QueuedItem { nodeId: string; message: AgentMessage } - -interface LaneSnapshot { - lane: string; - transcript: Node[]; // this lane's context window plus its compaction node - leafId: string | null; - - operation: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "aborting"; - startedAt: number; - suspended?: SuspendedOperation; - streamingMessage?: AssistantMessage; // message_start until node commit - runningTools: { toolCallId: string; toolName: string; args: unknown; - partialResult?: AgentToolResult }[]; - retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; - }; - - queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; - pendingWrites: { nodeId: string; type: NodeType; customType?: string; - message?: AgentMessage; data?: JsonValue }[]; - faulted: boolean; -} - -interface SessionSnapshot { - lanes: (LaneInfo & { suspended?: SuspendedOperation })[]; - faulted: boolean; -} -``` - -`operation.status` derives from durable state plus a process-local suspension marker: `suspended` for deferred, restored, or missing-identity suspension; `aborting` when `control.status === "cancel_requested"`; otherwise `running`. The missing-identity marker stores the exact `SuspendedOperation`, survives until a successful resume attempt or abort in this process, and is reconstructed as `reason:"crash"` after reopen. It changes snapshots but never durable recovery state. `queues` and `pendingWrites` derive from `inbox` and `pendingNextRun`, with content dereferenced by value id. `streamingMessage` and `runningTools` are process-local extras layered on top. - -Rules: - -- Configuration is **not** in snapshots. Getters return current values; `config_update` events tell a UI when to re-read. One source of truth. -- `streamingMessage` is not part of `transcript`. `message_end` replaces it with the final post-hook value but does not clear it; the matching `node_added` confirms the append, adds the node to `transcript`, and clears the draft. -- Direct messages and finalized tool results use the same immediate `message_start` → `message_end` lifecycle and enter `transcript` only on `node_added`. They never populate `streamingMessage`. -- An `aborting` snapshot reports only state that actually exists. It never synthesizes a streaming assistant message. -- Reconnect means a new `watch()`. Only process death loses stream state; a restored harness shows the suspended operation instead. Every node in the durable transcript is complete — a lost draft was never a node. -- A lane watcher receives events whose `lane` matches, plus events with no lane. The harness-global `usage` event is the explicit exception: it carries its originating lane but reaches every watcher, because its totals are session-wide. - -## 5.5 Events - -One flat stream. `events.on(type, listener)` matches across the harness; lane watchers filter as above. Events are **passive**: listeners cannot mutate execution, payloads are isolated from procedure state, and a throw produces `handler_error` plus telemetry without affecting execution. Only hooks intercept. - -Durable-fact events fire **after** commit — `node_added` means queryable. Multi-write events wait for full success, then follow mutation order. Process-local lifecycle events need not be durable: `message_end` precedes the node append. - -```ts -type HarnessEventPayload = - // Run lifecycle - | { type: "run_start"; runId: string } - | { type: "run_resume"; runId: string } - | { type: "run_suspend"; runId: string; reason: "deferred"; - deferred: DeferredHandle } - | { type: "run_suspend"; runId: string; reason: "missing_identities"; - missing: { tools: string[]; models: string[] } } - | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } - | ({ type: "run_end"; runId: string; leafId: string | null } & ( - | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) - | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant))) - | { type: "fault"; code: string; message: string } - | ({ type: "handler_error"; error: string; stack?: string } & - ({ kind: "hook"; hook: string } | { kind: "event"; event: string })) - - // Steps and retries. First-try success emits no retry events. - | { type: "turn_start"; runId: string; turnId: string } - | { type: "turn_end"; runId: string; turnId: string; - message: AssistantMessage; toolResults: ToolResultMessage[] } - | { type: "retry_scheduled"; runId: string; step: string; attempt: number; - maxAttempts: number; delayMs: number; errorMessage: string } - | { type: "retry_start"; runId: string; step: string; attempt: number } - | { type: "retry_end"; runId: string; step: string; attempt: number; - success: boolean; finalError?: string } - - // Messages - | { type: "message_start"; runId?: string; message: AgentMessage } - | { type: "message_update"; runId: string; message: AgentMessage; - event: AssistantMessageEvent } - | { type: "message_end"; runId?: string; message: AgentMessage; nodeId?: string } - - // Tools - | { type: "tool_start"; runId: string; turnId: string; toolCallId: string; - toolName: string; args: unknown } - | { type: "tool_update"; runId: string; turnId: string; toolCallId: string; - toolName: string; partialResult: AgentToolResult } - | { type: "tool_end"; runId: string; turnId: string; toolCallId: string; - toolName: string; result: AgentToolResult; isError: boolean; terminate: boolean } - - // Tree, queues, facts - | { type: "node_added"; node: Node } - | { type: "write_pending"; runId: string; nodeId: string; type: NodeType } - | { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; - nextRun: QueuedItem[] } - | ({ type: "fact_update" } & ( - | { fact: "name"; name: string | undefined } - | { fact: "label"; targetId: string; label: string | undefined } - | { fact: "custom"; key: string; value: JsonValue | undefined })) - - // Configuration - | ({ type: "config_update" } & ( - | { property: "model"; value: { provider: string; modelId: string }; previous: unknown } - | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel } - | { property: "activeTools"; value: string[]; previous: string[] } - | { property: "tools" | "resources" | "streamOptions" | "retryPolicy" - | "compactionSettings" | "steeringMode" | "followUpMode" })) - - // Structural - | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" } - | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & ( - | { outcome: "completed"; node: CompactionNode; fromHook: boolean } - | { outcome: "declined" | "aborted" } - | { outcome: "failed"; error: OperationError })) - | { type: "navigation_start"; runId: string; targetId: string | null } - | ({ type: "navigation_end"; runId: string; - oldLeafId: string | null; newLeafId: string | null } & ( - | { outcome: "completed"; summaryNode?: BranchSummaryNode } - | { outcome: "declined" | "aborted"; summaryNode?: never; error?: never } - | { outcome: "failed"; error: OperationError; summaryNode?: never })) - - // Lanes and cost - | { type: "lane_created"; at: string | null } - | { type: "usage"; lane: string; seq: number; value: UsageValue; totals: Usage }; - -type SpecialEventPayload = Extract; -type LaneEventPayload = Exclude; -type ConfigEventPayload = Extract; -type LaneConfigEventPayload = Extract; -type GlobalConfigEventPayload = Exclude; -type HandlerErrorPayload = Extract; - -type HarnessEvent = - | (LaneEventPayload & { lane: string; recovery?: true }) - | (LaneConfigEventPayload & { lane: string; recovery?: true }) - | (Extract & - { lane?: never; recovery?: never }) - | (Extract & { recovery?: never }) - | (GlobalConfigEventPayload & { lane?: never; recovery?: never }) - | (HandlerErrorPayload & ( - | { lane: string; recovery?: true } - | { lane?: never; recovery?: never } - )); - -type HarnessEventType = HarnessEvent["type"]; -type EventListener = - (event: E) => void | Promise; - -interface Events { - on( - type: T, - listener: EventListener>, - ): () => void; -} -``` - -`lane` is required on run/turn/retry/message/tool, node/write/queue, lane model/thinking/active-tool configuration, structural, and lane-created events. It is absent on facts, faults, and harness-global configuration. `handler_error` follows the failed handler's scope. `usage` is the global-delivery exception: base `lane` is absent, while its payload carries origin lane and durable `seq`. `recovery: true` appears on process-local lifecycle re-emitted by `resume()`, never on events for already-existing durable nodes. Cross-lane events are process ordered, not globally sequence ordered. A totals consumer keeps the greatest usage `seq` it has applied, preventing a late older event from regressing totals. - -Ordering for a streamed assistant response, asserted exactly by the conformance tests: - -``` -message_start → message_update* → after_response hook → message_end (final value, -optional reserved id) → atomic response + usage + classified-state commit -→ node_added → usage -``` - -Only `node_added` proves durability. Classification is computed before the transaction and becomes durable with it; it is not a separate event. Abort and overflow classification may normalize the committed response after `message_end`, so `node_added` is authoritative for those two cases. A synthetic settlement performs no provider effect, update, or response hook: `message_start → message_end → atomic commit → node_added → usage`. - -Nesting: - -``` -run_start - message_start / message_end / node_added consumed prompt and queue messages - turn_start - message_start / message_update* / message_end assistant stream finished - node_added response committed - tool_start / tool_update* / tool_end per real call - message_start / message_end tool results, source order - node_added each result committed - turn_end - compaction_start … node_added … compaction_end auto, at a checkpoint - turn_start … turn_end until nothing is pending -run_end -``` - -Deferred and recovery brackets are deterministic: - -- initial assistant generation uses `turnId = stepId`; a durable deferred response ends that turn, then emits `run_suspend`; -- every application `resume()` emits `run_resume`; `recovery:true` is present only when this harness restored the operation after process loss, not for same-process deferred resume; -- one deferred poll opens a turn whose durable id is `${stepId}:poll:${poll}`. Pending/error/ready settlement and any ready tool batch complete inside that turn, followed by `turn_end` and then suspend/failure/checkpoint; -- restored unresolved tools re-open their persisted `ToolBatch.turnId` with `recovery:true`, emit only new replay/interruption tool lifecycle, then close that recovery turn. Existing message/node events are never replayed; -- resumed structural work re-emits its structural start with `recovery:true`; structural streams emit no message lifecycle and their typed result alone emits `node_added`. - -Deferred polls emit no retry lifecycle. Events may contain sensitive conversation and tool content. Serving layers own authorization and redaction. Event payloads are isolated from mutable procedure state. Telemetry alone is content- and secret-free by default. - -## 5.6 Hooks - -Hooks are awaited interception points. Registration is harness-global; every payload carries `lane`. - -```ts -type BeforeResumePrepared = - | { kind: "run"; prompt: AgentMessage[]; systemPromptOverride?: string } - | { kind: "compaction"; sourceLeafId: string | null; - customInstructions?: string } - | { kind: "navigation"; sourceLeafId: string | null; targetId: string | null; - summarize: boolean; label?: string; customInstructions?: string }; - -interface HookMap { - before_run: { - event: { prompt: AgentMessage[]; systemPrompt: string; resources: Resources }; - result: { messages?: AgentMessage[]; systemPrompt?: string; resumeData?: JsonValue } | undefined; - }; - before_resume: { - event: BeforeResumePrepared & { resumeData?: JsonValue }; - result: void; - }; - before_run_end: { - event: { runId: string; messages: AgentMessage[] }; - result: { followUp?: string } | undefined; - }; - transform_context: { - event: { messages: AgentMessage[] }; - result: { messages: AgentMessage[] } | undefined; - }; - before_request: { - event: { model: Model; - step: "assistant" | "deferred" | "compaction" | "branch_summary"; - attempt: number; streamOptions: AgentHarnessStreamOptions }; - result: { streamOptions?: AgentHarnessStreamOptionsPatch } | undefined; - }; - before_payload: { - event: { model: Model; payload: unknown }; - result: { payload: unknown } | undefined; - }; - after_response: { - event: { status?: number; headers?: Record; - message: SettledAssistantMessage }; - result: { message?: SettledAssistantMessage } | undefined; - }; - before_tool: { - event: { toolCallId: string; toolName: string; args: Record }; - result: { args?: Record; - block?: { reason: string; terminate?: boolean } } | undefined; - }; - after_tool: { - event: { toolCallId: string; toolName: string; args: Record; - content: AgentToolResult["content"]; details?: JsonValue; - isError: boolean; usage?: Usage }; - result: { content?: AgentToolResult["content"]; details?: JsonValue; - isError?: boolean; usage?: Usage; terminate?: boolean } | undefined; - }; - before_compaction: { - event: { reason: "manual" | "threshold" | "overflow"; - preparation: CompactionPreparation; customInstructions?: string }; - result: { decline?: boolean; compaction?: CompactResult } | undefined; - }; - before_navigation: { - event: { targetId: string; preparation: BranchPreparation; - customInstructions?: string }; - result: { decline?: boolean; summary?: BranchSummaryResult } | undefined; - }; -} - -type HookName = keyof HookMap; -type HookInvocation = HookMap[K]["event"] & { - lane: string; - /** Durable operation id, provisional for pre-acceptance before_run. */ - runId: string; -}; -type HookHandler = - (event: HookInvocation) => Promise | HookMap[K]["result"]; - -interface Hooks { - on(name: K, handler: HookHandler, - options?: { id?: string }): () => void; -} -``` - -Uniform semantics: - -- `before_run` and `before_resume` require a stable `id`, unique within each hook name; duplicates reject synchronously. An extension reuses its id across both hooks and across restarts; the runner stores `resumeData` by id and gives each resume handler only its own value. -- Handlers run in registration order, each seeing the prior output. `messages` append; `systemPrompt` replaces. -- A throw emits `handler_error`, skips that handler, and lets the rest continue. **`before_tool` instead fails closed and blocks the tool.** -- Durable hook outputs commit before execution continues. A return alone is not durable; a pre-commit crash may rerun the hook. -- Events expose post-hook values. Passive listeners cannot transform them. - -One `EffectPlan{kind:"hook"}` runs the complete registered pipeline for that hook name and returns its final aggregate; individual handlers are not separate durable/manual actions. The runner still isolates and telemetry-wraps each handler internally. Aggregation is deterministic: - -- `before_run` appends messages and lets the latest defined system prompt replace the prior one; resume data is stored under each handler id. -- context/request/payload/response and `after_tool` transformations run in registration order, each seeing the prior transformed value; option/result patches merge field by field. -- `before_tool` argument replacements chain and are revalidated; the first block is terminal and later handlers do not run. -- `before_compaction`/`before_navigation` stop at the first decline or supplied result; if all handlers return neither, generation is selected. Returning decline plus a result is a handler error and is ignored like a throw. -- `before_run_end` uses the latest defined follow-up. - -| Hook | When | Event | Result | -|---|---|---|---| -| `before_run` | once, before acceptance, outside the mutation line | `{ prompt, systemPrompt, resources }` | `{ messages?, systemPrompt?, resumeData? }` | -| `before_resume` | on `resume()`, before any effect; must be idempotent | `BeforeResumePrepared + { lane, runId, resumeData? }` | `void` | -| `before_run_end` | at a normal finish boundary | `{ runId, messages }` | `{ followUp? }` | -| `transform_context` | per request, `AgentMessage` level, before `toProviderMessages` | `{ messages }` | `{ messages }` | -| `before_request` | per request, provider-neutral options | `{ model, step, attempt, streamOptions }` | `{ streamOptions? }` | -| `before_payload` | per request, provider-specific wire payload | `{ model, payload }` | `{ payload }` | -| `after_response` | per response, after streaming settles, before `message_end` and the commit | `{ status, headers, message }` | `{ message? }` (must keep role) | -| `before_tool` | after validation, before execution | `{ toolCallId, toolName, args }` | `{ args?, block?: { reason: string; terminate?: boolean } }` | -| `after_tool` | after execution, before the result commits; patch semantics | `{ toolCallId, toolName, args, content, details, isError, usage? }` | `{ content?, details?, isError?, usage?, terminate? }` | -| `before_compaction` | in `deciding` | `{ reason, preparation, customInstructions? }` | `{ decline?, compaction? }` | -| `before_navigation` | in `deciding` | `{ targetId, preparation, customInstructions? }` | `{ decline?, summary? }` | - -`before_request` receives `AgentHarnessStreamOptions` and returns `AgentHarnessStreamOptionsPatch`; neither can contain a signal or provider lifecycle callback. `after_response` must preserve the assistant role and may return `aborted` only when the harness signal is already aborted. `before_navigation` runs only for summarized navigation; unsummarized navigation cannot decline. - -Replay across retry and resume: - -| Hook | fresh | retry | resume | -|---|---|---|---| -| `before_run` | once | no | no (persisted in `Operation`) | -| `before_resume` | no | no | yes, idempotent | -| `transform_context`, `before_request`, `before_payload` | per request | yes | yes | -| `after_response` | per response unless abort wins before it starts | per response | same rule | -| `before_tool` | per call | — | not when the call is already `effect_pending` | -| `after_tool` | per executed result unless abort wins before it starts | — | on safe replay only, with the same abort rule | -| `before_compaction`, `before_navigation` | once, until a structural source commits | no | never once `generating` is durable | -| `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | - -`before_run_end` may fire again after a crash at the same boundary. Handlers that must not double-fire keep their own durable marker. This is the exactly-once non-goal (§0.6) surfacing in the hook layer. - -## 5.7 Agent-loop building blocks - -The existing `agent-loop.ts` remains behavior-compatible and is refactored into these exported phases. Existing fields on `AgentTool`, `AgentToolResult`, and provider messages are retained. Add recovery declaration `replay?: "never" | "safe"` to `AgentTool`; omission means `"never"`. `AgentHarnessTool` inherits it. The `AgentEventSink` below is the existing agent-loop sink, not the harness event listener; the harness adapts agent events into §5.5 events. - -```ts -interface StreamAssistantConfig { - model: Model; - thinkingLevel: ThinkingLevel; - systemPrompt?: string; - tools?: AgentTool[]; - transformContext?: (messages: AgentMessage[], signal: AbortSignal) => - Promise; - toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise; - requests: ModelRequestLease; // no registry re-resolution - streamOptions?: AgentHarnessStreamOptions; - /** Harness-owned before_payload adapter; undefined keeps the payload. */ - transformPayload?: (payload: unknown, model: Model) => - unknown | undefined | Promise; - /** Final settled-message transform used by after_response, before message_end. */ - transformResponse?: (message: SettledAssistantMessage, - metadata: { status?: number; headers?: Record }) => - Promise; - telemetryContext: TelemetryContext; - signal: AbortSignal; -} - -function streamAssistant(messages: AgentMessage[], config: StreamAssistantConfig, - emit: AgentEventSink): Promise; -// The implementation converts curated streamOptions to provider options and -// installs harness-owned payload/response callbacks; callers cannot replace them. -// Existing summary helpers gain ModelRequestLease overloads and use the same -// bound request path for every split request. - -type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; - tool: AgentTool; args: Record }; -type ImmediateOutcome = { kind: "immediate"; result: AgentToolResult; - isError: true; terminate: boolean }; -type FinalizedToolCall = { toolCall: AgentToolCall; result: AgentToolResult; - isError: boolean; terminate: boolean }; - -interface ToolCallbacks { - beforeToolCall?(call: AgentToolCall, args: Record): - Promise; - afterToolCall?(call: AgentToolCall, args: Record, - result: AgentToolResult, isError: boolean): - Promise; - executeTool?(call: PreparedToolCall): - Promise<{ result: AgentToolResult; isError: boolean }>; - onToolStart?(call: AgentToolCall, effectiveArgs: Record): Promise; - onToolResult?(call: AgentToolCall, message: ToolResultMessage, - terminate: boolean): Promise; -} - -function prepareToolCall(call: AgentToolCall, tools: AgentTool[], callbacks: ToolCallbacks, - telemetry: TelemetryContext, signal: AbortSignal): - Promise; -function executeToolCall(call: PreparedToolCall, emit: AgentEventSink, - telemetry: TelemetryContext, signal: AbortSignal): - Promise<{ result: AgentToolResult; isError: boolean }>; -function finalizeToolCall(call: PreparedToolCall, - executed: { result: AgentToolResult; isError: boolean }, - callbacks: ToolCallbacks, telemetry: TelemetryContext, - signal: AbortSignal): Promise; -``` - -External output that violates durable JSON/schema contracts is converted before settlement: an invalid provider message becomes a synthetic assistant `error` under the reserved response id; an invalid tool result becomes a synthetic error under its planned result id. Valid reported usage is retained when it can be validated independently, otherwise the synthetic node reports zero. Invalid hook output is handled like a throwing handler (`before_tool` still fails closed); invalid caller input returns `InvalidMessage` before acceptance. No invalid value reaches `Storage.commit()`. - -`AgentTool.prepareArguments` is deterministic/idempotent computation and may repeat before intent; effectful policy belongs in `before_tool`. `ToolCallbacks` contains the existing before/after callbacks plus `executeTool`, `onToolStart`, and `onToolResult` durability callbacks described in §3.8. `onToolStart` receives effective arguments after `prepareArguments`, validation, and `before_tool`; `onToolResult` receives the finalized message and terminate decision. Blocked calls may terminate when `before_tool.block.terminate` is true. Replacement arguments are validated again. - -For each live tool batch, the harness resolves `toolContext` exactly once, caches bound `AgentHarnessTool` adapters in `DriveState.toolBatches`, and passes that same context as the fifth execute argument for every call. Safe replay after restart creates one new batch snapshot; context is environmental and never persisted. - -`executeToolBatch` preserves the existing sequential/parallel behavior: source-ordered preparation and dispatch, concurrent effects in parallel mode, source-ordered finalization/results, no effect for blocked/invalid/genuine-length calls, and `terminate: true` only when every finalized outcome terminates. Compatibility wrappers keep existing public loop signatures and events. - -## 5.8 Telemetry - -Use the existing callback-based `TelemetryContext`, no-op/reference implementations, typed schema machinery, and agent-owned schemas. Do not invent a second contract. Context is passed explicitly; no core `AsyncLocalStorage` or global active span. - -Required spans remain: - -```text -pi.harness.run | compaction | navigation -pi.harness.checkpoint | turn | step | tool | hook | sleep | event_handler -pi.session.write -pi.ai.request -``` - -Operation, step, tool, hook, event, and write parents follow the actual interpreter/effect nesting. Sleep spans permit run, compaction, navigation, turn, and checkpoint parents. `stepId`/`taskId` correlate retries and recovery. Every provider request/fetch/cancel uses `pi.ai.request`; each real or safely replayed phase-two tool effect uses one tool span. - -Every storage transaction uses one `pi.session.write`. Its start attributes include `pi.session.item_count` and `pi.session.item_kinds` (`value`, `node`, `slot`). A calling procedure may supply its lane/operation ids; storage never infers them from values. End attributes include first and last committed sequence. Update the existing schema from old single-mutation vocabulary to this transaction shape; no span is emitted for a conditional no-write result. Synthetic settlements and blocked/invalid tools emit no provider/tool-effect span. - -Telemetry attributes may contain declared ids, names, counts, durations, statuses, and usage. They must never contain prompts, completions, tool arguments/results, file contents, provider payloads, headers, handles, or credentials. Events and hooks may contain such content. The existing generated schema document and adapter/runtime conformance tests remain authoritative; implementation slices extend instrumentation only through those schemas. - -# Part 6 — Build order - -Build the following vertical slices in order, except SQLite work may proceed after the tree contract stabilizes. Each slice implements the named behavior end to end and adds focused tests for its normal path, every state it introduces, every owned crash boundary, and both orders of owned races. Passing those tests and `npm run check` is its acceptance criterion. - -The current source tree is a work-in-progress implementation of the superseded record-log design. Replace its durable shapes rather than supporting both. Each slice updates or removes incompatible consumers/tests immediately so the repository compiles and `npm run check` passes after every merge; there is no compile-only legacy quarantine. Reuse existing behavior and tests where still valid: compaction preparation/split-turn generation, agent-loop streaming/tool behavior, event buffering, telemetry contracts, repository lifecycle, `BEGIN IMMEDIATE`, and fenced SQLite leases. - -If implementation exposes a design contradiction, missing transition, or materially simpler design, stop and send it to the user for review. Do not silently improvise a new durable contract inside a slice. - -| # | Slice | Implement | Required focused tests | -|---|---|---|---| -| 1 | **Single-session substrate** | Write-once values/nodes, slots/history, atomic transactions, runtime value/custom-message schemas, stats, Memory backend, and shared conformance helpers. | Rollback, sequence order, duplicate ids, target-kind/schema validation, unknown custom roles, tombstones, immutable reads, stats, close. | -| 2 | **JSONL v4 and v3** | Single-item/array transaction lines, projections, torn-tail handling, format-3 read normalization and first-write temp/rename conversion. Replace unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, every v3 rule, resolved/unresolved parent paths, aggregate imported usage adjustment. | -| 3 | **Tree and repositories** | Node materialization, lane/config/state slots, facts, branch/global queries, context projection, `SessionTree`, repository lifecycle, coherent branch/tree forks. | Placement, divergence, filters/cursors/stops, custom null/tombstones, context, fork before first attachment, configured fork snapshots/facts/zero ledger. | -| 4 | **Runtime shell** | Lane/settings mutation lines, total-state validation/transitions, `Models.lease` and runtime snapshots, `Effects`, manual scheduler/gate, hook/event primitives, restore inventory, identity leases, fault/close plumbing. Public operations may still report not implemented. | State/action exhaustiveness, compatible-descendant settlement, parallel scheduler order, hook aggregation, event buffering, gate nesting, zero effects while parked, restore without history reads. | -| 5 | **Minimal no-tool run** | Prompt expansion, `before_run`, atomic acceptance, captured request lease/options/thinking, payload/response hooks, one generation intent/effect/settlement, usage, finish, results, basic events/telemetry. | Successful run with final assistant fields, invalid caller/provider/hook output, exact transaction/event order, automatic/manual identical state, close at every boundary. | -| 6 | **Generation recovery and retry** | Retry waits, unknown-effect recovery, synthetic cap settlement, ordinary stop/error/deferred classification, provider-compliant `aborted`, and failure-drain foundation. Overflow classification remains explicitly unimplemented until slice 12. | Every generation state before/after reopen, caps/backoff, stop/error/aborted/deferred classification, missing identities. | -| 7 | **Tools** | Refactor existing loop into three phases, bind `AgentHarnessTool` context, durable complete plans/effective args, replay, sequential/parallel modes, blocked terminate, genuine-length results, tool events/hooks/usage. | Existing loop compatibility plus a built-in context-bound tool, invalid args/results, every planned/pending/completed state, safe/unsafe replay, ordering, termination, abort-ready states. | -| 8 | **Inbox, configuration, and writes** | `nextRun`, steer/follow-up modes, dispositions/cancellation, durable drain markers, checkpoint consumption, immediate total config setters, deferred tree writes, adjustments. | Capture/cancel/consume races, repeated cancellation, one-at-a-time crash after one drain, custom-write continuation, config-step race, writes surviving reopen. | -| 9 | **Abort, close, and failure drain** | Orthogonal control, stable drained input, signalling, per-phase reconciliation, best-effort cancellation of the current deferred source, waiters/run-when-idle, controlled-crash close. | Abort at every existing state, repeated abort, deferred cancellation, live/restore tool outcomes, writes before finish, close races, failure revived only by projecting input. | -| 10 | **Deferred provider redemption** | One poll per resume, copied configuration/options, leased request hooks, exact source lineage/equality, fresh intent after unknown poll, mismatch-to-error, ready tools, and advancement of slice 9 cancellation to each newest source. | Repeated pending, ready/error/aborted/mismatch, crash positions, no cap/backoff/loop, newest-handle cancellation. | -| 11 | **Manual compaction** | Adapt existing compaction implementation to reserved-lane admission, JSON preparation DTO/value, total structural state, hook/generated sources, leased nested request intents/usage, retained tail, retry/recovery/abort. | Empty/reservation race, hook decline/result, crash after request one of split-turn generation, every state/crash, no public summary-stream messages. | -| 12 | **Threshold and overflow compaction** | In-run structural decision, durable once-per-trigger threshold marker, continuation preservation, all overflow predicates, atomic response/preparation publication, specified normalization/projection, one overflow recovery flag, bounded second failure. | Threshold decline/empty across reopen, all overflow classifier/preparation inputs, no overflow tool plan, genuine length, crash/reopen at every transition. | -| 13 | **Navigation** | Validation, summarized decision/generation, and one final move/summary/leaf/label/finish transaction; summary-only navigation hook. | Root/current/unknown rejection, summarized/unsummarized paths, final leaf at summary, abort race, exact atomic publication. | -| 14 | **SQLite** | Rework the current unfinished schema/backend directly to values/nodes/slots, transactions, stats, leases, repository operations, segmented branch cache, node-id-keyed FTS search projection, and explicit repair. No migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, placed-only search, forks/search/stats/repair. | -| 15 | **Surface completion** | Complete snapshots/watch, event catalog/order/filtering, telemetry instrumentation/schema freshness, public exports, backend parity, and remove any remaining dead scaffold code. | Snapshot/event gap, attach during every live state, sensitive-event/content-free-telemetry assertions, full race/crash matrix on all backends. | - -Existing source guidance: - -- `packages/agent/src/harness/session/**` and the old record reducer/tests: slices 1–3. Remove incompatible reducer code as soon as slice 1 replaces its inputs; do not preserve both durable models. -- `packages/agent/src/harness/agent-harness.ts` and new small transition/effects modules: slices 4–13 and 15. -- `packages/agent/src/agent-loop.ts`: preserve behavior while slice 7 extracts phases. -- `packages/agent/src/harness/compaction/**`: adapt, do not rewrite gratuitously, in slices 11–13. -- `packages/session-backends/sqlite-node`: slice 14; retain working transaction and lease primitives. -- Existing tests are evidence, not authority. Keep those that assert unchanged behavior and replace those tied to the record-log format. - -# Part 7 — Invariants and tests - -## 7.1 Invariants - -Storage: - -1. Values and nodes are **write-once** and share one id namespace. Reusing an existing id for either kind is corruption. -2. Transactions are all-or-none, with consecutive `seq`. `seq` is monotonic session-wide. -3. Slots are the only mutable state. `null` is a tombstone and differs from unbound; every non-null target exists and matches its namespace's required kind. -4. No read on a hot path may fold history or depend on the absence of a record, and no query may be a table scan. - -Tree: - -5. A node's parent chain never changes. Branches share prefixes; nothing is copied. -6. A node whose `valueId` is missing or has a kind incompatible with §2.1 is corruption; only a custom node may use null. -7. Configuration and orchestration never enter the tree. Deleting every `operation` and `operation_state` value must leave a complete, valid conversation. -8. A lane's leaf moves only by append or navigation. -9. A branch segment chain, followed to its end, yields the full root path. - -Operations: - -10. `lane.state/{lane}` confers lane ownership, and `op.state/{operationId}` confers operation-state ownership. An open lane names operation O, immutable value O is that lane's compatible `Operation`, and `op.state/O` targets a compatible `OperationState`; state values carry no duplicate owner metadata. -11. A `finished` state and `currentOperationId = null` must land in the **same** transaction. -12. Acceptance must observe `currentOperationId === null`. -13. A reserved id (response, usage, tool result, structural result) may exist only with the content its intent state named. -14. Only transition functions construct `FinishedState`. -15. At most one operation is open per lane. Two is corruption. -16. `overflowRecoveryUsed` is `true` only after overflow compaction. A transition that adds projecting conversational input or tool results and requires an assistant writes `false`; an unprojected custom write preserves it. -17. **A committed response with `stopReason: "aborted"` must have `control.status === "cancel_requested"` in the same operation state.** Providers must comply with the harness-owned signal contract; violation is corruption. -18. Current-state validation in §3.3 runs on every decoded latest lane/operation state before execution. Queue dispositions are never recovery inputs. - -Everything that used to require a bounded historical validity audit is now either unrepresentable in the types or covered by one of the above. - -## 7.2 Race catalog - -Each race has exactly two durable histories. Test both, in manual drive, in both orders. - -| Race | Orders | -|---|---| -| `prompt` vs `prompt` on one lane | one accepts, one gets `LaneBusy` | -| `abort` vs response settlement | marker first → normalized `aborted`; response first → stop reason preserved | -| `abort` vs tool result commit | planned result synthesized; or the real result stands | -| `abort` vs `before_run_end` follow-up | follow-up dropped; or committed and the run continues | -| `cancelQueued` vs checkpoint consumption | `cancelled`; or `already_consumed` | -| `setModel` vs generation step start | old snapshot used; or new snapshot used | -| `abort` vs structural commit | `aborted` with no node; or `completed` | -| `nextRun` vs acceptance | captured by this run; or stays for the next | -| manual-compaction reservation vs idle tree write | reservation first → write waits; write first → preparation uses the new leaf | -| deferred write vs abort | write survives abort either way | -| `close` vs parked manual action | action rejected unexecuted; durable state is the committed prefix | -| `close` vs settlement | settlement abandoned, state stays `effect_pending`; or it committed before the flag was set | - -## 7.3 Test tiers - -**Tier A — state and resume.** For every state in Part 3, construct it durably, close, reopen, and assert the next action. Coverage must include: restore with no branch or configuration walk; assistant intent with no settlement, below and at the retry cap; settlement followed by each classification branch; every settled stop reason surviving except the two deliberate normalizations; a self-contained deferred step with copied configuration, consecutive polls, repeated equal-handle pending responses, ready and terminal responses, and handle-mismatch normalization into durable failure; every tool state including planned, effect_pending safe and unsafe, and completed; a batch where every call sets `terminate` finishing the run with no further request; genuine-`length` batches proving no execution and one explanatory result per call; every overflow crash position, including that the compacted `retainedTail` omits the normalized-`error` response by the ordinary projection rule; every navigation state with no post-move generation; abort at every position; missing identities on accept and on resume; and every half-completed recovery prefix. - -For each recovery prefix: close, reopen, resume, and compare against uninterrupted recovery. Invoking recovery twice from the initial prefix is **not** sufficient. - -One corruption assertion constructs an `aborted` response with running control directly and requires load rejection. Provider conformance separately proves implementations emit `aborted` only for the supplied signal. - -**Tier B — writer conformance.** Run the public harness against an instrumented storage recording every value, node, slot, and hook. Assert exact order against the Part 3 transaction tables and the §5.5 ordering rules. This tier catches the critical regression classes: an effect starting before its intent commit, a response omitted for one stop reason, classification starting before usage is durable, or a result id reserved after clearance began. - -**Tier C — deterministic interleavings.** Every race in §7.2, both orders, manual drive. - -**Cross-cutting:** - -- **Backend conformance.** One suite, three backends, identical results — including `getLog` ordering and torn-transaction handling. -- **Drive equivalence.** The same scenario in automatic and manual drive must produce byte-identical durable state. -- **Signal ownership.** No public surface accepts a signal; a `before_request` patch carrying one has it stripped. Assert by type and by test. -- **Ledger completeness.** Every settled attempt commits its response and its usage. Failed structural attempts retain their cost. `getStats()` equals the ledger sum after every commit. A fork starts at zero. -- **Query-plan guards.** `EXPLAIN QUERY PLAN` for `scanBranch` matches §1.6 exactly — no `nodes` scan or temporary ordering b-tree. Segment tests assert copied rows are bounded by the newest compaction interval. -- **Transaction discipline.** Assert every SQLite transaction opens with `BEGIN IMMEDIATE`. Add a regression test that reads, lets a second connection commit, then writes — it must succeed, and would fail with `database is locked` under a deferred `BEGIN`. -- **Segment chain soundness.** Build a chain by alternating branch-and-append across several compactions, then assert that a full-to-root scan through the chain returns exactly the nodes a flat branch would, with no duplicates and no gaps. Both §2.6 rules — resolve-through base, chain-searched `csq` — fail this test when violated, and fail silently without it. - ---- - -# Appendix A — Glossary - -| Term | Meaning | -|---|---| -| **Value** | Write-once payload with an id. Messages, state frames, configs, usage. | -| **Stored node** | Write-once tree position referencing a value. Stored in `nodes`. | -| **Node** | Stored node plus value, materialized for the application. Its id is the public node id. | -| **Slot** | Mutable namespaced key targeting a value id, node id, or null. | -| **Session** | One conversation: tree, facts, ledger, lanes. | -| **Lane** | Named cursor into the tree with its own config, queues, and one operation. | -| **Operation** | One accepted unit of work: run, compaction, or navigation. | -| **Effect** | Anything not pure computation: commit, provider request, tool, hook, timer. | -| **Repeat-sensitive effect** | One whose repetition is observable outside the harness. | -| **Operation state** | The complete state of one operation at one moment. The program counter. | -| **Reserved id** | An id fixed in an intent commit and used by the matching settlement. | -| **Lane mutation line** | Per-lane serialization point where all state-dependent mutations queue. | -| **Control** | Orthogonal cancellation flag: `running` or `cancel_requested`. | -| **Checkpoint** | The state between turns where queues, writes, and finishing are decided. | -| **Continuation** | Durable answer to "does this run still owe an assistant turn?" | -| **Segment** | A branch-index range that references an older branch instead of copying it. | - -# Appendix B — Changes from harness-v2.md - -| Change | Reason | -|---|---| -| Values and nodes replace single-row conversation items | Content and placement have different birth times; splitting makes both write-once and stops queued content being serialized twice | -| Slots replace four latest-wins mechanisms | One mechanism, one query | -| Durable operation state replaces journal-and-reduce recovery | Recovery is a read, not a fold; crash states are enumerable | -| Operation state holds **ids**, not payloads | Keeps immutable payload size out of repeated total state | -| Tool calls recovered from the assistant value, not repeated in state | Same | -| **Segmented** branch index replaces full prefix copy | Bounds copied rows by the compaction interval | -| Branch queries return materialized nodes | Callers receive the hydrated node they need; structure-only reads remain separate | -| Navigation completes in one transaction | Removes prepared-summary and post-move recovery states entirely | -| legacy `firstKeptEntryId` → self-contained `retainedTail` | Context never reads past a compaction | -| **Overflow responses commit with stop reason normalized to `error`** | The response describes itself, so §2.5 rule 3 excludes it. Deletes `supersededResponseNodeId`, the compaction link, and the omission rule | -| **`aborted` ⟺ the harness's own signal fired** | v2 claimed transport timeouts and provider cancellation could produce `aborted`; adapters show they produce `error`. Deletes the "unmarked aborted" retry path, its deferred source-tracking, and a test tier, and turns the case into invariant 17 | -| Provider `AbortSignal` removed from every public options type | Makes the above an invariant rather than a convention | -| Close specified as a controlled crash | No close-specific recovery machinery; reuses §4.5 | -| Retained queue dispositions; dropped state revision counters, `genuineLength`, `nextToFinalize`, and a separate adjustment kind | Dispositions preserve `already_cleared`; the remaining fields are derivable or represented directly | -| Historical validity audit replaced by bounded current-state validation | Restore validates one current materialized state and its exact immutable references, never completed history | - -The hook/event behavior, agent-loop compatibility, telemetry policy, and v3 normalization are restated here so this specification is self-contained with the named source types. - -# Appendix C — v3 compatibility - -Old coding-agent v3 JSONL files must open unchanged and restore idle. Normalization on load: - -- `custom_message` becomes a custom agent message. -- `label` and `session_info` become facts (latest by file position wins) and leave the tree. A label targets its nearest retained parent. -- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` nodes disappear. They do **not** initialize or alter `LaneConfiguration`; a normalized `main` uses the immutable options seed. -- Each retained child of a discarded node is reparented to its nearest retained ancestor. -- `main`'s leaf is the final physical node resolved through discarded nodes to its nearest retained ancestor. -- An old compaction resolves its legacy `firstKeptEntryId` field against its own branch and materializes that range as `retainedTail`. Format 4 never exposes or persists that field. -- Existing `details`, `usage`, and `fromHook` are preserved; an absent `fromHook` normalizes to `false`. -- v3 ISO timestamps convert to Unix milliseconds. -- A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`. -- On first format-4 write, append one aggregate adjustment usage value with `details: { source: "v3-import" }`, summing v3 node usage so ledger-derived totals remain unchanged. - -Read-only open leaves the file unchanged and computes stats from normalized node snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived. A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed. - -# Appendix D — Open questions - -1. **Repairing a missing model captured inside an open operation.** Registering the same provider/model identity unblocks it without changing state. Replacing it with a different durable identity needs an explicit repair API and is not silently performed by `setModel`. -2. **Overflow detection remains heuristic.** The normalization specified in §3.7 is authoritative. Preserve the original reason in `errorMessage` for diagnosis. -3. **Value deduplication.** The value model permits an optional content-hash layer, but reserved ids remain canonical and no initial implementation includes deduplication. -4. **JSONL readability.** Value/node separation makes raw lines less self-contained. Implement a hydrating log/debug command only if ordinary inspection proves insufficient. diff --git a/packages/agent/docs/harness-v2-test-matrix.md b/packages/agent/docs/harness-v2-test-matrix.md deleted file mode 100644 index 8df60635a30..00000000000 --- a/packages/agent/docs/harness-v2-test-matrix.md +++ /dev/null @@ -1,195 +0,0 @@ -# Harness v2 promotion test matrix - -QA1 inventory for tests removed by `44289550a feat(agent): promote durable harness API`. - -This document maps each removed test case to one of the QA1 outcomes: - -- **Covered** — the behavior is already covered by v4 conformance or another current test. -- **Ported** — the case was rewritten under the v4 API or moved to the SQLite package. -- **Inapplicable** — the old API, implementation detail, or compatibility path was intentionally deleted. -- **Uncovered** — the behavior may still be required but cannot be ported until a named implementation package lands. QA revisits it afterward; implementation packages derive their own tests from the design and do not use this matrix. - -No production or test changes are part of QA1. - -## Summary - -| Area | Removed cases | Status | -|---|---:|---| -| Harness runtime and stream behavior | 37 | Mostly uncovered by design while `AgentHarness` is scaffolded; assigned to H/L/I/C/N packages. Scaffold-safe configuration is covered by F0. | -| Branch query and corruption behavior | 6 | Core query semantics are covered; bounded SQLite validation gaps were ported by QA2, and JSONL corruption behavior is covered by J3. | -| Compaction helper behavior | 2 | Covered by current compaction/context tests. | -| Memory/SQLite v4 conformance entrypoints | 3 | Ported to `packages/agent/test/harness/session/*` and `packages/session-backends/sqlite-node/test/conformance.test.ts`. | -| Repository/backend lifecycle and JSONL behavior | 38 | Format-4 lifecycle and crash/corruption behavior are covered by v4 conformance and J0–J3; format-3 normalization and conversion remain assigned to J4–J5. | -| Session aggregate/context behavior | 17 | Covered by v4 conformance plus current context tests. | -| SQLite search | 1 | Ported to SQLite package search tests; old scanning backend is inapplicable. | - -## Harness runtime and stream tests - -Removed files: - -- `packages/agent/test/harness/agent-harness-stream.test.ts` -- `packages/agent/test/harness/agent-harness.test.ts` - -The promotion intentionally replaced the behavior-complete legacy harness with the v2 scaffold. Runtime operation methods must reject with `HarnessNotImplemented` until their owning packages land; see the public method ownership table in `harness-v2.md` section 20. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| snapshots stream options before provider request hooks | Uncovered | H1/H4 after I1/I4/L3: assistant request execution must snapshot stream options and run request hooks. | -| chains provider request patches and supports deletion semantics | Uncovered | I1 + I4 own hook aggregation/effect adapter; H1 covers run integration. | -| uses updated stream options for save-point snapshots without mutating the active request | Uncovered | H3/H4/H6: checkpoint/deferred configuration behavior and tool continuation snapshots. | -| chains provider payload hooks | Uncovered | I1 + I4, then H1 request integration. | -| constructs directly and exposes queue modes | Covered / Inapplicable | Direct construction is intentionally replaced by `AgentHarness.create()`. Queue-mode defensive configuration is covered by `agent-harness-scaffold.test.ts` (`keeps scaffold-safe configuration as defensive copies`). | -| rejects waiting before shutdown is requested | Inapplicable | Legacy shutdown API was deleted. `waitForIdle` belongs to H5 and currently rejects by F0 scaffold tests. | -| shuts down active work permanently and idempotently | Uncovered | H5 owns close/abort/wait settlement. | -| allows a hook to request shutdown without deadlocking its operation | Uncovered | H5 after I1/I2 owns close/abort settlement from hooks/events. | -| allows a subscriber to request shutdown without deadlocking its operation | Uncovered | H5 after I2 owns passive-listener settlement. | -| does not start a provider request when shutdown occurs during before_agent_start | Uncovered | H1/H5 after I1: before-run hook cancellation/close behavior. | -| aborts and awaits active compaction without persisting its result | Uncovered | H5 + C1: abort reconciliation for compaction. | -| aborts and awaits active tree navigation without moving the session leaf | Uncovered | H5 + N1: abort reconciliation for navigation. | -| does not treat concurrent mutations as active operations | Uncovered | I3 lane mutation line and H4 deferred writes/configuration. | -| awaits concurrent idle session mutations before shutdown resolves | Uncovered | I3/H5: mutation-line settlement before close. | -| shuts down an idle harness without modifying its durable session | Covered / Uncovered | F0 covers scaffold `close()` and record-free create. H5 must cover durable runtime close with no writes. | -| drains one queued steering message at a time and emits queue updates | Uncovered | H3 queues/checkpoints/events. | -| appends before_agent_start messages and persists them | Uncovered | H1 `before_run` initial message capture. | -| abort clears steer and follow-up queues but preserves next-turn messages | Uncovered | H5 durable abort queue draining; H3 owns queue state. | -| drains follow-up messages one at a time after the agent would otherwise stop | Uncovered | H3 checkpoint finish-boundary conditionals. | -| settles thrown hook failures with persisted assistant error messages | Uncovered | I1 hook isolation + H1/H2 terminal failure entries. | -| refreshes model, thinking level, resources, system prompt, and active tools at save points | Uncovered | H3/H4/H6 checkpoint and deferred configuration behavior. | -| orders pending listener session writes after agent-emitted messages | Uncovered | H4 deferred writes plus I2 listener delivery. | -| waitForIdle waits for external run settlement and awaited listeners | Uncovered | H5 after I2. | -| runs tool_call and tool_result hooks through the direct loop | Uncovered | L2/L3 tool phases, I1 hooks, H6 durable tool events. | -| passes a static application context to harness tools | Uncovered | I4 effect-context threading and H6 tool execution. | -| resolves async tool context providers for each turn snapshot | Uncovered | I4/H6. | -| persists generated compaction usage | Uncovered | C1 manual compaction operation. | -| persists hook-provided compaction usage | Uncovered | C1 with I1 hooks. | -| retries transient compaction errors and emits retry events | Uncovered | C1/C3 retry and event integration. | -| does not retry non-retryable compaction errors | Uncovered | C1/C3. | -| exhausts transient compaction retries after maxRetries failures | Uncovered | C1/C3. | -| retries transient branch summary errors and emits retry events | Uncovered | N1 navigation/branch-summary resume and retry behavior. | -| persists generated branch summary usage | Uncovered | N1. | -| persists hook-provided branch summary usage | Uncovered | N1 with I1 hooks. | -| preserves app tool types for getters and update events | Covered / Uncovered | Getter defensive copies are covered by F0 scaffold tests. Persisted active-tool selection and update events belong to H4/O1. | -| validates constructor tool names | Uncovered | H4 owns tool registry plus persisted active-tool validation. | -| preserves app resource types for getters and update events | Covered / Uncovered | Getter defensive copies are covered by F0 scaffold tests. Resource update events belong to O1/H0 event wiring. | - -## Branch query and corruption tests - -Removed file: `packages/agent/test/harness/branch-query.test.ts`. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| provides identical in-memory query semantics | Covered | v4 backend conformance: `supports bounded filtered and cursor-based queries`; memory conformance runner. | -| rejects corrupt parent chains in array-backed readers | Covered / Inapplicable | The old array-backed reader type was deleted. The v4 JSONL equivalents are covered by `jsonl.test.ts`: `rejects an imported entry that references a missing parent` covers missing-parent replay, and `rejects a lane-bound entry that does not chain to the lane leaf` covers lane-tail parent chaining. Cycle parity is inapplicable for v4 JSONL replay because entries cannot reference future parents during sequential replay. | -| provides identical JSONL query semantics | Covered | J1/J2 JSONL v4 storage/repository tests plus backend conformance cover normal bounded branch queries. | -| does not decode SQLite branch entries outside query bounds | Covered | Ported to `packages/session-backends/sqlite-node/test/branch-query.test.ts`: `does not decode entries outside bounded branch queries` corrupts an out-of-bounds payload and branch-cache membership, proves bounded reads decode only requested rows, and proves an unbounded read still rejects the broken chain. | -| validates SQLite entries before filtering and limiting branch results | Covered | Ported to `packages/session-backends/sqlite-node/test/branch-query.test.ts`: `validates entries before branch query filters and limits` proves corrupt in-window entries reject before `type`, `customType`, and `limit` filtering can hide them. | -| does not validate SQLite ancestors beyond newest-first stop bounds | Covered | Ported to `packages/session-backends/sqlite-node/test/branch-query.test.ts`: `does not validate ancestors beyond newest-first stop bounds` proves `stopAtId` and `stopAtType` reads can return a valid suffix while unbounded reads still reject missing-parent and cyclic ancestor corruption. | - -## Compaction helper tests - -Removed cases from `packages/agent/test/harness/compaction.test.ts` during promotion. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| falls back to firstKeptEntryId when a compaction has no retained tail | Covered | Current `session/context.test.ts` covers empty `retainedTail` context behavior; current compaction tests cover cut-point and retained-tail preparation. | -| prepares custom and branch summary entries for summarization | Covered | Current `compaction.test.ts` covers token estimation across custom, compaction, and branch-summary roles; `session/context.test.ts` covers custom projection and branch-summary context. | - -## v4 conformance entrypoint tests - -Removed/renamed files: - -- `packages/agent/test/harness/experimental/session/memory.test.ts` -- `packages/agent/test/harness/experimental/session/sqlite.test.ts` - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| experimental memory conformance dynamic cases | Ported | `packages/agent/test/harness/session/memory.test.ts` runs the current v4 backend conformance suite. | -| uses one injectable id generator across lane views | Covered | `packages/agent/test/harness/session/memory.test.ts` keeps this focused v4 memory case. | -| experimental SQLite conformance dynamic cases | Ported | `packages/session-backends/sqlite-node/test/conformance.test.ts` runs the current v4 backend conformance suite. | - -## Repository/backend lifecycle and JSONL tests - -Removed files: - -- `packages/agent/test/harness/repo.test.ts` -- `packages/agent/test/harness/session-backends.test.ts` - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| opens, deletes, and forks by metadata (memory) | Covered | v4 conformance: `creates lists and opens sessions`, `deletes sessions idempotently`, fork cases. | -| delegates full-session fork selection without opening the source | Inapplicable | Old repository optimization was deleted; v4 fork behavior is covered by conformance. | -| retains the opened aggregate instead of reloading for scoped reads | Inapplicable | Old aggregate caching detail was deleted with the legacy repository. | -| builds context from the branch storage without loading complete history | Inapplicable / Covered | Old branch-storage optimization was deleted; v4 context behavior is covered by `session/context.test.ts`. | -| rejects repository operations and session writes after disposal | Covered / Inapplicable | The v4 core `SessionRepo` contract has no disposable state, and the in-memory/JSONL repos do not implement permanent disposal. SQLite disposal is resource release rather than repo poisoning; `packages/session-backends/sqlite-node/test/repository.test.ts` covers the remaining applicable behavior in `closes active sessions when the repository is disposed`, proving active session writes reject after repository disposal. | -| supports lexical ownership with await using | Inapplicable | The old test covered permanent disposal on the deleted in-memory repository. The v4 core `SessionRepo` contract has no disposable surface, and memory/JSONL repos do not implement lexical ownership. SQLite `await using` is resource cleanup rather than repo poisoning; active-session closure is covered by `closes active sessions when the repository is disposed` in `packages/session-backends/sqlite-node/test/repository.test.ts`. | -| serializes conflicting create and fork destinations | Covered | JSONL tests cover concurrent create/create, create/fork, and fork/fork operations targeting the same destination, plus reservation release after failed operations. | -| encodes custom session IDs used in filenames | Covered | J2 JSONL repository lifecycle validates file-safe ids; `jsonl.test.ts` rejects invalid coding-agent filenames. | -| allows appends to different sessions to run concurrently | Covered | J2/v4 repository conformance and JSONL concurrent write tests cover accepted concurrent writes without the old keyed queue. | -| caps concurrent operations across JSONL sessions at four by default | Inapplicable | Old JSONL keyed-operation-queue implementation detail was deleted. | -| allows overriding the JSONL concurrency limit | Inapplicable | Old JSONL keyed-operation-queue implementation detail was deleted. | -| rejects invalid JSONL concurrency limits | Inapplicable | Old `maxConcurrentOperations` configuration was deleted with the JSONL keyed-operation queue. | -| releases JSONL concurrency capacity after an operation fails | Inapplicable | Old JSONL keyed-operation-queue implementation detail was deleted. | -| serializes appends to the same session | Covered | v4 single-writer/session mutation conformance and JSONL shared-sequence tests. | -| uses listing as a barrier between accepted session operations | Inapplicable | The old test covered deleted JSONL `KeyedOperationQueue.enqueueBarrier()` behavior. V4 JSONL intentionally does not retain created/opened storages in the repository and does not serialize repository operations; `harness-v2.md` says callers must await operations with ordering dependencies, so no listing barrier should be restored. The replacement serialization invariant is per opened session storage and is already covered by backend conformance `linearizes concurrent writes across two lanes` plus JSONL-specific `persists concurrent cross-lane writes in shared sequence order`. | -| waits for every accepted session operation during disposal | Inapplicable | The old test covered deleted JSONL backend-wide disposal and `KeyedOperationQueue.drain()` behavior. V4 JSONL repos are not disposable and do not retain opened storages, so there is no repo-wide set of accepted operations to drain. The replacement per-session append serialization is already covered by backend conformance `linearizes concurrent writes across two lanes` and JSONL-specific `persists concurrent cross-lane writes in shared sequence order`; harness close/recovery semantics are owned by H5/O3, not repository disposal. | -| waits for accepted appends before disposal and rejects later writes | Inapplicable | The old test covered deleted JSONL repository disposal: drain accepted appends, enter a permanent disposed state, then reject later writes through existing sessions. V4 JSONL repos are not disposable, do not retain opened storages, and have no repo-level closed state. Per-session append serialization remains covered by backend conformance `linearizes concurrent writes across two lanes` and JSONL-specific `persists concurrent cross-lane writes in shared sequence order`; close/drain/reject-after-close semantics belong to harness H5/O3, not `SessionRepo` disposal. | -| parses once when opened and retains state across appends | Inapplicable | Old JSONL in-memory aggregate implementation detail; v4 correctness is covered by reopen/shared-sequence tests. | -| collects sessions below encoded cwd directories and lists by cwd | Covered | J2 metadata lifecycle and listing tests cover v4 JSONL metadata and cwd filtering. | -| fails loudly when listing a malformed session file | Inapplicable / Covered | Section 13 now requires best-effort listing: malformed headers are skipped without opening or replaying the session. JSONL tests cover skipping the malformed file while retaining valid results; direct `open()` still rejects it. | -| rejects a missing active leaf when opened | Covered | JSONL and SQLite tests cover missing-reference rejection. | -| opens, deletes, and forks by metadata (JSONL) | Covered | J2 JSONL repo conformance. | -| persists header metadata through create, list, and fork | Covered | J0 codec and J2 repository metadata tests. | -| repository disposal closes its owned storage | Covered / Inapplicable | Old in-memory repo disposal is inapplicable because v4 memory/JSONL repos are not disposable and do not own returned session storage lifetimes. SQLite is the only disposable repository because it owns DB/lease resources; active-session closure is covered by `closes active sessions when the repository is disposed`, and DB close behavior is covered by existing SQLite connection lifecycle tests. | -| owns leaf navigation, labels, names, stats, and branch traversal | Covered | v4 conformance covers lanes, latest facts, labels, statistics, and branch queries. | -| serializes concurrent appends into one parent chain | Covered | v4 conformance `linearizes concurrent writes across two lanes`; JSONL storage shared-sequence tests. | -| includes assistant and summary usage in statistics | Covered | v4 conformance `keeps latest-value facts and computes ledger statistics across lanes`, JSONL storage, and SQLite repository statistics tests. | -| stops branch traversal at retained-tail compaction | Covered / Inapplicable | Branch-query stop semantics are still required outside context projection and are covered explicitly by backend conformance `supports bounded filtered and cursor-based queries` via `findEntriesOnBranch({ stopAtType: "compaction" })` across memory, JSONL, and SQLite. Retained-tail materialization is covered by context test `starts at the latest compaction and materializes its retained tail`. The old implicit `getBranch()` auto-stop-at-retained-tail-compaction behavior is inapplicable because v4 uses explicit branch bounds plus context projection. | -| writes headers and entries and reopens the aggregate | Covered | J1/J2 JSONL storage/repository tests. | -| fails loudly for malformed headers and entries | Covered | Direct JSONL opens reject malformed headers and complete invalid mutations without modifying the file; only a torn final JSON fragment is repaired. Listing separately skips malformed headers as required by section 13. | -| enforces entry uniqueness and does not recreate deleted files | Covered | v4 conformance rejects duplicate ids; J2 lifecycle covers delete/reopen behavior. | -| scopes entry uniqueness to the session path | Covered | v4 repository/session isolation conformance. | -| rejects non-object header metadata | Covered | Format-4 open rejects non-object header metadata, while listing skips that malformed file. Format-3 normalization remains assigned to J4. | - -## Session aggregate and context tests - -Removed file: `packages/agent/test/harness/session.test.ts`. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| appends messages and builds context in order | Covered | v4 conformance appends entries in parent/sequence order; `session/context.test.ts` covers context projection. | -| reads entries forward from the requested sequence | Covered | v4 conformance `supports bounded filtered and cursor-based queries`. | -| tracks model and thinking level changes | Covered | Current `compaction.test.ts` built-context case covers model/thinking changes; R2 reducer tests cover effective configuration. | -| supports branching by moving the leaf and appending a new branch | Covered | v4 conformance lane isolation and lane move cases. | -| supports moving the leaf to root | Covered | v4 conformance lane lifecycle/targets. | -| reconstructs compaction summaries in context | Covered | `session/context.test.ts` starts at latest compaction and materializes retained tail. | -| supports moving with branch summary entries in context | Covered | `session/context.test.ts` includes branch summary context behavior. | -| persists compaction usage | Covered | v4 conformance statistics plus JSONL/SQLite statistics tests. | -| persists branch summary usage | Covered | v4 conformance statistics plus JSONL/SQLite statistics tests. | -| supports custom message entries in context | Covered | `session/context.test.ts` custom projection coverage. | -| keeps custom entries in context entries but omits them from messages by default | Covered | `session/context.test.ts` custom projection/default omission coverage. | -| projects custom entries with configured custom-entry projectors | Covered | `session/context.test.ts` custom projector coverage. | -| applies context entry transforms after default compaction selection | Covered | `session/context.test.ts` transform-after-compaction-boundary coverage. | -| normalizes session names | Covered | v4 conformance latest-value facts; JSONL metadata tests cover name metadata. | -| supports labels and session info entries without affecting context | Covered | v4 conformance facts/labels plus `session/context.test.ts` context projection. | -| rejects labels for missing entries | Covered | v4 conformance `keeps latest-value facts and computes ledger statistics across lanes` includes missing-label rejection. | -| persists leaf changes and appended entries through the backend | Covered | v4 conformance lane moves, reopen/list/fork cases across memory/SQLite/JSONL. | - -## SQLite search test - -Removed case from `packages/agent/test/harness/sqlite-node.test.ts`. - -| Removed test | Classification | Coverage / follow-up | -|---|---|---| -| searches canonical session entries by scanning | Ported / Inapplicable | Search moved to `packages/session-backends/sqlite-node/test/search.test.ts` using FTS5. The old scanning-search backend is intentionally deleted. | - -## Implementation prerequisites for the final QA pass - -These packages must land before QA3 can re-evaluate the uncovered rows above. They do not use this matrix as their test plan. - -- **QA2**: completed storage/query audit and ports for bounded-query corruption/validation behavior, repository/session disposal lifecycle, listing/disposal barriers, and branch-query retained-tail semantics outside context projection. -- **J3**: completed JSONL malformed-file, torn-tail, missing-reference, and lifecycle/concurrency coverage. -- **J4/J5**: v3 read-only normalization and first-write conversion; include malformed v3/header metadata cases. -- **I1/I2/I3/I4/L1-L3**: hook/event/mutation/effects/loop primitive coverage required before runtime harness tests can return. -- **H1-H8**: durable run, queue, configuration, wait/abort, tool, recovery, and deferred-provider runtime behavior formerly covered by legacy `agent-harness*.test.ts`. -- **C1-C3/N1**: durable compaction and navigation runtime behavior formerly covered by legacy harness compaction/branch-summary tests. -- **O1/O2**: complete event/watch snapshots and runtime telemetry around the restored operation paths. diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md deleted file mode 100644 index c07173b1ff0..00000000000 --- a/packages/agent/docs/harness-v2.md +++ /dev/null @@ -1,4612 +0,0 @@ -# Durable AgentHarness design - -> **Compatibility policy.** Only coding-agent v3 JSONL sessions require backward compatibility: they must open and restore idle. Other formats, APIs, and their tests in `packages/agent/src/harness` and `packages/session-backends/sqlite-node` may break without migrations, schema versioning, or conversion paths. - -```mermaid -flowchart TD - App[Application / UI] -->|prompt, steer, abort, config| Harness - Harness -->|snapshots + events| App - Harness -->|hooks + events| Ext[Extensions] - Harness --> Lanes[Lanes: main, ...
one operation each, parallel] - Lanes --> Loop[Stable steps + attempts
requests / planned tools] - Loop --> Provider[LLM provider] - Loop --> Tools[Tools] - Harness --> Session[Session
tree · lanes · lane records · global facts] - Session --> Storage[(memory / JSONL / SQLite)] - Harness -.->|telemetry| Obs[Observability] -``` - -One harness executes runs against one session. The session has four kinds of state (section 2), its lanes execute in parallel (section 3), and storage backends encode it (Part III). - -# Part I — Concepts - -## 1. Goals - -- **Durable runs.** An accepted prompt is a durable operation. After a crash, a new process reconstructs the operation from its records and resumes from the last durable boundary. Every state that a crash can produce is recoverable. -- **Durable responses.** Partial streams are process-local. Every settled assistant-generation and deferred-fetch response is appended completely before classification, including retryable errors, overflow, deferred, and aborted responses. An `aborted` response means operation abort only when an earlier `abort_requested` won its append race; otherwise it is a provider interruption. -- **Lanes.** A lane is a named position in the conversation tree with at most one operation. Lanes run in parallel; runs and queued messages stay on their accepting lane. A Slack channel can be a session with one lane per thread. Interactive pi uses one hidden lane. Extensions receive the full lane-aware harness API; for example, a subagent may use another lane in its parent's session. -- **No partial outcomes.** A crash inside any operation — run, compaction, navigation — leaves one of two states: the operation has not happened, or recovery can complete it. Nothing in between is observable. -- **Harness API.** Events observe execution and cannot change it. Hooks intercept execution and can change it: context, requests, tools, run boundaries. Extensions build on events and hooks. -- **Deterministic stepping.** Every effect — durable write, provider request, tool execution, hook, timer — crosses one injected boundary. In `drive: "manual"` the harness parks before each effect and a test drives it call by call: stop at any boundary, inject input, or close and reopen to simulate a crash. Production and tests run the same procedures; the drive mode only controls the boundary (section 15). -- **Observability.** All execution is instrumentable for logging and tracing, down to provider request and response internals. This channel is separate from the hook system. -- **UI model.** A client gets one atomic snapshot, then a live event stream. Events are not replayed. Reconnect means a new snapshot. -- **Single writer.** One harness writes a session at a time. The serving layer enforces this. All lanes of a session live in that one harness. Restore treats states that a single writer cannot produce as corruption. -- **v3 sessions load.** Old coding-agent v3 JSONL files open unchanged and restore idle. - -## Non-goals - -- **Exactly-once hook side effects.** A hook result becomes durable when the record or entry that consumes it commits. A crash before that commit can run the hook again (section 11 replay table). Side effects a hook makes on its own are invisible to the harness: HTTP calls, file writes. A hook that needs crash-safe external effects must be idempotent, for example keyed by operation id. -- **Provider stream resumption.** Partial streams are never persisted or resumed. After a crash, an attempt without a response is an unknown provider effect: recovery starts a later numbered attempt only when policy permits, or, with an abort marker, settles the existing attempt once as synthetic `aborted` under its provisioned id without repeating the effect. A settled stream is persisted before classification. Deferred requests remain in scope: pi-ai persists the provider handle in a `deferred` assistant message. One stable deferred-fetch step redeems that response and later pending responses, copying the original generation step's total configuration and normalized retry policy once. Each `resume()` performs at most one numbered check and persists its response, including another pending `deferred` response. Recovery polls the newest persisted source instead of starting replacement generation. -- **Multiple writers.** Two processes on one session are out of scope. The serving layer routes all traffic for a session to the process that holds its harness. Lanes cover the workloads that look like multi-writer: parallel threads over shared history. -- **Replication.** A session lives in one place. Coordination-free sync of diverging copies is a different design. Nothing forecloses it later. -- **Coding-agent migration.** Migrating coding-agent to `AgentHarness` is out of scope. Compatibility means the new JSONL repository can read supported coding-agent v3 files. - -## 2. What a session is - -A session has four durable parts: - -1. **The tree** — the conversation. Entries with `parentId` links: messages, compaction summaries, branch summaries, custom entries. The tree is shared and passive. It belongs to no lane. It only grows; entries are never changed or deleted. -2. **Lanes** — where work happens. A lane is a name plus a leaf: the entry that future work extends. Every session has the lane `main`. Applications create more, keyed by external identity (a Slack thread id, an email thread id). -3. **Lane records** — the lane's current total configuration plus what happened and what must happen. One flat, chronological record sequence per lane contains total `lane_config` replacements and operation records such as operation started, step started, attempt started, tool batch planned, message queued, and operation finished. Commits update live state; after a crash, the same records are the authority for reconstructing it. -4. **Global facts** — session-scoped values where the latest write wins: the built-in session name and entry labels, plus string-keyed application facts in a separate custom namespace. They are not part of the tree and are kept as append-only history. Setting a name, label, or custom fact to `undefined` appends a deletion; JSON `null` remains a custom-fact value. The built-in and custom namespaces never overlap. - -All writes across the four parts share one monotonic sequence number. The sequence orders global-fact history and lets a lane's records refer to tree positions. - -```text -tree (shared, append-only) lanes -a ── b ── c ── d main → d (total config; op log: …) - └── e ── f slack:171943… → f (total config; op log: …) - -global facts: name = "Refactor auth", label(b) = "checkpoint-1", - custom("extension.example/state") = { "reviewed": true } -``` - -### Active and passive - -The tree and global facts are passive shared data. A lane is active: it owns its leaf, total configuration, operation log, queues, and pending writes. Lanes share none of these. A lane's durable actions append entries at its leaf or records to its sequence. - -### Invariants - -- The tree is conversation only. No lane configuration, orchestration state, or pointers live in it. -- An entry's parent chain never changes. Branches share prefixes; nothing is copied. -- A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that entry), or the lane navigates (leaf jumps to an existing entry). -- Configuration and operation records never enter the tree. Deleting every operation log leaves a complete, valid conversation. -- At most one operation is open per lane. A state where one lane has two open operations is corruption. -- Entries are shared; lane records are not. Two lanes may have the same entry on their paths. A record belongs to exactly one lane. - -Lane records describe configuration or execution, not conversation. They never enter model context, transcripts, or branch queries, and source records are not copied into forks. Within one lane their order is already their meaning, so parent links would add nothing. - -## 3. Lanes - -A lane is a named tree position plus the work serialized on it. It resembles a git branch in its own worktree: new work advances it, and navigation moves it to any existing entry without rewriting history. - -Every session has `main`. Applications create more lanes from a name and anchor entry. Lane names are permanent application keys, such as Slack or email thread ids; the platform UI can provide their inventory. - -A lane owns: - -- **Its leaf.** New entries chain to it and move it. Navigation jumps it. -- **Its operation log.** At most one open operation. A second operation on a busy lane is rejected; other lanes are unaffected. -- **Its queues.** Steering, follow-ups, and next-run messages target one lane. -- **Its total configuration.** One value contains the model reference, thinking level, and active tool names. A `lane_config` record always replaces the whole value; it is never a patch or a tree entry. `main` and every new lane start from the same immutable seed captured from `AgentHarnessOptions`, not from their anchor or another lane. A setter immediately appends a total replacement on the lane's mutation line, even while an operation is open, and that replacement survives abort. Every generation step snapshots the current total configuration when its `step_started` record commits; all attempts of that step use the snapshot. Tool implementations, resources, and stream options are harness-global; only tool activation is per-lane. - -Rules: - -- Lanes run operations in parallel. The harness stays the single writer; lane records and entries interleave in the shared sequence. -- Creating a lane copies no tree content, operation history, or configuration from its anchor. Creation atomically establishes the pointer and the seed total configuration. Lanes are not deleted or renamed. -- State-dependent mutations on one lane are linearized on that lane's mutation line: validation, at most one atomic storage append, and the in-memory update complete before the next mutation starts (section 15). Provider, tool, hook, and retry work never occupies the mutation line. -- Two lanes at the same leaf diverge on their next append. The tree handles this; no coordination exists between lanes. -- A lane with an unfinished operation restores as suspended, independently of its siblings. Suspension has a reason: crash, or a deferred provider request (section 1). - -## 4. How work executes - -### Operations - -An operation is the unit of durable work on a lane. Three kinds: - -- **Run** — an accepted prompt, through all automatic continuations: tool calls, steering, follow-ups, auto-compaction. Ends when nothing is pending. -- **Compaction** — replaces old context with a summary entry. -- **Navigation** — moves the lane's leaf to an existing entry, optionally with a branch summary. - -Acceptance precedes execution and is durable: after a crash, recovery completes or explicitly closes the operation. Exactly one `operation_finished` record is terminal; no tree entry is universal. Runs finish `completed`, `failed`, or `aborted`. Compaction and navigation may also finish `declined` when their decision hook vetoes the effect. - -### Runs, turns, and steps - -A run is a sequence of turns. A turn is one assistant generation step plus the complete tool batch requested by the accepted assistant response. - -A **step** is a durable logical unit within an operation. A **generation step** starts new LLM generation for an assistant response, compaction summary, or branch summary. Its `step_started` record assigns a stable `stepId` and snapshots the total lane configuration and normalized retry policy shared by its numbered provider attempts. The durable attempt count survives restarts. A deferred-fetch step instead polls existing provider work, but also has a stable `stepId`, numbered attempts, and one copy of the original generation step's configuration and policy so polls are self-contained. - -Structural decision hooks create a step without generation when they supply the result themselves. That `step_started` stores the complete provisioned compaction or branch-summary entry and optional hook-usage intent, so recovery never reruns the decision. A generated compaction commits by appending its result entry directly; it has no prepared-result record. A generated branch summary must survive a later navigation move, so its complete payload becomes a dedicated durable prepared record before the move. Structural provider streams are internal and never appear as public assistant-message events. - -Before an assistant-generation or deferred-fetch provider effect, its attempt provisions the response entry id. The complete settled response is appended under that id for every stop reason before classification can retry, compact, suspend, fail, abort, or accept it. A crash that leaves no response makes the effect unknown: without abort, recovery starts the next numbered attempt when allowed or appends a synthetic interruption under the provisioned id at the cap; with an abort marker, it appends synthetic `aborted` under that id and never retries. - -For an assistant generation, `triggerMessageId` is the id of the newest consumed message that projects as user context and caused that generation. A prompt, consumed steer or follow-up, or another run-owned user-context message can supply it. It bounds overflow recovery to one compaction for that user input: - -```text -consume user message U1 -start assistant step A1, triggerMessageId = U1 -persist recoverable-overflow response R1 -compact once, linked to R1 and U1 -start assistant step A2, triggerMessageId = U1 -persist recoverable-overflow response R2 → fail; no second compaction -consume steering message U2 -start assistant step A3, triggerMessageId = U2 → one new overflow compaction is allowed -``` - -An accepted assistant response with tool calls gets one durable planned tool batch before call clearance — tool lookup, argument validation, and `before_tool` — or execution. The provider contract requires `toolCallId` to be unique within one assistant response. The plan assigns a result entry id to every source call index, including calls later blocked, invalid, interrupted, or aborted. The index exists only to preserve source ordering and locate that planned result; it is not exposed publicly. A real call then writes `tool_started` immediately before its individual effect. Parallel effects may settle concurrently, but results finalize and append in source order (section 14). - -### Queues and deferred writes - -Two mechanisms carry input into a running lane. They differ in abort behavior: - -- **Queues** carry conversational intent: `steer` corrects the current work, `followUp` adds work for when the model would stop, `nextRun` seeds the lane's next run. Steering and follow-ups die on abort; their payloads are returned to the caller. Next-run messages survive. -- **Deferred writes** carry tree additions requested while a step is in flight. They survive abort and are applied even during cancellation. - -Both become durable when acceptance records their full payload. Their tree entry is appended later at application or consumption, where the model first sees it. Recovery completes an accepted item whose entry is absent. - -Lane configuration updates do not use deferred writes. A setter commits an immediate total `lane_config` replacement on the mutation line, so later generation steps see it while an already-started step and all its retries retain their captured configuration. - -### Checkpoints - -Between turns, the lane passes a checkpoint: - -1. Apply pending deferred writes. -2. Consume queued steering messages. -3. Compact if the next request would not fit. - -Compaction has a reactive trigger too: a durable provider response can reveal that its request did not fit — an explicit context-limit error, reported input plus cache-read tokens greater than the attempt's captured window, or a recoverable `length` stop. A response classified as recoverable overflow starts no tool batch. The run may start one overflow compaction linked to that exact response entry and its `triggerMessageId`; compaction preparation omits the linked response. A second recoverable overflow with the same trigger fails instead. Consuming a newer user-context message supplies a new trigger and permits one new compaction (section 6, "Context overflow at an assistant step"). - -A turn with tool calls forces another turn so the model sees its results — with one exception: a batch in which every finalized tool result persisted `terminate: true` suppresses automatic tool continuation (steering or follow-up input can still start another turn). Follow-up messages are consumed only when tool continuation and steering are exhausted. The run ends when a checkpoint finds nothing pending. - -### Append-only context - -> Across the requests of a lane, provider context only grows at the tail. An insertion before the previous request's tail invalidates the provider's KV cache from that point on and multiplies token cost. - -This invariant is why mid-turn writes defer to checkpoints: checkpoint application appends at the tail. Persistence and provider-context projection are separate. Assistant responses with stop reason `error`, `aborted`, or `deferred` project to no provider message. A genuine output-limit `length` response remains in context; a response classified as overflow is omitted through its exact compaction link. Compaction is the one deliberate cache invalidation; it trades that invalidation for a smaller context. - -### Lane lifecycle - -```mermaid -stateDiagram-v2 - [*] --> Idle: restored, no open operation - [*] --> Suspended: restored, open operation - Idle --> Running: operation accepted - Running --> Idle: finished - Running --> Cancelling: abort - Cancelling --> Idle: reconciled - Running --> Suspended: deferred handle persisted - Suspended --> Running: resume continues the open operation - Suspended --> Cancelling: abort -``` - -- States are per lane. One exception: a failed storage write faults the whole harness. A faulted harness stops all effects and rejects all calls; after the cause is fixed, reopening restores each lane from its records. -- **Suspended** means: an operation is open, nothing executes. Reached by restore after a crash, or deliberately when a deferred handle is persisted. `resume()` continues the operation; `abort()` starts cancellation reconciliation instead of ordinary continuation. -- **Abort** first records the cancellation durably; that marker is the authority. It then signals running effects and returns. Reconciliation completes missing accepted initial messages, required planned tool results, and accepted deferred writes. There is no universal assistant closure, and the harness never starts a request or appends an assistant message solely to manufacture one. `operation_finished` with outcome `aborted` is the universal terminal marker. Repeating `abort()` while that marker is open returns the same drained steer/follow-up payloads without writing another marker or signaling again. Automatic drive reconciles in the background; manual drive leaves reconciliation parked at its next action. - -### Resume - -Resume continues, but never starts, an operation and uses no persisted program counter. The harness reduces durable records and planned entry ids, then re-enters the ordinary procedure at its first unfinished transition: settle a missing attempt response, classify an accounted response, reconcile a tool batch, perform at most one deferred poll, or continue a checkpoint. Every poll response is durable, so later resumes use the newest persisted source. Accepted queues and deferred writes remain pending. - -For deferred polling, the **source lineage** is the response-entry chain that identifies what each poll redeems: the original deferred response is the first source, each pending poll response becomes the next source, and an interrupted or unknown poll retains its existing source. **Complete handle equality** means every required field, every optional field's presence and value, and the JSON value in `data` match; section 16 defines the field-level comparison. - -# Part II — How execution is recorded - -Part II is backend-neutral. It defines the records a lane writes, when it writes them, and how recovery reads them back. Part III maps this onto APIs and storage. - -## 5. Records - -### The durability rule - -> Before an effect: write an intent record that names what will happen and every durable id settlement will use. After an assistant/fetch effect: append the complete response entry, then its preplanned usage record. - -Each procedure boundary below uses a separate storage append unless the contract explicitly requires one atomic multi-write append: configured lane creation and labeled navigation completion. A single append may contain several logical mutations, which commit all-or-none with consecutive sequence positions; there is no crash prefix inside it. Assistant attempts, responses, and usage remain separate appends. A crash between an assistant/fetch attempt and its response therefore leaves the external effect unknown; without abort, recovery starts a later numbered attempt or closes at the cap under the existing response id. An abort marker instead closes that attempt as synthetic `aborted` under its existing response id. A crash between response and usage reconstructs the exact usage record before classification. A structural `step_started` provisions one typed result id. A generated compaction closes with its result entry or `step_failed`; a generated branch summary stops requesting once its complete `branch_summary_prepared` is durable and closes when that exact entry is appended or the step fails before preparation. A hook source stores its complete result on `step_started` and closes when that entry appends. An unknown generated attempt advances only under the captured policy. An assistant entry with `stopReason: "deferred"` fulfills its generation attempt and closes that generation step; what stays outstanding is the operation — the persisted handle awaits redemption (section 6). A provisioned id that exists with different content is corruption. - -### Provisioned ids - -Intent records carry the ids of entries that do not exist yet: - -```ts -/** An entry payload with its id pre-allocated. parentId, seq, and timestamp - are assigned by storage when the entry is appended: it chains to the - lane's then-current leaf. */ -type ProvisionedEntry = - T extends Entry ? Omit : never; -``` - -### Record catalog - -Every record belongs to one lane's record sequence. Records that belong to an operation carry `runId`: the id of that operation's `operation_started` record. Total configuration records, next-run queue records (`queue_enqueued` and their `queue_cancelled`), and standalone `adjustment` usage records carry no `runId`. - -```ts -interface RecordBase { - id: string; - seq: number; // shared sequence, section 2 - lane: string; - timestamp: number; // Unix ms -} - -interface ModelReference { - provider: string; - modelId: string; -} - -/** The complete durable configuration of one lane. Arrays are copied on - input and output. Tool implementations remain harness-global runtime - capabilities; only their names persist here. */ -interface LaneConfiguration { - model: ModelReference; - thinkingLevel: ThinkingLevel; - activeToolNames: string[]; -} - -// A total replacement, never a patch. The newest record is the lane's -// current configuration. It is independent of operations and survives -// abort. Every configured format-4 lane has at least one such record. -interface LaneConfigRecord extends RecordBase { - type: "lane_config"; - configuration: LaneConfiguration; -} - -// Acceptance boundary of an operation. Everything decided before acceptance -// is persisted here. This record's own id IS the runId that all other -// records of the operation carry. -interface OperationStartedRecord extends RecordBase { - type: "operation_started"; - sourceLeafId: string | null; // the lane's leaf at acceptance - intent: - | { - kind: "run"; - /** Normalized caller input after skill/template expansion, before - before_run. Kept for SuspendedOperation and before_resume. */ - originalPrompt: AgentMessage[]; - /** Captured nextRun items, then the prompt, then before_run - injections. Full payloads, provisioned ids. Capture happens in - the acceptance mutation (section 15): items present when it runs - belong to this run; later items belong to the next. */ - initialMessages: ProvisionedEntry[]; - /** Present only when a hook overrode the system prompt; fixed for the - whole run. Absent: the systemPrompt callback runs per request. */ - systemPromptOverride?: string; - /** Opaque state keyed by stable hook registration id. Each - before_resume handler receives only the value under its id. */ - resumeData?: Record; - } - | { - kind: "compaction"; - customInstructions?: string; - resultEntryId: string; // provisioned compaction entry - } - | ({ - kind: "navigation"; - customInstructions?: string; - } & ( - | { targetId: null; label?: never } // root has no label fact - | { targetId: string; label?: string } // written at completion - ) & ( - | { summarize: false; summaryEntryId?: never } - | { summarize: true; summaryEntryId: string } // provisioned branch-summary entry - )); -} - -// Operation acceptance does not copy lane configuration. A generation -// step captures it when that step starts; retries use that captured value. - -// Written exactly once before the first abort() resolves. A request marker, -// not a terminal state: reconciliation follows, then operation_finished with -// outcome "aborted" unless a structural commit had already won. Kills this -// operation's steer/follow-up queue items; next-run items survive. Repeated -// abort() calls while the operation remains open return the same killed items -// without another record. -interface AbortRequestedRecord extends RecordBase { - type: "abort_requested"; - runId: string; -} - -// Closes the operation. failed = orderly durable failure (for example, -// retries exhausted). aborted = closed only by an earlier abort_requested. -// declined = vetoed by a hook before any effect. -type OperationFinishedRecord = RecordBase & { - type: "operation_finished"; - runId: string; -} & ( - | { outcome: "failed"; error: { code: string; message: string } } - | { outcome: "completed" | "aborted" | "declined"; error?: never } -); - -// Starts one durable logical step. This record's own id IS the stepId. -// Generation sources copy configuration arrays and persist the normalized -// retry policy once, so every attempt, including one started after reopen, -// uses the same cap and backoff. A hook source instead persists the complete -// provisioned result; it has no provider attempts. hookUsageRecordId is -// present exactly when that result reports usage, so recovery can write the -// exact accounting record before the result entry without rerunning the hook. -type StructuralStepSource = - | { source: "generated"; - configuration: LaneConfiguration; - retryPolicy: RetryPolicy; - hookResult?: never; - hookUsageRecordId?: never } - | { source: "hook"; - configuration?: never; - retryPolicy?: never; - hookResult: ProvisionedEntry; - hookUsageRecordId?: string }; - -type StepStartedRecord = RecordBase & { type: "step_started"; runId: string } & ( - | { step: "assistant"; - configuration: LaneConfiguration; - retryPolicy: RetryPolicy; // normalized total value - triggerMessageId: string } - | { step: "deferred_fetch"; - /** Exact copies from the assistant generation step that persisted the - original deferred response. Copied active tools govern a ready - response's tool calls; the source handle supplies fetch identity. */ - configuration: LaneConfiguration; - /** Normalized copy. maxAttempts applies to attempts naming one source; - a successful pending response creates a new source. */ - retryPolicy: RetryPolicy } - | ({ step: "compaction"; - resultEntryId: string; // always a CompactionEntry - } & StructuralStepSource & ( - | { compactionReason: "manual" | "threshold"; - supersededResponseEntryId?: never; triggerMessageId?: never } - | { compactionReason: "overflow"; - /** Exact durable assistant response omitted by this recovery. */ - supersededResponseEntryId: string; - /** Copied from the assistant step that produced that response. */ - triggerMessageId: string } - )) - | ({ step: "branch_summary"; - resultEntryId: string } // always a BranchSummaryEntry - & StructuralStepSource) -); - -// Written immediately before one attempt's provider work. attempt is 1-based -// and consecutive within stepId. Assistant and deferred-fetch attempts each -// contain one provider effect and provision both objects settlement must -// produce: first the complete response entry, then its usage record. Their -// response ids are fresh per attempt. -// Generated structural attempts use the one typed result id on step_started; -// an attempt may make several provider requests (split-turn compaction makes -// two). Hook structural sources have no attempts. -type StepAttemptRecord = RecordBase & { - type: "step_attempt"; - runId: string; - stepId: string; - attempt: number; -} & ( - | { step: "assistant"; - responseEntryId: string; - usageRecordId: string; - /** Request-specific intended limit before context clamping. */ - intendedOutputLimit: number; - /** Context-window size used by this request. */ - contextWindow: number } - | { step: "deferred_fetch"; - /** Exact deferred response entry whose complete handle this poll - redeems. Pending responses advance this lineage even when the - complete handle is unchanged; interrupted responses do not. */ - sourceEntryId: string; - responseEntryId: string; - usageRecordId: string } - | { step: "compaction" | "branch_summary" } -); - -// A generated branch summary must be durable before navigation moves. This -// record stores the complete payload produced by one successful attempt. The -// later entry append uses it byte-for-byte; compaction has no corresponding -// prepared record because its result-entry append is its commit point. -interface BranchSummaryPreparedRecord extends RecordBase { - type: "branch_summary_prepared"; - runId: string; - stepId: string; - attempt: number; - result: ProvisionedEntry; -} - -// Structural generation has no assistant error entry to represent terminal -// failure. After the durable attempt cap is exhausted, this record closes a -// generated step. Assistant and deferred-fetch failures are complete response -// entries; hook-sourced structural steps cannot fail after their start record. -interface StepFailedRecord extends RecordBase { - type: "step_failed"; - runId: string; - stepId: string; - step: "compaction" | "branch_summary"; - error: { code: string; message: string }; -} - -// A resumed generation request uses the total configuration and retry policy -// captured by step_started, not the lane's current replacements. A deferred -// step copies both from the original generation step; each attempt additionally -// uses the provider/model carried by its exact source entry. - -// Written once after an assistant response is accepted and before clearance -// of any call. The array has exactly one item per source tool call, in source -// order. toolIndex preserves that order and locates the call's planned result; -// resultEntryId is the one destination for every real or synthetic outcome. -interface ToolBatchStartedRecord extends RecordBase { - type: "tool_batch_started"; - runId: string; - assistantEntryId: string; - calls: { toolIndex: number; resultEntryId: string }[]; // 0..tool-call count - 1 -} - -// Written after before_tool and validation pass, immediately before this -// call's individual effect. The result id is already fixed by the batch plan. -interface ToolStartedRecord extends RecordBase { - type: "tool_started"; - runId: string; - assistantEntryId: string; - toolIndex: number; - toolCallId: string; - toolName: string; - effectiveArgs: Record; // after before_tool - /** The tool's declared replay safety, snapshotted at execution time. - Recovery re-executes an unfinished call only when this field AND the - current tool declaration both say "safe"; otherwise it writes a - synthetic "interrupted" result. */ - replay: "never" | "safe"; -} - -// Queue acceptance. The payload travels here; the entry appears at the -// consumption point. -type QueueEnqueuedRecord = RecordBase & { - type: "queue_enqueued"; - target: ProvisionedEntry; -} & ( - | { queue: "steer" | "followUp"; runId: string } - | { queue: "nextRun"; runId?: never } -); - -// Durable retraction of a pending queue item, before consumption. Without -// this record a crash would resurrect the item: recovery treats a -// queue_enqueued without its entry as pending. -interface QueueCancelledRecord extends RecordBase { - type: "queue_cancelled"; - runId?: string; // matches the queue_enqueued it kills - entryId: string; // the enqueued target's provisioned id -} - -// Deferred-write acceptance: a tree entry requested while a step was in -// flight. Applied at the next checkpoint. Configuration never uses this -// record; its total replacement commits immediately. -interface WriteDeferredRecord extends RecordBase { - type: "write_deferred"; - runId: string; - target: ProvisionedEntry; -} - -// The cost ledger. Written whenever usage is reported or adjusted, whatever -// happens to later orchestration. Assistant and deferred-fetch settlement -// writes the complete response entry first, then the preplanned usage record; -// recovery checks that one presence bit and reconstructs a missing record from -// the immutable response usage before classification. Other usage remains pure -// accounting. A transport death mid-stream can still bill unreported tokens. -type UsageRecord = RecordBase & { type: "usage"; usage: Usage } & ( - // A provider request settled. Split-turn compaction writes two records - // sharing one structural attempt and result entry id. - | { cause: "assistant" | "compaction" | "branch_summary" | "deferred_fetch"; - runId: string; stepId: string; entryId: string; attempt: number; - stopReason: TerminalStopReason } - // A finalized tool result reported nested LLM work; skipped when it - // reports none. A safe replay writes a second record for the second - // execution: both were billed. - | { cause: "tool"; runId: string; entryId: string; toolCallId: string } - // A hook-supplied summary carried usage the hook measured itself. - | { cause: "hook"; runId: string; entryId: string } - // Application-supplied, anytime (lane.recordUsage): reconciliation, - // estimates, corrections. Negative values are legal. - | { cause: "adjustment"; runId?: string; entryId?: string; details?: JsonValue } -); - -type LaneRecord = LaneConfigRecord | OperationStartedRecord | AbortRequestedRecord - | OperationFinishedRecord | StepStartedRecord | StepAttemptRecord - | BranchSummaryPreparedRecord | StepFailedRecord | ToolBatchStartedRecord - | ToolStartedRecord | QueueEnqueuedRecord | QueueCancelledRecord - | WriteDeferredRecord | UsageRecord; - -type NewRecord = - T extends LaneRecord ? Omit : never; -``` - -The batch plan covers every call outcome, even without execution. Blocked and invalid calls write no `tool_started`; they append an `isError: true` result under their source index's planned id. A crash before that entry reruns clearance, including `before_tool`, against the same id. Genuine output-limit `length`, abort before start, and unsafe started-call recovery likewise append explanatory, aborted, or interrupted synthetic results under planned ids. - -A tool batch needs no outcome record: its result entries durably store every outcome and finalized `terminate` decision (section 12). A real call writes reported usage against its planned result id before the result entry; the entry snapshots only that finalized execution's `AgentToolResult.usage`. A crash before the result follows section 6 replay policy. Safe replay reruns `after_tool` as section 1 permits and may add another usage record; the ledger retains and sums each execution. Synthetic results have no usage snapshot, even if a lost execution left usage. Public hooks, events, snapshots, and tool context use provider `toolCallId` and tool name; source index remains private ordering and planned-result correlation. - -Settlement needs a second durable object because **cost durability must not depend on classification**. Every assistant-generation and deferred-fetch response is appended under its planned id, followed by its preplanned `usage` record before any retry, overflow, suspension, failure, abort, or acceptance logic. Recovery reconstructs a missing record from the attempt and immutable response usage. Structural requests have no assistant entry, so each reported usage is written immediately after its request and before a generated compaction entry or `branch_summary_prepared`; the provider-settle-to-write crash window remains. A successful structural result snapshots the sum of its successful attempt's request usage records, while failed-attempt records remain ledger-only. Tool usage precedes its planned result. Hook structural usage uses the id provisioned on `step_started` and is repaired from the stored result before result commit, including when abort prevents that commit. Applications use `adjustment` for unseen cost. - -A harness-written `usage.entryId` identifies the measured entry. Assistant and deferred-fetch entries exist before usage; a failed structural attempt may bind usage to a typed result id that never materializes. An entry's `usage` is an immutable display snapshot written at append. Its **effective cost** is the read-time sum of all lanes' usage and adjustment records bound to its id; **session cost** sums the whole ledger. A later provider attempt or replay writes another record because another billable execution occurred. - -### Validity - -These rules define valid writes and valid prefixes. Append-time validation applies the relationships available at each write. Restore applies them only to the indexed configuration, discovered open operation, independent next-run slice, and exact planned entries described in section 7; it never scans completed history merely to re-audit it. Within that bounded input, restore rejects corruption when: - -- a configured format-4 lane has no `lane_config` record, or a `lane_config` payload is not total; -- more than one operation is open; -- a navigation operation targets its own `sourceLeafId`, or has `targetId: null` and a label; root has no label fact; -- append/replay observes a lane create without its immediately following first total config, or completed labeled navigation without its immediately preceding accepted label fact in the same atomic append; -- an `operation_finished: failed` lacks `error`, or any other finish outcome carries `error`; -- one operation has more than one `abort_requested`, an `operation_finished: aborted` has no earlier abort marker, or an assistant/fetch response appended after the marker kept another stop reason; an `aborted` response without an earlier marker is valid provider interruption, not corruption; -- a `step_started`, `step_attempt`, `branch_summary_prepared`, `step_failed`, `tool_batch_started`, or `tool_started` follows its operation's abort marker; reconciliation may append only settlements already intended before the marker, accepted initial/deferred writes, accounting, committed structural results, and the terminal record; -- a run with an abort marker finishes with an outcome other than `aborted`, or a compaction/navigation with a marker finishes non-aborted when its result-entry/move commit had not already won; -- a record references an operation that does not exist, or follows its finish; -- a `step_attempt`, `branch_summary_prepared`, or `step_failed` references no earlier `step_started`, names a different operation or step kind, or follows that step's failure; -- a generation `step_started` has a non-total configuration or non-normalized retry policy; a deferred-fetch step has non-total copied configuration or non-normalized copied retry policy, or a second fetch step appears before the original deferred response and its later pending responses settle; an assistant step has no `triggerMessageId`; a structural step has the wrong typed result id; a manual structural step's result id disagrees with its accepted operation intent; a generated structural source lacks total configuration or normalized policy; or a hook structural source carries either of those generation fields; -- a hook structural source's complete provisioned result has the wrong id or type, has `fromHook !== true`, omits required compaction preparation fields, or disagrees with its accepted navigation source; its `hookUsageRecordId` is present exactly when the result has usage, and the matching `cause: "hook"` record, when present, must reproduce that usage and precede any result entry; -- a structural attempt or `step_failed` belongs to a hook source; a generated branch-summary source has more than one `branch_summary_prepared`, or its prepared record names a nonexistent/non-latest attempt, has the wrong result id/type or `fromHook !== false`, or follows the navigation move; a generated compaction has any prepared-result record; -- a generated structural result entry or prepared branch-summary payload with a usage snapshot has no matching successful-attempt usage records before it, or their sum differs from that snapshot; failed-attempt usage remains valid ledger history; -- a non-overflow compaction step carries overflow-link fields, or an overflow compaction step does not name both `supersededResponseEntryId` and `triggerMessageId`; the named entry must be the complete, accounted response of an earlier assistant attempt in the same run, its assistant step must carry the same trigger, and the durable response plus attempt fields must satisfy the overflow predicate; -- two overflow compaction steps use the same `triggerMessageId`; live execution gives a generation after newly consumed user-context input that newer message's id instead; -- attempt numbers are not consecutive within a `stepId`; -- two assistant/fetch attempts reuse a response or usage id, or their request-specific limits/source entry are invalid; a deferred-fetch source must be the original deferred response, the newest equal-handle pending response, or the unchanged source of a below-cap interrupted or unknown poll, as applicable; a pending response without an abort marker whose complete handle differs from its source is invalid, and no fetch attempt may follow a ready, terminal, or capped interrupted response while redeeming that original deferred response; -- an assistant/fetch response id exists as anything other than its complete `MessageEntry`, its usage record exists before that response, or the preplanned usage id exists with fields that do not match the attempt and immutable response usage; -- a `step_failed` belongs to assistant/fetch or hook-sourced work, or a structural result entry or prepared branch-summary payload coexists with `step_failed` for one step; -- a steer or follow-up `queue_enqueued` for a run follows its `abort_requested`; -- a `queue_cancelled` targets an id with no `queue_enqueued`, or one whose entry exists; -- a `tool_batch_started` does not name a complete, accounted, accepted assistant response in the same run, more than one plan names the same response, or a recoverable-overflow response owns a plan; -- a batch plan's `calls` are not exactly the source response's tool calls by zero-based source index and order, its result ids are not unique, or a result id is reused by another provisioned object; -- a `tool_started` has no earlier batch plan for its assistant entry and source index, does not identify the stored `toolCallId` and `toolName` at that index, or duplicates a start for that planned source position; the started record never supplies or changes the planned result id; -- planned tool-result entries do not form a source-order prefix, a planned id exists as a non-tool-result entry, or a tool `usage` record does not bind a planned started call and its stored provider call id; when a real result's finalized execution reports usage, that record must precede the result and match its `AgentToolResult.usage`, while a synthetic result has no usage snapshot; -- a provisioned id exists with different content. - -For navigation, bounded validity also compares the current leaf with the accepted source, target, and summary id. Without summarization, only source-before-move and target-after-move are valid. With summarization, the move requires a durable payload first: a hook result on `step_started` or a generated `branch_summary_prepared`. The valid sequence is source with no payload, source with payload, target with payload and no result entry, then summary-entry leaf with that exact result. A target leaf without a payload, a result entry while the leaf is not its id, or any unrelated leaf is corruption. No branch walk is needed. - -## 6. What each action writes - -Traces at the storage level. All traces show one already-configured lane; its initial `lane_config` precedes the shown operation. Legend: - -```text -E entry appended to the tree (chained to the lane's leaf) -R record appended to the lane's record sequence -L lane pointer move -G global fact written -A one atomic append containing the bracketed logical mutations -H hook (awaited; hooks are Part I concepts, their API is Part III) -X crash site -``` - -### Run with one tool call - -```text - prompt("fix the bug") -H before_run may inject entries, override system prompt -R operation_started kind run; initial messages with provisioned ids -E user message the provisioned id from the intent -R step_started assistant; config, retry policy, trigger = user id -R step_attempt attempt 1; provisioned response and usage ids -E assistant message [tool call] complete settled response, every stop reason -R usage preplanned id; before classification -R tool_batch_started c1 source index and provisioned result id -H before_tool may change args or block -R tool_started c1 effective args and replay; result id already planned - execute tool c1's individually gated phase-two effect -H after_tool may patch result, usage, and terminate -R usage c1 tool usage, only when reported; before result -E tool result c1's planned result id; persists the terminate decision -R step_started next assistant step; new stable id and trigger -R step_attempt attempt 1; fresh response and usage ids -E assistant message "done" -R usage -H before_run_end nothing pending, returns nothing -R operation_finished completed -``` - -A crash between any two lines is recoverable. An assistant/fetch attempt without its response is an unknown effect. Its provider effect never repeats under that attempt number, and its ids are never assigned to a later attempt; absent abort it advances under policy, while an abort marker settles synthetic `aborted` under that attempt's planned response id. A response without usage gets its exact preplanned record before classification. A generated compaction without its typed result and a generated branch summary without its prepared payload continue under captured policy or close with `step_failed`; a hook source already contains its complete result. A provider response or generated structural result without its planning step/attempt cannot exist. - -### Retry - -```text -R step_started assistant S; captured config and retry policy -R step_attempt S attempt 1; response R1, usage U1 -E assistant message R1 retryable error, complete and durable -R usage U1 reconstructed from R1 if this write was interrupted - retry delay -R step_attempt S attempt 2; fresh response R2, usage U2 -E assistant message R2 successful response -R usage U2 -``` - -Every settled assistant-generation and deferred-fetch response is appended before its usage record and before classification; the other traces omit those paired writes only where stated. Per-request hooks (`transform_context`, `before_request`, `after_response`) run inside every request and are omitted everywhere; Tier B records them (section 19). - -Crash during the first backoff: restore reads the response and usage for attempt 1, classifies that same durable response, and starts attempt 2 if the captured policy permits. The count never resets, and every attempt has a distinct response id. A retryable response at the cap — or a non-retryable terminal error — is already the durable assistant error that leads to `operation_finished` failed: - -```text -E assistant message stop reason error; the failure is durable -R usage exact preplanned record -X crash operation still open -R operation_finished recovery writes failed — never completed -``` - -The error entry is the terminal-failure marker. Recovery that finds it drains accepted writes and queued input; unless consumed steering or follow-up input starts new work, it closes the run failed (section 7). The same rule applies to an unmarked `aborted` response at its captured cap: a run whose newest own message is either terminal form can never be completed by recovery. - -Two settlement prefixes require explicit recovery: - -```text -R step_attempt N response RN, usage UN -X provider effect unknown RN absent -``` - -If `N` is below the persisted cap, recovery commits attempt `N+1` before repeating the provider effect; it never reuses `RN`. At the cap it appends a synthetic interruption error under `RN`, then appends `UN` from that response's zero usage. No id is invented after the attempt. - -```text -R step_attempt N -E assistant message RN -X usage UN missing -R usage UN recovery reconstructs it from RN before classification -``` - -An existing response without its preplanned usage record is a valid crash prefix, not usage loss. An existing usage record without its response is corruption because live settlement cannot produce that order. - -A transport timeout, harness-close signal, provider-side cancellation, or similar interruption can settle as `aborted` without `abort_requested`. That response follows the durable retry boundary rather than the abort path: - -```text -R step_started assistant S; captured policy -R step_attempt S attempt 1; response R1, usage U1 -E assistant message R1 stop reason aborted; no abort marker -R usage U1 - retry delay -R step_attempt S attempt 2; fresh response and usage ids -``` - -At the captured cap, that attempt's `aborted` response is the durable terminal interruption response and the run finishes failed after its normal drain. It remains omitted from provider context. It never produces `operation_finished: aborted` or `run_abort`. - -### Post-persistence assistant-response classification - -Classification begins only after both durable settlement objects exist: the complete `MessageEntry`, then its preplanned `usage` record. It is a pure, process-local decision, not another record. Its inputs are the immutable response, the assistant `step_started` and `step_attempt`, the current abort marker, and linked later records. It returns retry, overflow recovery, suspension, failure, abort, or acceptance. It never changes or deletes the response and needs no general attempt-outcome metadata. - -A linked later record means that an ordinary transition already won: a newer attempt of the same step represents retry, an overflow compaction must name this exact response and trigger, a durable tool-batch plan represents accepted tool calls, and `operation_finished` is terminal. Absent an abort marker, resume continues that represented transition instead of classifying into a second one. A deferred response is itself the durable suspension fact, so reclassification simply parks again. - -Every prefix around settlement and transition has one interpretation: - -| durable prefix | recovery before ordinary orchestration | -|---|---| -| `step_started`, no attempt | append attempt 1 before the provider effect | -| `step_attempt`, response absent, no abort | effect unknown; start a fresh numbered attempt below the cap, or append the synthetic interruption response under the provisioned id at the cap | -| `step_attempt`, response absent, abort present | append synthetic `aborted` under the provisioned response id and its preplanned usage; never retry | -| response present, usage absent | reconstruct the exact preplanned usage record from the response; then abort wins if its marker exists | -| usage present, response absent | corruption; live settlement cannot write in this order | -| response and usage present, no linked transition | run the pure classifier on that same response; an abort marker selects reconciliation | -| response and usage present, linked transition present | absent abort, resume the represented retry, overflow compaction, tool batch, suspension, or finish; with abort, preserve the response and reconcile instead | - -An abort marker takes priority over ordinary transitions, regardless of a response's preserved stop reason. Otherwise classification checks overflow before interruption or retryable error, so an oversized request compacts rather than retries unchanged. `deferred` suspends. Unmarked `aborted` retries below the captured cap and fails at it. Retryable `error` retries below the cap; other errors fail. `stop`, `toolUse`, and genuine output-limit `length` are accepted. Live and reopened execution use this order. - -### Context overflow at an assistant step - -Overflow classification has three explicit inputs, all durable on the response and attempt: - -1. **Explicit provider context-limit error.** The response has `stopReason: "error"`, and its durable `errorMessage` matches pi-ai's context-limit patterns after exclusions such as throttling and rate limits. Examples: "prompt is too long", "exceeds the context window", and DashScope/Qwen's "Range of input length should be". Reopen needs no transient exception or HTTP object. -2. **Reported input exceeds the captured window.** The response has `stopReason: "stop"`, `attempt.contextWindow > 0`, and `message.usage.input + message.usage.cacheRead > attempt.contextWindow`. This preserves the existing successful-response check without introducing a separate named condition or durable outcome. -3. **A recoverable `length`.** The response either matches the existing Xiaomi MiMo-compatible context-pressure signal — zero output and reported input plus cache-read tokens at least 99% of the captured non-zero window — or ended below the request's persisted intended output limit: - -```ts -function isRecoverableLength(message: AssistantMessage, intendedOutputLimit: number): boolean { - return message.stopReason === "length" - && intendedOutputLimit > 0 - && message.usage.output < intendedOutputLimit; -} -``` - -`usage.output` includes reported reasoning tokens. `intendedOutputLimit` is the caller's `maxTokens`, or `model.maxTokens`, captured before context clamping. The sent value cannot be the reference: some providers reject explicit caps (OpenAI Codex returns HTTP 400 for `max_output_tokens`), while pi clamps others to remaining context. Thus 16 reasoning tokens against a 128k intent and zero-output Xiaomi/Qwen pressure are recoverable, but a fully used explicit 1,024 cap is genuine. Xiaomi compatibility is the only percentage check; there is no general percentage heuristic. - -A recoverable response remains a complete transcript entry. After usage commits, overflow classification starts no tool plan. The overflow compaction names that response and omits it from summary preparation and retained-tail construction. **Compaction preparation** is the summary input and proposed tail passed to `before_compaction` and, when needed, structural generation; `CompactionEntry.retainedTail` uses the same filtered preparation. The response remains queryable; omission affects context, not history. - -```text -R step_started assistant S1; trigger U; captured policy/config -R step_attempt S1 attempt 1; response R1, usage U1; request limits -E assistant message R1 recoverable length, context-limit error, or reported input > window -R usage U1 before overflow classification -H before_compaction reason overflow; preparation omits R1 -R step_started compaction C1; reason overflow; supersedes R1; trigger U -R step_attempt C1 attempt 1 -E compaction entry C1's stable result id; retained tail omits R1 -R step_started assistant S2; same trigger U, new stable step -R step_attempt S2 attempt 1; fresh response and usage ids -E assistant message -R usage -``` - -**One recovery per conversational input.** An overflow compaction requires that no earlier one in the run has the same `triggerMessageId`. A second recoverable response for that trigger remains durable and accounted, starts no tool batch, and enters failure drain; `length` does not reset the guard. Consuming newer prompt, steer, follow-up, or other user-context input gives the next assistant step a new trigger and one new allowance. Hook decline or empty preparation is terminal because the request cannot fit without compaction. - -Per crash site: - -| crash after | durable state | recovery | -|---|---|---| -| assistant `step_started` | no attempt | commit attempt 1 before the provider effect | -| assistant `step_attempt` | response absent; provider effect unknown | start the next numbered attempt below the cap; at the cap append the synthetic interruption response under the attempt's id | -| assistant response entry | preplanned usage may be absent | append missing usage, then classify the durable response | -| assistant usage | response settled and accounted; no linked compaction yet | classify that response; if recoverable, run the omission-aware compaction decision before any new request or tool plan | -| overflow compaction `step_started` | exact response and trigger linked; stable typed result absent | continue that same compaction with preparation and retained tail omitting the linked response | -| compaction `step_attempt` | structural effect unknown | start the next numbered attempt below the cap; otherwise append `step_failed` | -| compaction entry | structural step closed by its typed result | checkpoint path; a fresh assistant step with the same trigger follows | - -A genuine output-limit `length` response is accepted and remains in transcript **and provider context**. Without tool calls it reaches the normal checkpoint. With calls, the harness plans the batch, executes none, and appends one planned `isError: true` result per call in source order, explaining that truncation may have left arguments incomplete. The errors do not terminate, so another assistant turn sees the response and results. A crash before planning reclassifies and plans; a crash after planning fills missing results without execution. - -### Steering while a tool runs - -```text -E assistant message [tool call] -R usage -R tool_batch_started all result ids planned -R tool_started immediately before this call's effect - steer("focus on the tests") caller resolves here -R queue_enqueued steer, full payload, provisioned id -E tool result -E user message checkpoint consumes the queue item; provisioned id -R step_started next assistant step; trigger = steering message id -R step_attempt attempt 1; response and usage ids -``` - -Crash before `queue_enqueued`: the steer never happened; the caller's promise never resolved. Crash after: recovery finds the record without its entry and appends it at the same point the checkpoint would have. - -A queued item can be durably retracted before consumption: - -```text -R queue_enqueued steer, full payload, provisioned id - cancelQueued(entryId) caller resolves here -R queue_cancelled the entry will never be appended -``` - -Crash between the two records: the item is still pending; the cancel promise never resolved. Cancellation and consumption are jobs on the lane mutation line, so `[cancel, consume]` and `[consume, cancel]` are the only histories (section 15). - -### Configuration update during a generation step - -A setter changes one public property but writes the resulting total value. It never changes a generation step that already started: - -```text -R step_started S1 captures C1 -R lane_config setter commits total C2 and resolves - retry of S1 still uses C1 -R step_started S2 captures C2 -``` - -The setter and generation-step start are jobs on the lane mutation line, so the snapshot is either wholly before or wholly after the total replacement. The record survives abort. Tool activation follows the same rule: a tool batch from S1 uses S1's captured active names, resolved against the current harness-global tool implementations. - -### Input at the finish boundary - -Same-lane decisions have one order: the lane mutation line (section 15). The final pending-work check and the terminal append are one `tryFinishRun` mutation, so a concurrent steer has exactly two histories: - -```text -steer first finish first -R queue_enqueued R operation_finished - tryFinishRun → continue steer() → NoActiveRun -E user message -... run continues -R operation_finished -``` - -Deferred writes and abort use the same ordering. A write accepted before finish applies before close; after finish it appends on the idle lane. `abort_requested` before finish selects reconciliation; abort after finish returns `NoActiveOperation`. No third history exists. - -### Deferred write mid-turn - -```text -R step_started assistant; trigger U -R step_attempt response A and usage id; request in flight - session.appendMessage(M) caller resolves here -R write_deferred full payload, provisioned id -E assistant message A provider cached [.., U, A] -R usage before classification -E message M checkpoint applies the write; tail append -``` - -Appending M directly would produce [.., U, M, A]: a valid provider sequence that invalidates the KV cache from M on, and a transcript claiming A saw M when it did not. The checkpoint prevents both (append-only context, section 4). - -### Abort - -The abort marker and an active assistant response append race on the lane mutation line. The marker wins in this trace: - -```text -R step_started assistant S -R step_attempt S attempt 1; response R1, usage U1 - provider stream active - abort() first call resolves after the next record -R abort_requested queues drain; stream is signalled -E assistant message R1 planned response id; stop reason normalized to aborted -R usage U1 measured usage from the settled stream -E pending deferred writes accepted run-owned writes still apply -R operation_finished aborted; no separate tree closure -``` - -Response settlement is one mutation-line job. If `abort_requested` committed first, it clears deferred-only handle data, normalizes the settled stop reason to `aborted`, and appends under the attempt's `responseEntryId`. If that response is absent after a crash, recovery appends synthetic zero-usage `aborted` under the same id, then the preplanned usage. It never retries or allocates another assistant id. If the response committed first, abort preserves its stop reason; recovery repairs usage, while the marker prevents retry, overflow, tool planning, and other ordinary transitions. Stop reason alone is not abort authority: unmarked `aborted` follows interruption retry/failure and never aborts the operation. - -Between assistant steps, abort has no response id and appends no assistant message. During retry delay it cancels sleep and starts no later attempt or events for that attempt. Repeated `abort()` during reconciliation writes, signals, and emits nothing again, returning copies of the same drained steer/follow-up payloads. If finish won first, it returns `NoActiveOperation`. - -During a planned tool batch, started effects are signalled and may settle; their finalized real/error results keep planned ids, and usage remains bound to those ids. Unstarted calls get planned synthetic `aborted` results. After a crash, a started call without a result gets planned synthetic `interrupted`; abort-only recovery never replays it: - -```text -E assistant message [tool calls c1, c2] -R usage -R tool_batch_started planned result ids for c1 and c2 -R tool_started(c1) immediately before c1's effect - abort() -R abort_requested steer/follow-up queues die; payloads returned - signal c1 -E tool result c1 real/error result from the started effect -E tool result c2 planned synthetic aborted; c2 never started -E pending deferred writes -R operation_finished aborted; no assistant request is started -``` - -After a crash following `abort_requested`, recovery completes planned results and deferred writes, then appends the terminal record; steer/follow-up items are not applied. A response without a pre-abort batch plan has no promised tool results. - -On a suspended deferred response, abort best-effort cancels the newest persisted handle, retains every deferred response entry, applies pending writes, and finishes aborted without another assistant message. A live deferred-fetch attempt follows the same existing-response-id settlement rule as an active assistant attempt. Cancellation failure is telemetry only and cannot block reconciliation; a crash may repeat the best-effort cancellation, but repeated `abort()` in one process does not. - -Compaction and navigation never write an assistant response for abort. Their commit points decide the race: the compaction result entry and the navigation lane move, respectively. A marker committed first signals any structural provider effect, discards an in-memory generated result, and finishes aborted without `step_failed`; persisted hook-result usage is still repaired from its `step_started`. If the structural commit happened first, the procedure completes that already-committed compaction or navigation, including the exact prepared summary and label writes, and finishes completed. All provider usage reported before cancellation remains in the ledger. Navigation never needs provider or hook work after its move because its complete summary payload is durable first. - -### Tool-batch ordering and crash sites - -The assistant response and its usage are durable before this trace begins. Planning is one record for the complete batch, before lookup, argument preparation, validation, or `before_tool` for any call: - -```text -X1 accepted response; no batch plan -R tool_batch_started c1 and c2, one result id per source index -X2 before clearance of c1 plan exists; no result or start for c1 -H before_tool(c1) -X3 clearance decided, nothing else same durable state as X2 -R tool_started(c1) effective args and replay declaration -X4 immediately before/during effect effect outcome unknown after a crash - execute tool c1 crosses fx.executeTool by itself -H after_tool(c1) -X5 finalized result only in memory same durable state as X4 -R usage for c1 only when finalized result reports usage -X6 usage durable, result absent -E tool result c1 c1's planned id -X7 result durable c1 complete -``` - -| crash site | durable state | recovery | -|---|---|---| -| X1 | accepted, accounted assistant response; no plan | append one complete `tool_batch_started` with fresh ids, then continue; no clearance or effect can have occurred | -| X2, X3 | plan exists; this call has no `tool_started` and no result | ordinary call: rerun lookup, validation, and `before_tool`; blocked or invalid outcome uses the planned id. Genuine `length` skips clearance and writes its planned explanatory result. Aborting reconciliation writes the planned `aborted` result for a call that never started | -| X4, X5 | `tool_started`, no result | the effect is unknown. Without an abort marker, re-execute persisted args only when the record and current declaration both say `safe`, then run `after_tool`; otherwise append a planned synthetic `interrupted` result with no hooks. With an abort marker, never replay; append the interrupted result | -| X6 | `tool_started` and one or more tool-usage records, no result | keep all billed usage in the ledger, then take the same replay-or-interrupt action as X4. Without abort, a replay is another effect and may add another usage record; its real result snapshots only that replay's usage. An interrupted synthetic has no usage snapshot | -| X7 | planned result entry exists | skip the call; if its message reports usage, the matching usage record must already exist | - -Blocked and invalid calls go directly from X2/X3 to their planned error result and never write `tool_started`. Genuine output-limit `length` does the same for every source call without running clearance. During live abort, a started effect that settles keeps its real/error result; after a crash, any unresolved started call gets an `interrupted` result without replay. A planned call that never started gets an `aborted` result. Every synthetic result has `isError: true` and `terminate: false`. - -Both modes prepare sequentially in source order. Sequential mode then starts, executes, finalizes, optionally writes usage, and appends each result before the next preparation. Parallel mode prepares, starts, and dispatches each real `fx.executeTool` in source order without awaiting earlier effects; effects may settle concurrently, but finalization, usage, and result appends await them in source order. Durable results therefore form a source-order prefix. Starts may lead that prefix and skip blocked or invalid positions. Recovery reduces calls independently and settles missing results in source order. - -### Auto-compaction at a checkpoint - -```text -E tool result step ends - checkpoint: next request would not fit -H before_compaction may decline or supply the summary -R step_started generated source; stable typed result id -R step_attempt attempt 1 — generated source only -R usage (one or two) successful request usage, before result -E compaction entry the generated commit point -R step_started assistant; config, policy, trigger snapshot -R step_attempt attempt 1; run continues on compacted context -``` - -When the hook supplies the compaction, `step_started` instead stores the complete provisioned `CompactionEntry` with `fromHook: true` and no generation configuration or attempts. Its preplanned hook usage record, when present, is written next, then the exact stored entry commits. A crash after that start never reruns `before_compaction`. Generated compaction has no prepared-result record: a crash after provider settlement but before the result entry treats the attempt as unknown and advances under its captured policy. - -Auto-compaction writes no `operation_started`; it belongs to the run. Manual `compact()` is its own operation: `operation_started` (kind compaction, provisioned result id) → hook → hook- or generated-source `step_started` → optional numbered attempts → compaction entry → `operation_finished`. - -Structural terminal failure has no assistant message to carry it and applies only to generated sources: - -```text -R step_started generated compaction or branch_summary; stable typed result id -R step_attempt final allowed attempt -R usage (zero or more) any reported request cost -R step_failed terminal error; typed result id remains absent -R operation_finished failed (standalone structural operation) -``` - -For auto-compaction, `step_failed` instead enters the enclosing run's failure-drain path. A crash after `step_failed` never starts another structural provider request. A retryable generated failure below the captured cap uses the existing `retry_scheduled` → `retry_start` → `retry_end` lifecycle around the later numbered attempt; terminal failure closes with `step_failed`. Hook sources have no retry lifecycle. Structural provider streams are internal: none of these requests emits public `message_start`, `message_update`, or `message_end` events. - -### Navigation - -```text - navigateTree(target, { summarize: true, label: "before-refactor", - customInstructions: "focus on API changes" }) -R operation_started kind navigation; target, summary id, label, instructions -H before_navigation receives preparation and custom instructions -R step_started generated source; stable typed result id -R step_attempt attempt 1 — generated source only -R usage (zero or more) reported request cost -R branch_summary_prepared complete generated BranchSummaryEntry payload -L lane move → target one storage write; the commit point -E branch summary entry exact prepared payload; appends from target -A [G label, R operation_finished] one append; consecutive seq, completed -``` - -For a hook summary, `step_started` stores the complete provisioned `BranchSummaryEntry`, `fromHook: true`, and optional usage id; no attempt or prepared record exists. Generated payloads use `fromHook: false`. Either source is durable before the move. The move is the first tree/fact effect; the later entry chains from its target. These writes are not atomic together. - -Acceptance returns `InvalidNavigation` before writing `operation_started` when `target === sourceLeafId` or when `target === null` and a label is present. Root is not an entry and has no label fact. With `summarize: true`, the durable states and actions are exhaustive: - -| current leaf and durable result state | action | -|---|---| -| source leaf, no structural step | run `before_navigation` again; decline, persist its complete result in `step_started`, or select generated work with `step_started` | -| source leaf, hook-source `step_started` | repair its preplanned usage when needed, then move using its stored payload | -| source leaf, generated step without `branch_summary_prepared` or `step_failed` | start or retry numbered attempts; on success write usage then the complete prepared record | -| source leaf, generated `step_failed` | finish failed; never move | -| source leaf, durable hook or generated payload | move to the target unless abort already won | -| target leaf, durable payload, summary entry absent | append that exact payload; never call the hook or provider | -| summary-entry leaf, exact summary entry present | atomically append the accepted label and completed finish when labeled; otherwise append only the finish | - -A target leaf without a durable payload, a summary entry whose id is not the leaf, or any other leaf is corruption. With `summarize: false`, only source-before-move and target-after-move are valid; no structural step or summary entry exists. A pre-move abort finishes aborted and leaves any prepared payload only in records. Once the move commits, abort cannot undo it: recovery appends the durable summary when required, writes the label, and finishes completed without model or hook lookup. - -The accepted label and `operation_finished` are consecutive mutations in one atomic append, with no interleaving or internal crash prefix. The label wins over earlier writes; writes after the terminal record remain newer. No fact or operation-specific fact id is needed. - -Between move and finish, readers see the target or summary leaf with an open, recoverable navigation. The lane runs nothing else. - -### Deferred provider request - -```text -R step_started assistant generation G -R step_attempt G attempt 1; response D1, usage U1 -E assistant message D1 stop reason deferred, complete handle H -R usage U1 - lane suspends; prompt() resolves with outcome "suspended" - ... hours pass, maybe a different process ... - resume() #1 D1 is the newest unredeemed source -R step_started stable deferred-fetch step F; copies G config and policy once -R step_attempt F poll 1; source D1, response D2, usage U2 - fetchDeferred(model, H, wait: 0) provider/model and complete handle from D1 -E assistant message D2 still deferred; distinct entry, complete handle H unchanged -R usage U2 - lane suspends - resume() #2 D2, not D1, is now the newest source -R step_attempt F poll 2; source D2, response D3, usage U3 - fetchDeferred(model, H, wait: 0) -E assistant message D3 still deferred; another distinct entry with handle H -R usage U3 - lane suspends - resume() #3 the same stable step continues from D3 -R step_attempt F poll 3; source D3, response D4, usage U4 - fetchDeferred(model, H, wait: 0) -E assistant message D4 ready, interrupted, or terminal; always durable -R usage U4 - ready continues; interrupted suspends; terminal fails -``` - -Storage represents deliberate suspension and a crash alike: an open operation with an unredeemed source. Restore lists either as suspended. The first `resume()` creates stable deferred-fetch step F and copies configuration and normalized retry policy from generation step G. Later polls use F without rereading G. Each source handle supplies provider/model; F's copied active names select tools for a ready response despite later lane changes. - -Before fetching, each consecutively numbered F attempt records its exact source and fresh response/usage ids. A pending response advances lineage to its distinct entry even with an unchanged handle; an unmarked interruption retains its source. A committed response prevents refetch by that attempt. - -Each `resume()` performs at most one `fetchDeferred(..., { wait: 0 })` check. The caller schedules another, optionally using `pollAfterMs`. Polling emits no `retry_scheduled`, `retry_start`, or `retry_end`. Four outcomes: - -- **pending** — append and account the new `deferred` response, require complete handle equality, and re-suspend on its entry. Repeated pending answers produce durable D2, D3, and so on; each poll names the newest. -- **interrupted** — an unmarked `aborted` response and usage are durable but omitted from context. Count attempts naming its `sourceEntryId`. Below the copied cap, retain that source and let the next `resume()` wait captured backoff before one later attempt; at the cap, fail. A pending response creates a new source and resets this per-source count. -- **ready** — append and account the normal assistant response, then continue. Tool calls use F's copied active names; fetch identity came from the exact source handle. -- **terminal** — append and account a returned error (expired, unknown, consumed) or rejection converted to the same durable message, then fail. Never start replacement generation; already-accepted steering or follow-up can still start a later turn. - -On a suspended lane, `abort()` writes its marker, best-effort cancels the newest handle, applies deferred writes, then finishes aborted. Deferred entries remain and no assistant closure is added. Missing provider/model implementations do not block this path; cancellation runs only when resolvable. - -Deferred assistant messages carry a handle, not content; they project to nothing in provider context. - -## 7. Recovery - -### Restore - -Opening restores each lane independently. After section 8's one-time initialization of an unconfigured `main`, restore only reads; it starts no writes, providers, tools, hooks, or timers. - -Recovery uses indexed, bounded reads. For each lane: - -1. Read the newest total `lane_config` through its latest-value index. An already-configured format-4 lane without one is corruption; configuration never comes from tree entries or harness-option fallback. -2. Call `findOpenOperations(lane, { limit: 2 })`. Zero results means idle, one means suspended, and two means corruption. Backends answer from replayed or indexed open-operation state rather than by scanning starts and finishes. -3. Independently find the newest run-kind `operation_started`. From its `seq` exclusively, or from the start of the lane when no run has started, read only `queue_enqueued` and `queue_cancelled` records needed to reduce `nextRun`. This query runs for every lane, including when a newer compaction or navigation is open. Structural operations never consume or hide next-run input. -4. If an operation is open, read its records by exact `runId`, oldest first. The slice begins with the discovered `operation_started` and contains only that operation's records; completed operation history is not read. -5. Build the complete **entry plan**, then call `getEntries(ids)` once. Plan every id whose presence affects reduction: run initial messages; structural results from operation intent, `step_started`, and `branch_summary_prepared`; assistant/fetch responses from `step_attempt`; tool results from `tool_batch_started`; operation queue and deferred-write targets; and next-run targets. Hook and prepared branch-summary payloads are inline records, not extra reads. Source handles, overflow links, and cancellations must reference already-planned ids. One immutable map returns existing entries; absence may be a valid crash prefix. -6. Pass the lane pointer, indexed configuration, bounded records, plan, and returned entries to the pure reducer. - -The planner checks only what a safe exact-id query needs: matching discriminants, existing referenced plans, and role-specific id uniqueness. The reducer applies section 5 relationships to that bounded prefix. Restore never walks leaf-to-`sourceLeafId`, reads the operation branch or completed operations, scans unrelated adjustments, or reads another lane. Ordinary execution may query a branch later for context or structural preparation. - -Next-run reduction is separate from the open-operation slice. An item is pending when enqueued after the newest run start, absent from the tree, and not cancelled. Earlier items belong to that run's captured `initialMessages`, even if a later structural operation is open: - -```text -R operation_started run A captures all earlier nextRun items -R operation_finished run A completes -R queue_enqueued nextRun N, target entry absent -R operation_started manual compaction B remains open at crash -X crash - restore B is suspended; N is still pending for the next run -``` - -### Entry-plan reduction - -Entry planning and lane reduction are pure: no storage access, effects, id allocation, or runtime identity lookup. Equal inputs produce equal outputs. Planned entries are indexed by id and use storage `seq` only where chronology matters; tree paths never imply operation ownership. - -The reducer derives: - -- **configuration and next-run state** — the newest indexed total replacement becomes the lane configuration. The independent next-run slice becomes `pendingNextRun`, whether the lane is idle or has any operation kind open. -- **abort request** — the sole `abort_requested` record, when present, plus the steer/follow-up items it killed. The killed payloads are derived from queue records before the marker and are stable across repeated `abort()` calls and restore. -- **steps and attempts** — each `step_started.id` defines one stable `stepId`; attempts group by that id and are consecutive. The start supplies captured configuration, policy, trigger, reason, and structural result id as applicable. A generated structural source has numbered attempts; a hook source instead contains the complete result and optional usage-record id and has none. A compaction step closes when its result entry or `step_failed` exists. A generated branch-summary step stops provider work when `branch_summary_prepared` exists and closes when its exact result entry appears; a hook branch-summary already has its payload on the start. -- **assistant/fetch settlement** — each attempt identifies its exact planned response and usage ids. The valid newest-attempt prefixes are attempt absent, attempt with response absent, response present with usage absent, and response plus usage. Usage without response is corruption. A durable response remains represented when a later attempt or transition exists. -- **post-response transition** — no general outcome record exists. A newer attempt of the same step, an overflow compaction naming the exact response and matching trigger, durable tool-plan/start/result state, deferred suspension implied by a pending response or below-cap unmarked fetch interruption, or `operation_finished` shows that the corresponding branch advanced. An unrelated later step cannot supersede a response. Response plus usage without a linked transition must be classified again. -- **overflow recovery used** — a compaction `step_started` with reason `overflow` carries the current assistant trigger and names the exact response omitted by that recovery. A compaction for an older trigger does not consume the current trigger's allowance. -- **tool batch** — `tool_batch_started` fixes one result id for every source call. The planned assistant response supplies each source-indexed call. An existing planned result is complete; a missing result with `tool_started` is an unknown real effect; a missing result without `tool_started` has not durably selected a real effect. Existing tool-usage records remain billed but do not complete a call. Stored result `terminate` values determine continuation. -- **deferred handle** — the newest unredeemed deferred response is the source. Before a fetch step exists, the original response's assistant step supplies the values that will be copied; after it exists, only the fetch step's copies are used. An equal-handle pending response advances the source to its own entry. A below-cap unmarked `aborted` fetch response retains its exact source, and attempts naming that source determine its cap. -- **pending operation input** — absent planned initial-message, queue-target, and deferred-write entries become `missingInitialMessages`, pending steer/follow-up, and pending writes after cancellation and abort rules are applied. -- **structural state** — source kind, complete hook result, generated `branch_summary_prepared`, planned result presence, `step_failed`, and lane-pointer equality determine compaction completion and every pre-move/post-move navigation prefix. For summarized navigation, source leaf means not moved, target leaf means moved but summary not appended, and summary-entry leaf means moved and appended. The reducer rejects a move without a durable payload. No branch content is required and no post-move generation state exists. -- **newest own entry and terminal failure** — the highest-`seq` existing entry in the open operation's plan is its newest own entry. Only a step-produced assistant error or an unmarked `aborted` response at its applicable captured cap becomes terminal-failure provenance; an arbitrary deferred-write message cannot. - -Restore enforces section 5 validity only for the open operation, relevant next-run slice, and entries needed to interpret them. It does not re-audit completed records, unrelated entries, historical facts, or other lanes. Append validation and conformance tests enforce the full write contract; restore asks only whether the current prefix is safe and unambiguous. - -Live commits apply the same transitions in memory. Fresh reduction must match live state in tests; production does not reread storage after settlement, suspension, or finish. - -### Ordinary procedure re-entry - -`resume()` persists no program counter and dispatches no recovery-only continuation. It invokes the live run, compaction, or navigation procedure, which reads reduced state and reaches its first unfinished transition: - -| reduced prefix | ordinary re-entry | -|---|---| -| missing accepted initial messages | append the missing planned entries before other operation work, including while aborting | -| assistant `step_started`, no attempt | `assistantStep` commits attempt 1, then makes the provider request | -| assistant attempt, response absent | `assistantStep` treats the effect as unknown: start the next numbered attempt below the captured cap, or settle the missing planned response as synthetic interruption at the cap | -| assistant/fetch response present, usage absent | `persistMissingResponseUsage` reconstructs the preplanned usage record before classification or abort completion | -| accounted assistant response, no linked transition | `assistantStep` runs the ordinary pure classifier; retry, linked overflow, suspension, terminal failure, tool planning, or checkpoint follows normally | -| accepted tool-call response, no batch plan | `runToolBatch` commits the complete plan before clearance | -| tool plan with missing results | `reconcileToolBatch` proceeds in source order: rerun clearance for no-start calls, replay only safe started calls, or append the required interrupted, genuine-length, or abort synthetic under the planned id | -| original or pending deferred source, no fetch attempt awaiting settlement | `redeemDeferred` creates the fetch step once if needed, commits one attempt for the exact source, and performs at most one check-once fetch for this `resume()` | -| fetch attempt, response absent | `redeemDeferred` treats the poll as unknown: use a later numbered attempt below the copied per-source cap, or settle the missing id as terminal interruption at the cap | -| accounted fetch response | ordinary deferred classification advances an equal-handle pending source, retains an interrupted source below cap, accepts a ready response, or enters terminal-failure drain | -| compaction operation or auto-compaction before a durable step | the ordinary compaction procedure runs its decision hook and persists either the complete hook result or a generated source before continuing | -| hook structural source, result absent | repair its preplanned hook usage when present, then commit the stored result if abort has not won | -| generated compaction with no attempt, unknown final attempt, result, or `step_failed` | `summaryStep` respectively starts attempt 1, starts a later numbered attempt below cap, commits its typed result directly, or enters the ordinary structural failure path; no prepared-result record exists | -| generated branch summary without prepared result or `step_failed` | `summaryStep` starts or retries attempts; success writes reported usage and `branch_summary_prepared` before navigation can move | -| navigation at source with a durable hook/prepared payload | `navigationProcedure` conditionally commits the move without another hook or provider effect | -| navigation at target with summary absent | append the exact durable payload; never regenerate it | -| navigation at its summary-entry leaf, or unsummarized navigation at target | atomically append the label and finish when labeled; otherwise append the finish | -| pending writes or conversational queues | the ordinary checkpoint applies writes, consumes eligible input, and re-evaluates assistant need | -| terminal assistant/fetch failure | the ordinary failure-drain checkpoint applies writes and consumes eligible input; absent new work it finishes failed | -| no unfinished transition | the ordinary checkpoint or structural finish boundary conditionally appends `operation_finished` | - -An abort marker takes priority after missing initial messages and response accounting are repaired. If a compaction result or navigation move already committed, the ordinary structural procedure completes that committed structure. Otherwise `abortPath` settles a missing active assistant/fetch response under its planned id, completes planned tool results without replay, best-effort cancels a deferred handle, applies pending writes, and finishes aborted. No unrelated assistant entry is appended. - -Recovery uses entry presence from the one batched lookup. Each write updates memory, so re-entry skips newly existing entries after verifying their content. A crash leaves a shorter prefix for the same procedure; repeating recovery is safe. Restore itself writes nothing. - -Check runtime identities immediately before the effect that needs them: the captured/source model before request or fetch, and the tool before invocation or safe replay. Synthetic settlement, usage repair, persisted structural commits, queue/write application, finish, and non-replay reconciliation need none. Abort-only reconciliation bypasses model/tool checks; deferred cancellation runs only when resolvable. Navigation performs provider/hook work before moving, so post-move completion needs no model. `SuspendedOperation.missing` forecasts the next effect, not every configured name. - -Interrupted hook handlers follow the section 11 replay table. Old v3 sessions contain no durable operation records, so restore reports normalized `main` idle at its final retained logical entry; legacy configuration entries never initialize the v4 lane configuration. - -# Part III — API and implementation - -## 8. Public API - -### The lane surface - -`AgentLane` is one lane's operation surface; `AgentHarness` implements it for `main`. Methods, including getters, are async so remote proxies can implement them. Only `name` and listener registration (`hooks.on`, `events.on`) are synchronous; servers bridge event delivery, not registration. - -```ts -interface AgentLane { - readonly name: string; // "main" on the harness itself - getLeafId(): Promise; - - // Operations. Never throw; every call resolves with a result (see below). - // At most one operation per lane; other lanes are unaffected. - prompt(text: string, images?: ImageContent[]): Promise; - prompt(message: AgentMessage | AgentMessage[]): Promise; - skill(name: string, additionalInstructions?: string): Promise; - promptFromTemplate(name: string, args?: string[]): Promise; - compact(options?: { customInstructions?: string }): Promise; - navigateTree(targetId: string | null, options?: NavigateOptions): Promise; - resume(): Promise; // continue this lane's open operation - abort(): Promise; // first call is durable on resolve; reconciliation runs in background - // repeated calls while aborting return the same drained input - - // Queues. Durable on resolve (queue_enqueued record); the returned - // entryId identifies the item until consumption. steer/followUp require - // an active run. nextRun works while idle or during any operation and - // only queues input; it never starts a run. cancelQueued works anytime. - steer(text: string, images?: ImageContent[]): Promise; - steer(message: AgentMessage): Promise; - followUp(text: string, images?: ImageContent[]): Promise; - followUp(message: AgentMessage): Promise; - nextRun(text: string, images?: ImageContent[]): Promise; - nextRun(message: AgentMessage): Promise; - /** Durably retract a pending queue item (queue_cancelled record). */ - cancelQueued(entryId: string): Promise; - /** Append an adjustment usage record (section 5): reconciliation, - estimates, corrections. Allowed anytime; records are not context. */ - recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }): - Promise; - - waitForIdle(): Promise; - runWhenIdle(callback: () => void | Promise): Promise; // runtime-only - - // Manual drive controls. Section 15 defines their exact behavior; they - // are usable only with AgentHarnessOptions.drive === "manual". - peekAction(): Promise; - executeAction(): Promise; - runToCompletion(): Promise; - - // Persisted total configuration. Getters read the newest lane_config; - // getModel resolves its durable reference through Models. Each setter - // commits an immediate total replacement on this lane's mutation line, - // including while an operation is open. - getModel(): Promise; setModel(model: Model): Promise; - getThinkingLevel(): Promise; setThinkingLevel(level: ThinkingLevel): Promise; - getActiveTools(): Promise; setActiveTools(names: string[]): Promise; - - /** This lane's view of the tree: reads default to this lane's leaf; - appends defer while a run is open and otherwise chain to the leaf - (section 12). */ - session: SessionTree; - - /** Scoped: this lane's transcript, state, queues, and events (section 9). */ - watch(): Promise<{ snapshot: LaneSnapshot; start: (listener) => void; unsubscribe: () => void }>; -} -``` - -Prompt overloads normalize to ordered `AgentMessage[]`; text plus images becomes one user message. Skill/template expansion precedes storage. `OperationStartedRecord.intent.originalPrompt` contains this array, excluding captured `nextRun` items and hook injections. - -### The harness - -```ts -class AgentHarness implements AgentLane { - /** Initializes an unconfigured main when needed, then restores every - lane without starting provider, tool, hook, or timer effects. One - suspended entry per lane with an open operation. */ - static create(options: AgentHarnessOptions): Promise<{ - harness: AgentHarness; - suspended: SuspendedOperation[]; - }>; - - // Lane management. Names are permanent application keys - // ("slack:1719432.0021"). Handles are stateless facades bound to the - // name: any number may exist, all equivalent; identity is the name, - // never the object. Lanes are not deleted or renamed. - lane(name: string): Promise; // lookup, never creates - createLane(name: string, at: string | null): Promise; - /** Inventory. Always includes "main". */ - lanes(): Promise; - - // Harness-global configuration: registries and runtime capabilities. - // Tool implementations are code and cannot persist; active names live - // only in each lane's total configuration. setTools replaces only the - // global registry; use a lane's setActiveTools to change activation. - getTools(): Promise; setTools(tools: AgentTool[]): Promise; - getResources(): Promise; setResources(r: Resources): Promise; - getStreamOptions(): Promise; setStreamOptions(o: StreamOptions): Promise; - getRetryPolicy(): Promise; setRetryPolicy(p: RetryPolicy): Promise; - getCompactionSettings(): Promise; setCompactionSettings(s): Promise; - getSteeringMode(): Promise; setSteeringMode(m: QueueMode): Promise; - getFollowUpMode(): Promise; setFollowUpMode(m: QueueMode): Promise; - - /** Session-wide observer: lane inventory snapshot plus the unfiltered - event stream. No transcripts; compose with lane.watch(). */ - watchSession(): Promise<{ snapshot: SessionSnapshot; start; unsubscribe }>; - - // Registries are harness-global. Every hook payload carries `lane`; - // events are either lane-scoped or harness-global as section 10 defines. - hooks: Hooks; - events: Events; - - /** Detach cleanly. Stops admission, signals in-flight effects, rejects - parked manual actions, drains appends already accepted by Session, then - closes Session and releases its writer claim. Open operations stay - resumable; no shutdown record is needed. */ - close(): Promise; -} - -interface LaneInfo { - name: string; - leafId: string | null; - operation: null | { id: string; kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "aborting" }; -} - -``` - -### Options - -```ts -interface AgentHarnessOptions { - // Identity and providers - session: Session; - models: Models; // provider collection for all requests - - // Immutable lane seed captured at create(). It initializes main when the - // session is first attached and every lane later created by this harness; - // it is never a fallback for a configured lane. - model: Model; - thinkingLevel?: ThinkingLevel; // seed default: "off" - activeToolNames?: string[]; // seed default: initial tool names - - // Runtime capabilities — harness-global, reconstructed at create() - tools?: AgentTool[]; - toolContext?: TContext | (() => TContext | Promise); - systemPrompt?: string | ((ctx) => string | Promise); // evaluated per request - resources?: Resources; // skills, prompt templates - - // Execution policy - streamOptions?: StreamOptions; // transport, headers, timeouts, deferred - retry?: RetryPolicy; // step attempt cap; the durable count - compaction?: CompactionSettings; - steeringMode?: QueueMode; - followUpMode?: QueueMode; - /** Batch default; a called tool declaring executionMode "sequential" - forces sequential regardless (section 14). */ - toolExecution?: "sequential" | "parallel"; // default parallel - /** automatic: operation methods drive their procedures to completion. - manual: the operation's effects park at the gate; peekAction() / - executeAction() / runToCompletion() drive them. Deterministic tests - and debuggers. Section 15. */ - drive?: "automatic" | "manual"; // default automatic - - // Projection - /** AgentMessage → provider messages, before each request. Default handles - bash executions, custom messages, summaries; validates at acceptance - that queued/prompted messages convert to user messages. */ - toProviderMessages?: (messages: AgentMessage[]) => Message[] | Promise; - /** Custom entry → context messages, at context build. Entries without a - projector never enter provider context. */ - entryProjectors?: Record; - - // Telemetry. The default context is a no-op. Section 18. - telemetryContext?: TelemetryContext; -} -``` - -`AgentHarness.create()` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Before restore, it appends the seed as the first `lane_config` for fresh or normalized-v3 `main`. Existing format-4 lanes use only their newest config; the seed never overrides them. Any other config-less format-4 lane is corrupt. - -`createLane(name, at)` atomically writes its pointer and the original captured seed, regardless of later lane changes. Setters replace only their lane's total value, never the seed. Reopen options can seed new lanes but cannot alter existing ones without a setter. - -### Results and tagged errors - -The public API vendors this small `better-result` v3 pattern without a runtime dependency: - -The subset contains only: - -- serializable `Result.ok()` and `Result.err()` values; -- `Result.isOk()` and `Result.isErr()` guards; -- `TaggedError` with a literal `_tag`, readonly payload, normal `Error` behavior, `.toJSON()`, and class-level `.is()`; -- exhaustive `matchError()`. - -```ts -export type Result = - | { ok: true; value: T } - | { ok: false; error: E }; - -export const Result = { - ok(value: T): Result { - return { ok: true, value }; - }, - err(error: E): Result { - return { ok: false, error }; - }, - isOk(result: Result): result is { ok: true; value: T } { - return result.ok; - }, - isErr(result: Result): result is { ok: false; error: E } { - return !result.ok; - }, -}; - -export interface TaggedErrorValue extends Error { - readonly _tag: Tag; - toJSON(): { _tag: Tag; message: string } & Record; -} - -export interface TaggedErrorFactory { - new ( - props: Props, - ): TaggedErrorValue & Readonly; - is(value: unknown): value is TaggedErrorValue; -} - -export declare function TaggedError(tag: Tag): TaggedErrorFactory; - -export type ErrorMatchers, R> = { - [Tag in E["_tag"]]: (error: Extract) => R; -}; - -export declare function matchError, R>( - error: E, - matchers: ErrorMatchers, -): R; -``` - -Keep the implementation under about 80 lines excluding tests. Do not add combinators, generator composition, promise wrappers, retry/collection helpers, or `Panic`; Promise is the async boundary, and defects throw or reject with `HarnessFault`. - -Each expected rejection is a class with a literal tag and caller-relevant fields. Use this v3 form without trailing `()` after the property type: - -```ts -class LaneBusy extends TaggedError("LaneBusy")<{ - lane: string; - operationId: string; - operationKind: "run" | "compaction" | "navigation"; - message: string; -}> {} - -class MissingIdentities extends TaggedError("MissingIdentities")<{ - lane: string; - tools: string[]; - models: string[]; - message: string; -}> {} -``` - -The remaining classes use the same base: - -| class | payload besides `message` | -|---|---| -| `NoActiveRun` | `lane` | -| `NoActiveOperation` | `lane` | -| `NothingToResume` | `lane` | -| `InvalidMessage` | `lane`, `reason` | -| `InvalidNavigation` | `lane`, `reason` | -| `UnknownSkill` | `name` | -| `UnknownTemplate` | `name` | -| `UnknownTarget` | `targetId` | -| `UnknownQueueItem` | `lane`, `entryId` | -| `LaneExists` | `lane` | -| `InvalidLane` | `lane`, `reason` | -| `NothingToCompact` | `lane` | -| `Closed` | none | - -Transports serialize `{ _tag, message, ...payload }` and reconstruct the class at the proxy. Adding a rejection class changes its error union, forcing exhaustive `matchError` callers to handle the tag. - -An `Err` means the call did not create or accept the requested work. While the harness remains open and writable, every accepted operation resolves with `Ok`, including `aborted`, `failed`, and `suspended`: - -```ts -interface OperationError { - code: string; - message: string; -} - -type OptionalFinalAssistant = - | { finalEntryId: string; finalMessage: AssistantMessage } - | { finalEntryId?: never; finalMessage?: never }; - -type RunOutcome = - | { kind: "completed"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } - | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) - | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) - | { kind: "suspended"; leafId: string; finalEntryId: string; deferred: DeferredHandle }; - -type CompactionOutcome = - | { kind: "completed"; leafId: string; entry: CompactionEntry } - | { kind: "declined"; leafId: string } - | { kind: "aborted"; leafId: string } - | { kind: "failed"; leafId: string; error: OperationError }; - -type NavigationOutcome = - | { kind: "completed"; newLeafId: string | null; summaryEntry?: BranchSummaryEntry } - | { kind: "declined"; leafId: string | null } - | { kind: "aborted"; leafId: string | null } - | { kind: "failed"; leafId: string | null; error: OperationError }; - -type RunRejected = LaneBusy | InvalidMessage | UnknownSkill | UnknownTemplate | Closed; -type CompactionRejected = LaneBusy | NothingToCompact | Closed; -type NavigationRejected = LaneBusy | InvalidNavigation | UnknownTarget | Closed; -type ResumeRejected = LaneBusy | NothingToResume | MissingIdentities | Closed; -type QueueRejected = NoActiveRun | InvalidMessage | Closed; -type NextRunRejected = InvalidMessage | Closed; -type CancelQueuedRejected = UnknownQueueItem | Closed; -type AbortRejected = NoActiveOperation | Closed; - -type RunResult = Result<{ runId: string } & RunOutcome, RunRejected>; -type CompactionResult = Result<{ runId: string } & CompactionOutcome, CompactionRejected>; -type NavigationResult = Result<{ runId: string } & NavigationOutcome, NavigationRejected>; -type QueueResult = Result<{ entryId: string }, QueueRejected>; -type NextRunResult = Result<{ entryId: string }, NextRunRejected>; -type CancelQueuedResult = Result<{ - outcome: "cancelled" | "already_consumed" | "already_cleared"; -}, CancelQueuedRejected>; -type RecordUsageResult = Result; -type AbortResult = Result<{ - runId: string; - steer: AgentMessage[]; - followUp: AgentMessage[]; -}, AbortRejected>; - -type ResumeOutcome = - | ({ operation: "run"; runId: string } & RunOutcome) - | ({ operation: "compaction"; runId: string } & CompactionOutcome) - | ({ operation: "navigation"; runId: string } & NavigationOutcome); - -type ResumeResult = Result; - -type CreateLaneResult = Result; -``` - -`steer`/`followUp` use the active-run `QueueResult`; only it includes `NoActiveRun`. `NextRunResult` accepts valid input while open and idle or during any operation. It appends only an operation-independent queue record; later run acceptance captures it. - -`navigateTree()` returns `InvalidNavigation` before hooks or writes for the current leaf or labeled root target. An unknown non-null entry returns `UnknownTarget`. Root-label facts do not exist. - -`cancelQueued` reports `cancelled` when append is prevented, `already_consumed` when the entry exists, and `already_cleared` when abort or an earlier cancel removed it. - -A storage write failure is not an `Err`. It faults the harness and rejects the promise with `HarnessFault`: - -```ts -class HarnessFault extends Error { - readonly cause: unknown; - - constructor(message: string, cause: unknown) { - super(message); - this.name = "HarnessFault"; - this.cause = cause; - } -} - -class HarnessClosed extends Error { - constructor() { - super("AgentHarness was closed while the operation was active"); - this.name = "HarnessClosed"; - } -} -``` - -A faulted harness rejects with the same `HarnessFault` until reopen. `close()` rejects local accepted-operation promises with `HarnessClosed`, leaving durable operations resumable. Afterwards, result-returning calls return `Err(Closed)` and others reject with `HarnessClosed`. Invariant violations also reject. Promise rejection means a defect or dead harness, never an expected outcome; these errors are outside public `Result` unions. - -`finalMessage` and `finalEntryId` identify the newest durable assistant response. Failed or aborted runs omit both if none settled; otherwise both identify the newest response, regardless of stop reason. `leafId` is the finish-time lane leaf and race-free branch-query anchor; later deferred writes or tool results can make it differ from `finalEntryId`. Results do not duplicate transcripts. - -**Type provenance.** Core conversation and tool types (`AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, `ThinkingLevel`) come from `packages/agent/src/types.ts`. Provider types (`Model`, `Models`, `Usage`, `RetryPolicy`, stream options, deferred handles) come from `packages/ai`. The generic telemetry contract and schema machinery come from `packages/telemetry`; the AI-request and harness span schemas come from `packages/agent/src/harness/telemetry.ts`. Session, harness, hook, event, result, snapshot, navigation, and durable-record types are defined under `packages/agent/src/harness/`. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runPlannedToolCall`, and request/option bags such as `AssistantRequest`) are constructive implementation detail, not contract. - -### Suspended operations - -```ts -interface SuspendedOperation { - lane: string; - kind: "run" | "compaction" | "navigation"; - id: string; - startedAt: number; // Unix ms, from the operation_started record - reason: "crash" | "deferred"; - prompt?: AgentMessage[]; // runs: normalized original prompt - deferred?: DeferredHandle; // reason "deferred": pending response or - // below-cap unmarked fetch interruption - aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; // abort accepted pre-crash; - // stable cleared payloads returned by repeated abort, - // offered for requeue - /** Identities required by the next ordinary effect, not every name in the - lane configuration. Recomputed as reduction advances; abort-only - reconciliation never reports or blocks on these. */ - missing: { tools: string[]; models: string[] }; -} -``` - -### Examples - -```ts -// Interactive pi. suspended has 0 or 1 entries, always "main". -const { harness, suspended } = await AgentHarness.create({ - session, - models, - model, - thinkingLevel: "off", - activeToolNames: tools.map((tool) => tool.name), - tools, -}); -for (const s of suspended) await (await harness.lane(s.lane))!.resume(); -await harness.nextRun("focus on the tests"); // legal while idle; queues input but starts no run -await harness.prompt("fix the bug"); // captures the queued item, then starts the run -await harness.setModel(opus); - -// Slack bot. Channel = session + main; thread = lane, keyed by thread id. -const key = `slack:${threadTs}`; -let thread = await harness.lane(key); -if (!thread) { - const created = await harness.createLane(key, pingedEntryId); - if (!created.ok) return handleLaneError(created.error); - thread = created.value; -} -// The new lane has the immutable options seed, not configuration from -// pingedEntryId, main, or another lane. -await thread.prompt("summarize this thread"); // parallel to main and other threads -await thread.setModel(haiku); // immediate total replacement; this thread only -await thread.session.appendMessage(msg); // this thread's branch - -// Thread renderer: this lane only. -const { snapshot, start } = await thread.watch(); -render(snapshot.transcript); -start((event) => update(event)); - -// Deferred run (batch pricing). prompt() parks; a webhook or timer resumes. -const result = await thread.prompt("analyze this mailbox"); -if (result.ok && result.value.kind === "suspended") schedulePoll(thread); -// later: await thread.resume(); - -// Dashboard: inventory + firehose, no transcripts. -const s = await harness.watchSession(); -for (const lane of s.snapshot.lanes) { - if (lane.operation?.status === "suspended") await (await harness.lane(lane.name))!.resume(); -} -``` - -## 9. Snapshots and subscription - -A UI needs current state and every later change without a gap. A proxy must put the snapshot on the wire before events, so `watch()` buffers until armed: - -```ts -const { snapshot, start, unsubscribe } = await lane.watch(); // harness.watch() = main's - -await send(client, { kind: "snapshot", snapshot }); // snapshot is on the wire -start((event) => send(client, event)); // flush buffer in order, then live -``` - -`watch()` atomically snapshots and begins buffering. `start(listener)` flushes in order, then delivers live; each event arrives once and in order, without sequence numbers or registration races. `unsubscribe()` drops the watcher and buffer. A never-started watcher buffers without bound. - -`watch()` contains one lane's transcript, operation, queues, pending writes, and scoped/global events. `watchSession()` contains lane inventory, no transcripts, and the unfiltered stream. A dashboard can use the latter for overview and `lane.watch()` for open lanes. - -```ts -interface QueuedItem { - entryId: string; // QueueResult/NextRunResult and cancelQueued correlation - message: AgentMessage; -} - -interface LaneSnapshot { - lane: string; - /** This lane's branch, oldest first: the context window plus its - compaction entry. Older history is paged via session queries. */ - transcript: Entry[]; - leafId: string | null; - - operation: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "aborting"; - startedAt: number; // Unix ms - /** status "suspended": everything a client needs to offer resume/abort. - The same data create() returned; a remote UI only sees snapshots. */ - suspended?: SuspendedOperation; - /** The current assistant/fetch response draft. It remains present from - message_start until the matching response entry commits. After - message_end it is final but still non-durable until entry_added. */ - streamingMessage?: AssistantMessage; - runningTools: { - toolCallId: string; - toolName: string; - args: unknown; - partialResult?: AgentToolResult; - }[]; - retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; - }; - - queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; - pendingWrites: { id: string; entry: ProvisionedEntry }[]; - - faulted: boolean; // harness-wide, mirrored into every snapshot -} - -interface SessionSnapshot { - lanes: (LaneInfo & { suspended?: SuspendedOperation })[]; - faulted: boolean; -} -``` - -Rules: - -- Configuration is not in snapshots. Getters return the current value; `config_update` events (section 10) tell a UI when to re-read. One source of truth. -- `streamingMessage` and `runningTools` let a client that attaches mid-turn render immediately, without replaying events. `streamingMessage` is not part of `transcript`: `message_end` replaces it with the final post-hook value but does not clear it; the matching `entry_added` confirms the append, adds the entry to `transcript`, and clears the draft. A fault or process death can remove a draft that never committed. -- Direct messages and finalized tool-result messages use the same immediate `message_start` → `message_end` lifecycle, then enter `transcript` only on `entry_added`. They never populate `streamingMessage`, whose type is assistant-only. `runningTools` follows `tool_start` through `tool_end`; durable tool-result visibility still comes from `entry_added`. -- An `aborting` snapshot reports only durable/live state that actually exists. It does not synthesize a streaming assistant message. After reconciliation, the operation disappears; the transcript may be unchanged by abort except for required planned tool results and accepted deferred writes. -- Reconnect means a new `watch()`. Against a living harness the new snapshot includes live progress. Only process death loses stream state: a restored harness has no partial streams to report, and the snapshot shows the suspended operation instead. Every entry in the durable transcript is complete; a lost draft was never an entry. Surviving transport drops is the serving layer's job. -- A lane watcher receives events whose `lane` matches, plus events with no lane. The harness-global `usage` event is the explicit exception: it carries the originating lane but is delivered to every lane watcher because its totals are session-wide. `watchSession()` receives the unfiltered stream, and `events.on(type, listener)` observes matching events across the whole harness. `events.on` is live-only — no snapshot, no buffer. -- Watchers are independent; each has its own buffer and its own `start()` gate. - -## 10. Events - -Events form one flat stream. `events.on(type, listener)` matches across the harness; lane watchers apply section 9 filtering. - -Guarantees: - -- Passive. Listeners cannot mutate execution; payloads are isolated from procedure objects. Throws produce `handler_error` plus telemetry and never affect execution. A `handler_error` listener throw goes only to telemetry. Only hooks intercept. -- Ordered. Watchers and `events.on` receive process order. Concurrent lanes do not promise `seq` order; durable consumers use `getLog()`. -- Not persisted, not replayed. Reconnect means a new `watch()`. -- Durable-fact events fire after commit: `entry_added` means queryable. Multi-write events wait for full success, then follow mutation order; labeled navigation emits `fact_update` before operation end, with both already durable. Process-local lifecycle events need not be durable: `message_end` precedes entry append. -- Completion events follow transformation hooks. Streaming updates are intermediate; `after_response` precedes `message_end`, and `after_tool` precedes `tool_end` and result-message events. If abort wins between `message_end` and append, `entry_added` carries the normalized durable response and is authoritative. -- Payloads are secret-free JSON. Models and tools are named, never embedded. -- Lane-scoped events carry `lane: string` (omitted below); harness-global events omit it — except `usage`, which is delivered harness-globally and carries the record's lane in its payload. Operation-scoped events carry `runId`; turn-scoped events carry `turnId`; recovered work carries `recovery: true`. - -### Catalog - -```ts -// Run lifecycle -{ type: "run_start"; runId } -{ type: "run_resume"; runId } // resume() entered (any operation kind) -{ type: "run_suspend"; runId; deferred: DeferredHandle } // lane parked -{ type: "run_abort"; runId; steer: AgentMessage[]; followUp: AgentMessage[] } // first abort accepted; emitted once -({ type: "run_end"; runId; leafId } & ( - | { outcome: "completed"; finalEntryId: string; finalMessage: AssistantMessage; error?: never } - | ({ outcome: "aborted"; error?: never } & OptionalFinalAssistant) - | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant) -)) -{ type: "fault"; code; message } // harness-wide -{ type: "handler_error"; error; stack? } & ({ kind: "hook"; hook } | { kind: "event"; event }) - -// Steps and retries. First-try success emits no retry events. -{ type: "turn_start"; runId; turnId } -{ type: "turn_end"; runId; turnId; message: AssistantMessage; toolResults: ToolResultMessage[] } -{ type: "retry_scheduled"; runId; step; attempt; maxAttempts; delayMs; errorMessage } -{ type: "retry_start"; runId; step; attempt } -{ type: "retry_end"; runId; step; attempt; success: boolean; finalError? } - -// Messages. Every produced user, assistant, and tool-result message keeps -// the existing lifecycle. Direct and tool-result messages emit start/end -// back-to-back; only assistant/fetch streams emit updates. after_response -// has already produced the final response seen by message_end. message_end -// means the message stream ended, not that an entry committed; a direct -// message has a zero-update stream. entryId, when present, is the provisioned -// intended id and is not proof of append. -{ type: "message_start"; runId?; message: AgentMessage } -{ type: "message_update"; runId; message: AgentMessage; event: AssistantMessageEvent } // streaming only -{ type: "message_end"; runId?; message: AgentMessage; entryId?: string } - -// Tools -{ type: "tool_start"; runId; turnId; toolCallId; toolName; args } // effective args -{ type: "tool_update"; runId; turnId; toolCallId; toolName; partialResult } -{ type: "tool_end"; runId; turnId; toolCallId; toolName; result; isError; terminate } - -// Tree, queues, facts -{ type: "entry_added"; entry: Entry } // every committed entry, including messages -{ type: "write_pending"; runId; entryId; entry } // deferred write accepted; message lifecycle may - // occur later, but entry_added confirms commit -{ type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] } -{ type: "fact_update" } & ( - | { fact: "name"; name: string | undefined } - | { fact: "label"; targetId: string; label: string | undefined } - | { fact: "custom"; key: string; value: JsonValue | undefined }) - -// Configuration. A lane setter has committed one total lane_config before -// this compact property event fires; clients re-read via getters. -{ type: "config_update" } & ( - | { property: "model"; value: { provider; modelId }; previous } - | { property: "thinkingLevel"; value; previous } - | { property: "activeTools"; value: string[]; previous: string[] } - | { property: "tools" | "resources" | "streamOptions" | "retryPolicy" - | "compactionSettings" | "steeringMode" | "followUpMode" }) - -// Structural operations. End events mirror operation outcomes. -{ type: "compaction_start"; runId; reason: "manual" | "threshold" | "overflow" } -{ type: "compaction_end"; runId; reason; outcome: "completed" | "declined" | "aborted" | "failed"; - entry?: CompactionEntry; fromHook: boolean; error? } -{ type: "navigation_start"; runId; targetId } -{ type: "navigation_end"; runId; outcome: "completed" | "declined" | "aborted" | "failed"; - oldLeafId; newLeafId; summaryEntry?; error? } - -// Lanes -{ type: "lane_created"; at: string | null } // pointer and seed lane_config committed - -// Cost. Harness-global delivery — every watcher receives it — with the -// record's lane in the payload. totals is the session-wide ledger sum as -// of this commit: stateless consumers render it (seed once via getStats()); -// provenance consumers read the record. Cross-lane delivery is -// process-ordered, not seq-ordered; a rare inversion self-heals on the -// next event. -{ type: "usage"; lane: string; record: UsageRecord; totals: Usage } -``` - -### Nesting - -```text -run_start - message_start / message_end / entry_added consumed prompt/queue messages - turn_start - message_start / message_update* / message_end assistant/fetch stream finished - entry_added response committed - tool_start / tool_update* / tool_end per real call - message_start / message_end tool results, source order - entry_added each tool-result entry committed - turn_end - compaction_start ... entry_added ... compaction_end auto, at a checkpoint, when needed - turn_start ... turn_end until nothing is pending -run_end -``` - -Busy UI spans `run_start`..`run_end` or standalone structural brackets. Resumed structural work re-emits its start with `recovery: true` to balance brackets. Internal compaction/branch-summary streams emit no message lifecycle; their typed result emits `entry_added`, while structural and retry events describe orchestration. - -Streamed assistant/fetch order is: `message_start`, `message_update`*, `after_response`, `message_end` with final value and optional intended id, response append, `entry_added`, usage commit, classification. Only `entry_added` proves durability. If abort wins before append, `entry_added` reports the normalized committed value. Direct messages, synthetic responses, and finalized tool results omit updates but emit start/end before append and `entry_added` only after success. Recovery emits nothing for existing entries. - -A retryable assistant response below cap, including unmarked `aborted`, emits `retry_scheduled`, then `retry_start`/`retry_end` around the later attempt. Abort during delay starts no attempt or events for it. Deferred polls emit no retry lifecycle: pending suspends on its new source, interrupted on its retained source, and a later resume may poll again. - -Abort emits `run_abort` once and eventually `run_end`. Assistant message events occur only when an already-intended assistant/fetch attempt settles or is synthetically settled under its provisioned id. An abort between steps, during tool work, or while suspended can therefore have no abort-specific assistant event; `run_end.finalMessage`/`finalEntryId` then refer to the newest response that already exists or are both absent. Planned tool-result message lifecycles, their `entry_added` confirmations, and accepted deferred-write events still precede `run_end` when reconciliation appends those entries. Structural operations emit no assistant-message lifecycle for abort: `compaction_end` / `navigation_end` reports `aborted` when the marker won before the commit point and `completed` when the structural commit won first. - -## 11. Hooks - -Hooks are awaited interception points. Registration mirrors events and may use a stable id: - -```ts -const off = harness.hooks.on("before_tool", async (event) => { - if (event.toolName === "bash") return { block: { reason: "not allowed" } }; -}); - -harness.hooks.on("before_run", async () => ({ - resumeData: { version: 1 }, -}), { id: "extension.example" }); -``` - -Semantics, uniform across all hooks: - -- Registration is harness-global. Every hook event carries `lane` (omitted below); a handler scopes itself. -- `before_run` and `before_resume` require a stable `id`, unique within each hook name; duplicates reject synchronously. An extension reuses its id across both hooks and restarts. The runner stores `resumeData` by id and gives each resume handler only its value. -- `before_run` runs before acceptance, outside the mutation line, on the normalized caller prompt. It does not see `nextRun` items, which acceptance captures later. Rejected acceptance discards its output. -- Handlers run in registration order, each seeing the prior output. Transformations compose: `messages` append and `systemPrompt` replaces. -- A throw emits `handler_error`, skips that handler, and lets the remaining handlers continue without failing the run. `before_tool` instead fails closed and blocks the tool. -- Durable hook outputs commit before execution continues: `before_run` in `operation_started`, effective `before_tool` args in `tool_started`, and finalized `after_tool` result/`terminate` in its entry. A return alone is not durable; a pre-commit crash may rerun it. -- Events expose post-hook values. Passive listeners cannot transform them; response replacement uses `after_response`, not `message_end`. - -### Catalog - -```ts -// Run boundaries ------------------------------------------------------ - -// Once per run, before acceptance. Not re-run on retry or resume; its -// output is persisted in the operation_started record. -before_run: { - event: { prompt: AgentMessage[]; systemPrompt: string; resources }; - result: { - messages?: AgentMessage[]; // persisted as entries after the prompt - systemPrompt?: string; // persisted override, fixed for the run - resumeData?: JsonValue; // stored under this handler's registration id - } | undefined; -} - -// On resume(), before any effect. Rebuilds process-local extension state. -// Must be idempotent: a crash can rerun it. Cannot rewrite the prompt. -before_resume: { - event: - | { runId; kind: "run"; prepared: { prompt: AgentMessage[]; systemPromptOverride? }; - resumeData?: JsonValue } - | { runId; kind: "compaction" | "navigation"; resumeData?: JsonValue }; - result: void; -} - -// At a normal finish boundary: no tool continuation, no queued messages. -// Returned follow-ups continue the same run; the runner commits them -// conditionally — an abort that wins while the hook runs drops the -// follow-up (section 15). Does not run for abort, terminal failure, or -// exhausted auto-compaction. May fire again after a crash at the same -// boundary; handlers that must not double-fire keep their own durable -// marker. -before_run_end: { - event: { runId; messages: AgentMessage[] }; - result: { followUp?: string } | undefined; -} - -// Request pipeline ---------------------------------------------------- - -// Per request. AgentMessage level, before toProviderMessages. Pruning, -// injection, custom-message handling. Ephemeral: shapes what the provider -// sees, never what the session contains. -transform_context: { - event: { messages: AgentMessage[] }; - result: { messages: AgentMessage[] } | undefined; -} - -// Per request. Provider-neutral request options. -before_request: { - event: { model: Model; step: "assistant" | "compaction" | "branch_summary"; attempt; streamOptions }; - result: { streamOptions?: StreamOptionsPatch } | undefined; -} - -// Per request. Provider-specific wire payload. Last stop. -before_payload: { - event: { model: Model; payload: unknown }; - result: { payload: unknown } | undefined; -} - -// Per response, after provider streaming settles but before message_end and -// before the assistant entry append. Its returned message is the final stream -// value seen by message_end and is the input to settlement. If abort wins -// before append, settlement may still normalize the durable copy to aborted; -// entry_added and the session expose that authoritative committed value. -after_response: { - event: { status: number; headers: Record; message: AssistantMessage }; - result: { message?: AssistantMessage } | undefined; // must keep role -} - -// Tools --------------------------------------------------------------- - -// After validation, before execution. Effective args are persisted in the -// tool_started record. Not re-run for a call whose tool_started exists. -before_tool: { - event: { toolCallId; toolName; args: Record }; - result: { args?: Record; block?: { reason: string } } | undefined; -} - -// After execution, before the result entry is committed. Patch semantics, -// field by field. Runs on safe replay; not on synthetic results. -after_tool: { - event: { toolCallId; toolName; args; content; details; isError; usage? }; - result: { content?; details?; isError?; usage?; terminate?: boolean } | undefined; -} - -// Structural operations ------------------------------------------------ - -// Decline, adjust, or supply the summary. Runs after operation_started. -// If it supplies output, the harness first constructs the complete typed -// provisioned entry and persists it on step_started; if it selects provider -// generation, step_started persists that choice and policy. Either source -// record prevents this decision hook from running again. -before_compaction: { - /** For reason "overflow", preparation already omits the exact response - named by the pending or durable overflow link. */ - event: { reason: "manual" | "threshold" | "overflow"; preparation: CompactionPreparation; customInstructions? }; - /** A supplied compaction is materialized completely on step_started, then - appended exactly as a CompactionEntry with fromHook: true. */ - result: { decline?: boolean; compaction?: CompactResult } | undefined; -} - -before_navigation: { - event: { targetId; preparation: NavigationPreparation; customInstructions? }; - /** A supplied summary persists completely on step_started, then as the - exact BranchSummaryEntry with fromHook: true after the move. */ - result: { decline?: boolean; summary?: { summary: string; details?; usage? } } | undefined; -} -``` - -### Replay across retry and resume - -Hooks re-run only where the work itself re-runs. Persisted outputs are never recomputed. - -| hook | fresh | retry | resume | -|---|---|---|---| -| `before_run` | once | no | no (persisted) | -| `before_resume` | no | no | yes, idempotent | -| `transform_context`, `before_request`, `before_payload` | per request | yes | yes | -| `after_response` | per response | per response | per response | -| `before_tool` | per call | — | not when `tool_started` exists | -| `after_tool` | per executed result | — | on safe replay only | -| `before_compaction`, `before_navigation` | once until a structural source commits | no | not when any structural `step_started` for this work exists; hook output is persisted there | -| `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | - -## 12. Session and SessionTree - -### Entries - -The tree content. No other entry types exist; pointers and global facts are not entries (section 2). - -```ts -interface EntryBase { - type: string; - id: string; - seq: number; // shared sequence; read-side, storage-assigned - parentId: string | null; // storage-assigned: the appending lane's leaf - timestamp: number; // Unix ms, storage-assigned -} - -interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; - terminate?: true } -interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; - retainedTail: AgentMessage[]; - tokensBefore: number; details?; usage?; fromHook: boolean } -interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; summary: string; - details?; usage?; fromHook: boolean } -interface CustomEntry extends EntryBase { type: "custom"; customType: string; data? } - -type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; -``` - -Harness assistant entries always contain `SettledAssistantMessage`; reject `pending` before writing. V4 tool-result entries persist `terminate?: true` beside `message` for reduction, never provider context. Because `AgentToolResult.terminate` exists but `ToolResultMessage` omits it, the entry field is its durable form. - -Every v4 compaction/branch-summary entry requires `fromHook`: true for hook output, false for generation. It also defines `details` ownership. The harness may interpret its generated shape, such as cumulative file tracking, but hook-supplied details remain opaque. - -Every v4 compaction stores complete `retainedTail`, using `[]` when empty, and context never reads past this self-contained checkpoint. Overflow preparation and tail omit the exact superseded response named on `step_started`; the response stays in the tree. Entry `usage` fields are immutable display snapshots: assistant/fetch usage feeds the preplanned record; a real tool result shows only its finalized execution, a synthetic none; structural entries show the sum of successful-attempt request usage, never failed attempts. The ledger separately retains every execution/replay and supplies adjusted effective cost by `entryId` (sections 5, 13). - -v3 files additionally contain `custom_message`, `label`, `session_info`, `model_change`, `thinking_level_change`, and `active_tools_change` entries, plus old compaction entries that use `firstKeptEntryId`. These names are decoder vocabulary only; format 4 exposes no configuration entry type. Load normalizes them before exposing the v4 tree: - -- `custom_message` becomes a custom agent message. -- `label` and `session_info` become global facts (latest by file position wins) and disappear from the logical tree. A label targets its nearest retained parent. -- Legacy model, thinking-level, and active-tool entries disappear. They do not initialize or alter `LaneConfiguration`; harness attachment uses the immutable options seed for an unconfigured normalized `main`. -- Each retained child of a discarded fact-like or legacy-configuration entry is reparented to that entry's nearest retained ancestor. -- `main`'s leaf is the final physical entry resolved through discarded entries to its nearest retained ancestor. -- An old compaction resolves `firstKeptEntryId` against its own branch and materializes that range as `retainedTail`. V4 never exposes or persists `firstKeptEntryId`. -- Existing `details` and `usage` on compaction and branch-summary entries are preserved unchanged. Existing `fromHook` provenance is preserved; an absent v3 value normalizes to `false`. -- v3 entry timestamps are ISO strings and convert to Unix milliseconds. - -Read-only v3 open leaves the file unchanged; the first v4 write persists normalization (section 13). - -### SessionTree - -Each lane exposes this tree view as `lane.session`; `Session` implements it for `main`. Reads pass through. A lane-view entry write enters the mutation line: during a run, including suspension/cancellation, it becomes a durable deferred write; during structural work it waits; while idle it appends. Fact setters commit immediately through `Session.append()` without queues or leaf movement. Standalone Session writes are immediate. - -```ts -interface EntryQuery { - type?: Entry["type"]; - customType?: string; // for type "custom" - order?: "newestFirst" | "oldestFirst"; // default newestFirst - limit?: number; - cursor?: EntryCursor; -} - -/** Bounds of a branch scan. Default: the whole path, leaf to root. */ -interface BranchBounds { - start?: string; // default: the view's lane leaf - stopAtType?: Entry["type"]; // scan ends after the first match, inclusive - stopAtId?: string; -} - -interface SessionTree { - getLeafId(): Promise; - getEntry(id: string): Promise; - getStats(): Promise; - - // Global facts. Latest wins; not branch-scoped. The application surface - // says "set"; tree methods say "append". Low-level Session.append() is the - // storage-mutation primitive, not application fact vocabulary. Keys live - // only in the custom namespace and cannot collide with name or labels. - // undefined deletes a label or custom fact; JSON null is a custom value. - getName(): Promise; - setName(name: string | undefined): Promise; - getLabel(targetId: string): Promise; - setLabel(targetId: string, label: string | undefined): Promise; - getCustomFact(key: string): Promise; - setCustomFact(key: string, value: JsonValue | undefined): Promise; - - /** Session-wide, all branches, sequence order. */ - findEntries(query?: EntryQuery): Promise; - findEntry(query?: EntryQuery): Promise; - - /** Branch-scoped: the path from start toward root. */ - findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise; - findEntryOnBranch(query?: EntryQuery & BranchBounds): Promise; - - // Writes. Resolve on durable acceptance; the returned id is the entry's - // id (provisioned when the write defers). - appendMessage(message: AgentMessage): Promise; - appendCustomEntry(customType: string, data?: unknown): Promise; -} -``` - -A branch query takes the `start`-to-root path, walks in `order`, stops inclusively at `stopAt`, filters, then applies `limit` and `cursor`. - -- `newestFirst` with `stopAtType: "compaction"` ends at the newest compaction: the context window. -- `type` and `customType` filter results; a `stopAt` entry is returned only if it passes the filter. -- Extension patterns: effective state = `findEntryOnBranch({ type: "custom", customType })`; collections = `findEntriesOnBranch(...)`; global inventory = `findEntries(...)`. -- Context uses a branch scan stopping at compaction: summary, materialized tail, then later entries; nothing earlier is read. Before `transform_context` and `toProviderMessages`, projection omits `error`, `aborted`, and `deferred` assistant responses but retains genuine `length`. Overflow is not entry-marked; its linked compaction omits the exact response from preparation and tail. Custom entries pass through `entryProjectors`, then all messages through `toProviderMessages`. -- `SessionTree` has no navigation; moving a lane is `navigateTree()` on the lane. - -Finders and `getEntry` return only committed entries. A deferred write is invisible to tree queries until applied but appears in snapshots by provisioned id. Harness-attached message writes emit their immediate lifecycle before append and `entry_added` after commit; standalone Session has no harness events. - -### Session - -`Session` adds the lane surface and the record log. It is usable standalone — no harness required. In production the harness writes records; recovery fixtures and Tier A tests prefill them through the same API. Lanes, entries, and facts are Session-level. - -```ts -type FactWrite = - | { fact: "name"; name: string | undefined } - | { fact: "label"; targetId: string; label: string | undefined } - | { fact: "custom"; key: string; value: JsonValue | undefined }; - -type SessionMutation = - | { kind: "entry"; lane: string; entry: ProvisionedEntry } - | { kind: "record"; record: NewRecord } - | { kind: "fact"; fact: FactWrite } - | { kind: "lane"; action: "create" | "move"; lane: string; leafId: string | null }; - -type NonEmptySessionMutations = - readonly [SessionMutation, ...SessionMutation[]]; - -/** One committed logical mutation. Entry LogItems are lane-free: routing - belongs only to the corresponding input SessionMutation. Facts and lane - changes have no durable id. */ -type LogItem = - | { kind: "entry"; entry: Entry } - | { kind: "record"; record: LaneRecord } - | { kind: "fact"; seq: number; fact: FactWrite } - | { kind: "lane"; seq: number; action: "create" | "move"; - lane: string; leafId: string | null }; - -class Session implements SessionTree { // bound to "main" - constructor(storage: SessionStorage, options?: { idGenerator?: IdGenerator }); - /** Process-local id provisioning used by Session and harness. Default - UUIDv7; tests inject a deterministic generator. Sync by design. */ - readonly idGenerator: IdGenerator; - - /** Stops new calls, lets appends already admitted by Session settle, drains - the storage queue, then releases backend resources. Reopen through the - repository to use the durable session again. */ - close(): Promise; - - /** SessionTree bound to a lane: reads default to its leaf, appends chain - to it and advance it. The only write-binding mechanism; no SessionTree - method takes a lane parameter. view("main") behaves like the Session. */ - view(lane: string): SessionTree; - - /** Batched exact-id lookup used by bounded reduction. The input ids must - be unique. The immutable result contains only existing requested ids, - keyed by id; missing ids are omitted and no unrequested entry appears. */ - getEntries(ids: readonly string[]): Promise>; - - // Lanes — permanent named pointers. Durable via storage (section 13). - getLanes(): Promise<{ lane: string; leafId: string | null }[]>; - /** Latest total replacement; undefined only for a fresh or normalized-v3 - main before its first harness attachment. */ - getLaneConfig(lane: string): Promise; - /** Atomically creates the pointer and first total configuration with - [lane create, lane_config]. Session provisions the record id and returns - the committed LaneConfigRecord. */ - createLane(lane: string, at: string | null, - configuration: LaneConfiguration): Promise; // rejects existing names - moveLane(lane: string, to: string | null): Promise; - - /** Low-level atomic append for the harness, recovery, and test fixtures. - Bypasses SessionTree deferral policy. Session validates the whole input - before dispatch; a harness caller already holds the lane mutation line. - Arrays are non-empty, apply in order, and return expanded logical items. */ - append(mutation: SessionMutation): Promise; - append(mutations: NonEmptySessionMutations): Promise; - - // Harness and recovery append orchestration records through append(). - // Applications use recordUsage() rather than constructing records. - findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - findRecords(query?: RecordQuery): Promise; - /** Unfinished operation starts, newest first. limit: 2 distinguishes the - valid zero/one states from multiple-open-operation corruption. */ - findOpenOperations(lane: string, options?: { limit?: number }): Promise; - /** Full chronological view: entries, records, facts, lane moves, - merged by seq. Debugging and tests. */ - getLog(options?: { afterSeq?: number; limit?: number }): Promise; -} - -interface IdGenerator { next(): string; } - -interface RecordQuery { - /** Exact lane match. Omit to query every lane. */ - lane?: string; - /** Exact record discriminant match. Omit to query every record type. */ - type?: LaneRecord["type"]; - /** - * Operation identity. Matches OperationStartedRecord.id and the runId - * property of operation-owned records. Records without an operation - * identity do not match. - */ - runId?: string; - /** Exact operation intent kind. Valid only with type "operation_started". */ - operationKind?: OperationStartedRecord["intent"]["kind"]; - /** Exclusive chronological lower bound: seq > afterSeq, regardless of order. */ - afterSeq?: number; - /** Sequence order. Default: "newestFirst". */ - order?: "oldestFirst" | "newestFirst"; - /** Positive maximum number of matching records. */ - limit?: number; -} -``` - -Semantic methods construct mutations and inspect returned `LogItem` discriminants. Array results align positionally, preserving an entry mutation's routing correlation without copying its lane into the result. `createLane()` extracts the second item as its config; Effects similarly recover typed payloads. Storage exposes no parallel typed writes. - -`Session` exposes no `getStorage()` escape hatch: all writes flow through `Session`, which is the single writer the storage contract assumes. - -**Ownership:** after passing a Session to `AgentHarness.create()`, mutate it only through the harness/lane views until `close()` resolves. Concurrent standalone writes are unsupported. Harness close closes that Session object; later use reopens durable state through the repository. - -## 13. Storage - -### Contract - -One storage instance serves one session. Storage persists and queries; `Session` validates and binds views. Storage runs no operations, queues, or recovery. Record payloads are opaque except for query columns and latest-config/open-operation projections. Exact entry lookup uses only the id index. - -```ts -interface SessionStorage { - getMetadata(): Promise; - /** Stops new calls, drains already-accepted writes, stops background - renewal, and releases only this instance's writer claim. */ - close(): Promise; - - /** The only storage write primitive. One call is one queued atomic storage - mutation. A non-empty array applies in order with consecutive seq values. - The result expands into one LogItem per logical mutation. */ - append(mutation: SessionMutation): Promise; - append(mutations: NonEmptySessionMutations): Promise; - - // Reads - getLanes(): Promise<{ lane: string; leafId: string | null }[]>; - getLaneConfig(lane: string): Promise; - getEntry(id: string): Promise; - /** One exact batched lookup. Input ids are unique; missing ids are omitted. - Returned map and entries are immutable and contain no unrequested id. */ - getEntries(ids: readonly string[]): Promise>; - findEntries(query?: EntryQuery): Promise; - /** start is mandatory here; defaulting to a lane's leaf is view sugar. */ - findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise; - findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - findRecords(query?: RecordQuery): Promise; - findOpenOperations(lane: string, options?: { limit?: number }): Promise; - getLog(options?): Promise; - - // Global-fact reads. Writes use append({ kind: "fact", ... }). - getName(): Promise; - getLabel(id: string): Promise; - getCustomFact(key: string): Promise; - getStats(): Promise; -} -``` - -Contract rules, all backends: - -- One monotonic `seq` across entries, records, facts, and lane changes. -- `append()` accepts one mutation or a non-empty array. Session validates and JSON-checks all input first. The backend validates against intermediate state, applies in order with consecutive `seq` and no interleaving, and commits all-or-none. Later items observe all earlier transactional changes. -- Entry `lane` is routing envelope data, absent from `Entry` and committed `LogItem`. Storage assigns `parentId`, `seq`, and `timestamp` from the lane leaf after prior mutations, then advances the leaf. Positional results preserve routing correlation without persisted ownership. -- Records receive `seq`/`timestamp`; fact and lane `LogItem`s receive sequence positions. `getLog()` expands appends before ordering, cursor, and limit. -- A configured lane's newest `lane_config` is its whole value; setters append one record. Lane create is valid only immediately before that lane's first total config in the same append. `Session.createLane()` emits exactly `[lane create, initial lane_config]` and returns the second item. Neither half is observable. -- Labeled navigation completion requires its exact label fact immediately before `operation_finished` in one append. `finishOperation()` emits `[label fact, operation_finished]`; no interleaving is possible, so the accepted label wins over earlier facts. Unlabeled completion appends only finish. -- Hook structural starts store the complete typed result and optional usage id. Generated `branch_summary_prepared` stores its complete payload before move; compaction has no prepared record. Both need no special transaction or index. -- `tool_batch_started` atomically stores the full source-index/result-id plan. Starts, usage, and result entries are later ordinary writes; no tool-specific backend transaction/index is needed. -- Storage linearizes all lane appends; callers never manage `seq`, and promises resolve in commit order. The lane mutation line serializes decisions while storage serializes commits; both are required. -- Promise resolution means durable append. Returned items are deeply immutable. Session installs them only after success, then emits commit events in mutation order; observers never see a partial array. Process-local lifecycle events may precede their entry. -- `Session` and the harness provision ids with `session.idGenerator`; storage enforces per-session uniqueness across the existing state and the full append input. -- Every payload is JSON-serializable. Session validates before dispatch so all backends accept the same values. -- Reads are immutable. `getEntries(ids)` makes one backend call for unique ids; internal chunking is allowed, but one map returns only requested existing entries. -- `findOpenOperations` is a required recovery projection: Memory maintains it with its record state, JSONL derives it while replaying the file, and SQLite answers it from the lane's current open-operation projection. It returns unfinished starts newest first and must expose a second result when a replayed/imported backend observes multiple open operations so recovery can reject corruption. Backends with conditional current-state projections may reject a second `operation_started` append instead of creating that corruption through their normal write API. -- No general conditional writes exist. Single-writer plus mutation lines avoid compare-and-set for normal appends and pointer/fact updates. Only operation start conditionally changes the lane's open-operation projection from null to run id; failure means busy. -- One writer per session is serving-layer policy; SQLite also enforces it. One database may host many independently owned sessions. -- Any append failure faults the harness without publishing state/events. Memory and SQLite roll back. After JSONL I/O failure or death, reopen sees the prior prefix or whole append, never part; recovery handles either. -- Fact and lane-move history is append-only; latest `seq` wins. Name, label, and custom kinds are distinct. Omitted values are tombstones; custom JSON `null` is a value. Facts have no ids or shared identity namespace. Lane-move history also serves as a reflog. -- `close()` rejects new calls, drains admitted appends, then stops renewal and releases resources/claim. Fencing allows release only by the acquired owner/fence pair. Session closes admission and settles admitted appends before closing storage; harness first stops lane admission and signals local effects. Close writes no record, finishes no operation, and leaves open operations resumable. -- For format-4 sessions, the token and cost fields returned by `getStats()` are the sum of `usage` records across all lanes — one rule, no entry-derived billing, and no double counting by construction. `messageCount` counts all message entries in the session tree, including entries copied into a fork. A fork initializes the count from its copied entries, then increments it for newly appended message entries. Backends maintain both as running projections, so reads and the `usage` event's totals are O(1). Format-3 sessions have no records; their usage stats stay entry-derived. The one-time v4 conversion writes one aggregate `adjustment` record (`details: { source: "v3-import" }`) summing the v3 entries' usage, so totals survive conversion. Outside the ledger's claim: the settle-to-write crash window, unreported mid-stream billing, tools that die without reporting, and extension-private LLM calls (section 1 non-goal) — though `adjustment` records let an application close even those after the fact. - -### Memory - -Plain structures: entry map, record list, lane map, latest-config map, separate built-in/custom fact lists, running statistics, one seq counter, one session-wide mutation queue. Each `append()` is one queue job: clone transactional state, validate and apply its logical mutations in order with consecutive `seq`, then publish all changes together or none. The job resolves with its `LogItem`s only after success; Session then publishes live state and commit events. `getEntries` performs exact lookups in the entry map and returns one immutable map. Close rejects new calls and drains the queue. The reference implementation: the parity test suite runs against it first. - -### JSONL - -The concrete repository is `JsonlSessionRepo`. Its metadata and options extend the backend-neutral contracts: - -```ts -interface JsonlSessionMetadata extends SessionMetadata { - cwd: string; - path: string; - modifiedAt: number; // filesystem mtime used for listing order - sourceFormat: 3 | 4; - /** Present only when a v3 parent path could not yet be resolved to an id. */ - legacyParentSessionPath?: string; -} -interface JsonlSessionCreateOptions extends SessionCreateOptions { - cwd: string; - metadata?: Record; -} -interface JsonlSessionListOptions { cwd?: string; } -``` - -A v3 `parentSession` path resolves to the available parent header id; otherwise metadata and first-write conversion retain `legacyParentSessionPath`. Format 4 uses `parentSessionId`. Filesystem `modifiedAt` is not sequenced. - -Layout matches coding-agent v3. Under `sessionsRoot`, cwd directory is `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; files are `${createdAtIso.replace(/[:.]/g, "-")}_${sessionId}.jsonl`. `list({ cwd })` scans one directory; `list()` scans all direct children. Listing reads only headers/filesystem metadata and omits malformed headers. V3 conversion replaces in place without renaming. - -Each session file has a header, then one physical line per `SessionStorage.append()`. One mutation, even via a length-one array, encodes as its ordinary object; multiple mutations encode as one ordered JSON array without batch metadata. Lines order by first logical `seq`; array elements are consecutive. - -```text -{"kind":"header", "version":4, id, createdAt, cwd, parentSessionId?, legacyParentSessionPath?, metadata?} -{"kind":"entry", "lane":"main", "entry":{id,parentId,type,timestamp,...}} // append; advances main -{"kind":"entry", "entry":{id,parentId,type,timestamp,...}} // repository-private fork import -{"kind":"record", "record":{lane,id,runId?,type,timestamp,...}} // config, steps, plans, usage -{"kind":"lane", "action":"move", "lane":"slack:t1", "leafId":"e57"} -{"kind":"fact", "fact":{"fact":"name", "name":"Refactor auth"}} -{"kind":"fact", "fact":{"fact":"label", "targetId":"e17", "label":"checkpoint"}} -{"kind":"fact", "fact":{"fact":"custom", "key":"extension.example/state", "value":null}} -[{"kind":"lane","action":"create","lane":"slack:t1","leafId":"e42"}, - {"kind":"record","record":{lane:"slack:t1",id,type:"lane_config",timestamp,configuration:{...}}}] -``` - -The displayed configured-lane array is one physical line; it is wrapped above only for readability. - -- Open loads the file; all queries use that state. One session-wide queue serializes lanes. Each append allocates consecutive positions and issues one `appendFile` with one object/array and newline. The array overload returns `LogItem[]` even at length one. Replay expands arrays for projections and `getLog()`. -- A complete array is one transaction. Replay validates all elements and relationships in temporary state before publication. Invalid/empty arrays or bad ordering/references are corruption. Omitted custom `value` deletes; null remains JSON null. -- The repository locates/loads sessions, then transfers storage and its queue to `Session`; it retains no opened instance. Close rejects enqueue, drains accepted appends, then releases. Reopen creates a fresh instance; serving enforces one writer. Repository operations are not serialized, so callers await dependencies. -- Encoded entry `lane` is routing metadata discarded at decode. Normal append always supplies it; replay verifies `parentId` against that leaf and advances it. Lane-less entries are private fork imports, advance no lane, and cannot appear in append arrays. Both decode to lane-free `LogItem`s. -- A malformed final line is torn and discarded wholly, including every array element. Malformed interior or complete-invalid transactions are corruption. -- Durability is process-crash level: a resolved append call. No fsync promise; if power-loss durability is ever needed, it becomes an explicit capability. -- V3 files have untagged entries. Open builds section 12's normalized tree on `main`, whose leaf is the final physical entry resolved to its nearest retained ancestor. The first format-4 mutation rewrites once via temp/rename. Read-only open/close never rewrites. - -### SQLite - -SQLite uses a greenfield schema with one persisted leaf per lane. - -```sql -sessions (session_id, created_at, parent_session_id, metadata) -- repository catalog -session_stats (session_id, message_count, usage_payload) -- O(1) running projections -session_sequences (session_id, next_seq) -- atomic seq allocator -entries (session_id, seq, id, parent_id, type, timestamp, payload) -records (session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload) -lanes (session_id, lane, leaf_id, open_operation_id) -- current pointer + open op projection -lane_moves (session_id, seq, lane, leaf_id) -- history; getLog parity -facts (session_id, seq, kind, key, is_deleted, value) -- name, labels, custom; latest by seq -branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) -branch_tips (session_id, branch_id, tip_id) -- PRIMARY KEY (session_id, tip_id) -writer_leases (session_id, owner_id, fence, expires_at_ms) -- writer claim - --- indexes -entries: UNIQUE (session_id, id) -- exact and batched entry lookup -records: (session_id, lane, type, seq), (session_id, lane, type, op_kind, seq) - (session_id, lane, run_id, seq) -- bounded open-operation slice -facts: (session_id, kind, key, seq) -- latest built-in/custom value or tombstone -branch_entries: (session_id, branch_id, entry_type, entry_seq) - (session_id, entry_id) -- reverse lookup: entry → branches -``` - -`records.run_id` is the effective operation identity: a start stores its own id, an owned record its payload `runId`, and independent records null. The index therefore returns the whole open slice, including start, without `OR`. - -`writer_leases` provides expiring fenced ownership. Storage renews on appends and while idle; after queue drain, close stops renewal and deletes only its matching owner/fence, so stale owners cannot release replacements. Each append transaction allocates one consecutive sequence range, applies mutations/projections in order, and commits all-or-none. Configured lane creation uses ordinary `[lane create, lane_config]`; existing indexes answer config and distinguish fact tombstones, SQL null, and custom JSON null. - -`open()` acquires the claim. `list()` does not: it reads the catalog and projects the latest name into `SqliteSessionMetadata.name` for inventory without changing application `metadata`. - -`branch_entries` and `branch_tips` are private SQLite caches. Only explicit repair rebuilds them from parents; runtime never falls back. - -Two invariants carry the whole design: - -- **Every entry is in at least one branch.** Every append inserts its entry into a branch (extend or copy, below). A branch holds a full root path; below any entry it contains, it agrees with every other branch containing that entry, because parent chains are unique. -- **Tips are unique.** A branch only ever ends in the entry that was just created — extension and copy both place a brand-new entry at the end — so no two branches share a tip. `branch_tips` answers "does a branch end at X" with one point lookup, 0 or 1 rows. - -**Read plan** — `findEntriesOnBranch({ start })`, any entry, tip or not: - -1. Reverse index: look up `start` → any containing branch. -2. Range scan that branch, `entry_seq <= start.seq` (parent-before-child makes path order equal seq order), join entries, apply filters and stops. - -**Entry-mutation plan** — applied whenever an `append()` transaction reaches `{ kind: "entry", lane, entry }`. The storage instance queues complete append calls before opening the transaction and reserves enough consecutive sequence values for the whole call, so concurrent lanes cannot interleave their logical mutations and promises resolve in commit order. - -1. `leaf = lanes[lane].leaf_id`; use this mutation's assigned `seq`; insert the entry with `parent_id = leaf`. -2. `branch_tips` lookup: does a branch end at `leaf`? - - Yes → insert one `branch_entries` row there; update that tip to the new entry. - - No → new branch: copy rows `entry_seq <= leaf.seq` from any branch containing `leaf`, insert the new entry's row, insert its tip. (Empty lane: no copy, just the new branch.) -3. `lanes[lane].leaf_id = entry.id`. Update statistics and continue with the next logical mutation against this transactional state. After all mutations validate and apply, commit once; Session then installs the returned `LogItem`s and emits commit events in logical order. - -The four cases, `Bn: [...]` are one branch's rows in seq order: - -```text -Case 1 — plain append. The overwhelmingly common case: one lookup, one row. - - tree: a(1)─b(2)─c(3) lanes: main→c cache: B1:[a b c] - main appends d(4): a branch ends at c → extend - tree: a─b─c─d lanes: main→d cache: B1:[a b c d] - -Case 2 — two lanes, one leaf. First extends, second copies. - - lanes: main→c, t1→c cache: B1:[a b c] - t1 appends u(4): B1 ends at c → extend B1:[a b c u] - (B1 now runs past main's leaf — harmless: main's reads stop at seq ≤ 3) - main appends d(5): no branch ends at c → copy B2:[a b c d] - tree: a─b─c─u lanes: main→d, t1→u - └─d - -Case 3 — lane parked mid-history. createLane("t2", at=b, configuration=Cseed), then append. - - lanes: main→d, t2→b cache: B1:[a b c u], B2:[a b c d] - t2 reads: b found in B1 (or B2), scan seq ≤ 2 — nothing built - t2 appends x(6): no branch ends at b → copy B3:[a b x] - -Case 4 — a branch still ends at an entry that has children. - - From case 2: B1:[a b c u], B2:[a b c d]; t1 navigates away, main navigates to c. - main appends e(7): c has children (u, d) — but the tip test asks the - right question: does a branch END at c? No → copy. - If instead a branch DID end there (its continuation had gone to another - branch's copy), the tip test extends it — one row instead of a path copy. - The has-children test would copy needlessly; the tip test never does. -``` - -Stale branches (no lane resolves through them) are kept. - -Restore uses indexed bounded/exact queries: open-operation projection, newest-run index, relevant next-run index, run-id slice, and one id-batched entry lookup. It does not use branch caches or touch another lane; branch indexes serve later context/structural work. - -SQLite implementation follow-ups: - -- Finish search backend work now in progress. -- Add limit and cursor support to search results. -- Route `findEntries` through indexed/search-backed query paths where possible instead of decoding and filtering all session entries. -- Re-audit SQLite query plans after search and `findEntries` changes to see whether further index or query-shape improvements are warranted. - -## 14. Agent-loop building blocks - -`agent-loop.ts` exposes stateless, session-agnostic blocks. The harness composes them with durability writes between phases. - -### Streaming one assistant response - -```ts -export interface StreamAssistantConfig { - model: Model; - systemPrompt?: string; - tools?: AgentTool[]; - /** AgentMessage[] → AgentMessage[]. Pruning, injection. */ - transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - /** AgentMessage[] → provider messages. */ - toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise; - /** Dispatch. models.streamSimple resolves auth per request (credential - store, expiring tokens, header merge, env, baseUrl) — no auth surface - on this config. streamFn overrides dispatch for tests. */ - models: Models; - streamFn?: StreamFn; - /** SimpleStreamOptions carries apiKey/headers/env overrides, transport, - timeouts, metadata, deferred — and onPayload/onResponse, the mounting - points for the before_payload and after_response hooks. */ - streamOptions?: SimpleStreamOptions; - /** Explicit parent for request telemetry. Section 18. */ - telemetryContext: TelemetryContext; - signal?: AbortSignal; -} - -/** One provider request. Emits message_start / message_update, runs the - after_response transform, then emits message_end with that final value; - returns the same final assistant message. A harness sink may attach the - attempt's provisioned entry id to message_end; compatibility callers may - omit it. Provider errors are in-band: stopReason "error" | "aborted" | - "deferred". Does not mutate its inputs — persistence happens later in - the caller. */ -export function streamAssistant( - messages: AgentMessage[], - config: StreamAssistantConfig, - emit: AgentEventSink, -): Promise; -``` - -### Tool execution - -Tools declare recovery safety. Omission means `"never"`: - -```ts -interface AgentTool { - replay?: "never" | "safe"; - // existing fields -} -``` - -Calls expose three phases so `tool_started` fits between clearance and effect, and recovery can run effect/finalization without clearance. The batch driver owns no durable ids; the harness plans all calls first. Callbacks receive the original `AgentToolCall`, allowing private source-index lookup without exposing that index: - -```ts -type PreparedToolCall = { kind: "prepared"; toolCall: AgentToolCall; tool: AgentTool; args: unknown }; -type ImmediateOutcome = { kind: "immediate"; result: AgentToolResult; isError: true }; - // unknown tool, invalid args, blocked, aborted -type FinalizedToolCall = { toolCall: AgentToolCall; result: AgentToolResult; isError: boolean }; - -/** Phase 1 — clearance. Tool lookup, prepareArguments, schema validation, - beforeToolCall (may replace args or block), validation of replacement - args, abort checks. No effect starts here. */ -export function prepareToolCall( - toolCall: AgentToolCall, tools: AgentTool[], callbacks: ToolCallbacks, - telemetryContext: TelemetryContext, signal?: AbortSignal, -): Promise; - -/** Phase 2 — the effect. Streams tool_execution_update via the sink and - drains pending update events before resolving. Never throws; failures - become error results. */ -export function executeToolCall( - prepared: PreparedToolCall, emit: AgentEventSink, - telemetryContext: TelemetryContext, signal?: AbortSignal, -): Promise<{ result: AgentToolResult; isError: boolean }>; - -/** Phase 3 — afterToolCall patch, field by field; a throwing callback - becomes an error result. */ -export function finalizeToolCall( - prepared: PreparedToolCall, executed: { result; isError }, callbacks: ToolCallbacks, - telemetryContext: TelemetryContext, signal?: AbortSignal, -): Promise; - -/** content ?? [] normalization, addedToolNames passthrough, timestamp. */ -export function createToolResultMessage(finalized: FinalizedToolCall): ToolResultMessage; -export function createErrorToolResult(text: string): AgentToolResult; - -export interface ToolCallbacks { - beforeToolCall?(call, args, signal): Promise<{ - args?: Record; - block?: { reason: string }; - } | undefined>; - afterToolCall?(call, args, result, isError, signal): Promise; - /** Phase-two dispatch. Omission calls executeToolCall() directly. The - harness supplies a function that delegates each invocation to - fx.executeTool; this callback is internal orchestration, not tool context. */ - executeTool?(prepared: PreparedToolCall): Promise<{ - result: AgentToolResult; - isError: boolean; - }>; - /** Between phases 1 and 2: the durability point. The batch plan already - exists; the harness writes tool_started without another result id. - Called in source order in both modes. */ - onToolStart?(call: AgentToolCall, effectiveArgs: Record): Promise; - /** After phase 3 and the result message's start/end lifecycle; source - order. The original call identifies its existing planned id. The - harness writes usage first when present, then appends the result with - terminate; entry_added follows that commit. */ - onToolResult?(call: AgentToolCall, message: ToolResultMessage, - terminate: boolean): Promise; -} - -/** Batch-driver rules: - - Provider toolCallId values are unique within the assistant response by - contract. The driver assumes that invariant and adds no duplicate handling. - - A stopReason "length" passed to this driver produces one explanatory - immediate error per source call without lookup, hooks, onToolStart, or - execution. The harness calls this only after classifying a genuine - output-limit stop; an overflow-classified response gets no plan or batch. - - Mode: sequential when options.toolExecution === "sequential" or when - any called tool declares executionMode "sequential"; else parallel. - - Preparation is source-ordered and sequential in both modes. Sequential - mode completes phases 1–3, emits the result message lifecycle, and calls - onToolResult for one call before the next. - - Parallel mode invokes onToolStart and dispatches callbacks.executeTool - in source order without awaiting earlier effects. Effects settle - concurrently. Phase 3, result-message lifecycle emission, and - onToolResult then await and finalize those outcomes in source order. - - Blocked and invalid calls skip onToolStart and phase 2, but still call - onToolResult at their source position with an immediate error. - - Abort stops further preparation and lets already-dispatched effects - settle. An internal durability callback may propagate abort before - phase 2; the batch driver starts no effect - for that call, awaits/finalizes earlier dispatched effects in source - order, then propagates control. The harness's reconciliation fills every - still-missing planned result. - - terminate is true only when every finalized result sets terminate. */ -export function executeToolBatch( - assistant: AssistantMessage, tools: AgentTool[], callbacks: ToolCallbacks, - options: { toolExecution?: "sequential" | "parallel" }, emit: AgentEventSink, - telemetryContext: TelemetryContext, signal?: AbortSignal, -): Promise<{ messages: ToolResultMessage[]; terminate: boolean }>; -``` - -### Compatibility wrapper - -The public `agent-loop.ts` interface, signatures, behavior, event order, and results remain unchanged for `agentLoop`, `agentLoopContinue`, `runAgentLoop`, `runAgentLoopContinue`, and `AgentEventSink`, including config callbacks `getSteeringMessages`, `getFollowUpMessages`, `prepareNextTurn`, `shouldStopAfterTurn`, `beforeToolCall`, and `afterToolCall`. They compose the new blocks with no-op telemetry and direct phase-two execution, adding no durability. Existing loop/agent tests pass unchanged. - -## 15. Harness internals - -The following code specifies behavior using section 14 blocks. `prompt()` and `resume()` run the same procedures after fresh or existing acceptance. Lane procedures run concurrently and meet only at storage append. - -Part III adds no durability semantics. It implements Part II with a steppable **effects boundary** and a **lane mutation line** that closes check-then-act races. - -### The effects boundary - -Every procedure write, provider request/fetch, individual tool invocation, hook, and timer crosses injected `Effects` (`fx`). Automatic mode delegates; manual mode gates the same handle. Its methods are the complete crash-site catalog: each before/after boundary is a section 6 state. - -Lane-surface mutations deliberately bypass gating: acceptance, queue/config calls, lane-view writes, abort, and lane creation use the same lane FIFO directly, allowing control while parked. Pre-acceptance `before_run` still crosses `fx.runHook`; its later acceptance is ungated, so manual mode may expose a hook before any operation record. - -Read-only `ProcedureRuntime` supplies reduced state/planned entries, branch/context readers, ids, environmental identity resolution, and passive events. These do not write, wait externally, invoke effects, or gate. Procedures never receive Session, Models, tool registries, or hook runners directly. Resolve identities only immediately before their hook/provider/tool path; synthetic and abort-only paths resolve none. - -```ts -type StructuralStepStartIntent = - | { source: "generated" } - | { source: "hook"; hookResult: ProvisionedEntry; hookUsageRecordId?: string }; - -type StepStartIntent = { id: string; runId: string } & ( - | { step: "assistant"; triggerMessageId: string } - | { step: "deferred_fetch"; configuration: LaneConfiguration; retryPolicy: RetryPolicy } - | ({ step: "compaction"; resultEntryId: string } & - StructuralStepStartIntent & ( - | { compactionReason: "manual" | "threshold" } - | { compactionReason: "overflow"; - supersededResponseEntryId: string; triggerMessageId: string } - )) - | ({ step: "branch_summary"; resultEntryId: string } & - StructuralStepStartIntent) -); - -interface Effects { - // Semantic durable writes. Implementations delegate to Session.append() - // but retain typed validation, live-state, event, telemetry, and manual-gate - // behavior. Each commits at the head of the lane mutation line, then updates - // LaneState. Every successful entry commit emits entry_added after the - // complete storage append and state update agree. - appendEntry(entry: ProvisionedEntry, telemetryContext: TelemetryContext): Promise; - appendRecord(record: NewRecord, telemetryContext: TelemetryContext): Promise; - /** After the response's message_end, appends it under its attempt's - provisioned id and emits entry_added. In the same mutation-line job, an - earlier abort marker normalizes the settled message to stopReason - "aborted" and clears deferred-only fields; an already-committed response - is never changed. */ - settleAttemptResponse( - attempt: Extract, - message: SettledAssistantMessage, - telemetryContext: TelemetryContext, - ): Promise; - /** Effect-intent races. These append only when abort has not already won. */ - startAttempt(attempt: NewRecord, - telemetryContext: TelemetryContext): Promise; - startToolBatch(plan: NewRecord, - telemetryContext: TelemetryContext): Promise; - startTool(start: NewRecord, - telemetryContext: TelemetryContext): Promise; - /** Structural commit races. These write only when abort has not already - won; an existing result/move remains committed when abort arrives later. */ - commitStructuralEntry(entry: ProvisionedEntry, - telemetryContext: TelemetryContext): Promise<"committed" | "aborted">; - /** A generated structural step can fail only while abort has not won. */ - failStructuralStep(record: NewRecord, - telemetryContext: TelemetryContext): Promise; - /** Persists one complete generated branch-summary payload before the move. - Returns aborted instead of writing when the marker won. */ - prepareBranchSummary(record: NewRecord, - telemetryContext: TelemetryContext): - Promise; - /** A summarized move additionally verifies that a complete hook or - generated payload is already durable. */ - commitNavigationMove(to: string | null, - telemetryContext: TelemetryContext): Promise<"committed" | "aborted">; - /** Commits step_started while atomically capturing the lane's current - total configuration and normalized retry policy for generated sources. - Hook sources instead persist the supplied complete result and usage id. - Deferred-fetch configuration and policy are copied from its original - generation step and supplied by its owning procedure. */ - startStep(start: StepStartIntent, - telemetryContext: TelemetryContext): Promise; - moveLane(to: string | null, telemetryContext: TelemetryContext): Promise; - setFact(fact: FactWrite, telemetryContext: TelemetryContext): Promise; - - // Conditional commits. Decision and write in one mutation-line job. - tryFinishRun(runId: string, outcome: "completed" | "failed", - telemetryContext: TelemetryContext, - error?: OperationError): Promise<"finished" | "continue">; - /** For completed navigation with an accepted label, the conditional - commit uses one Session.append([label fact, operation_finished]). */ - finishOperation(runId: string, outcome: "completed" | "declined" | "failed" | "aborted", - telemetryContext: TelemetryContext, - error?: OperationError): Promise<"finished" | "continue">; - commitRunEndFollowUp(runId: string, item: ProvisionedEntry, - telemetryContext: TelemetryContext): Promise<"committed" | "dropped">; - consumeQueueItem(runId: string, queue: "steer" | "followUp", entryId: string, - telemetryContext: TelemetryContext): Promise<"consumed" | "skipped">; - applyPendingWrite(runId: string, entryId: string, - telemetryContext: TelemetryContext): Promise<"applied" | "skipped">; - - // External effects. - /** Exactly one provider generation request. Assistant/fetch public message - lifecycle is emitted by the owning request adapter; structural callers - supply a private sink. Nested request hooks call this same `fx` facade. */ - streamAssistant(request: AssistantRequest, - telemetryContext: TelemetryContext): Promise; - executeTool(prepared: PreparedToolCall, - telemetryContext: TelemetryContext): Promise<{ result: AgentToolResult; isError: boolean }>; - /** The source entry supplies the exact provider/model and complete handle. - Returned and rejection-converted responses complete their public - message lifecycle before this method resolves. */ - fetchDeferred(source: MessageEntry, options: DeferredFetchOptions, - telemetryContext: TelemetryContext): Promise; - cancelDeferred(source: MessageEntry, - telemetryContext: TelemetryContext): Promise; - - // Interception and time. - runHook(name: K, event: HookEvent, - telemetryContext: TelemetryContext): Promise>; - sleep(delayMs: number, telemetryContext: TelemetryContext): Promise<"elapsed" | "aborted">; -} -``` - -Rules: - -- Reads (`getEntry`, `findEntriesOnBranch`, context building, id allocation) are not effects and never gate. -- **Construction rule:** procedures receive `fx`, their current `TelemetryContext`, and the read-only `ProcedureRuntime` described above — never the session, models, tools, or hook runner directly. Every effect call receives that context as its final non-payload parameter; section 15 snippets omit repetitive context threading where it would obscure control flow. The harness supplies `ToolCallbacks.executeTool = prepared => fx.executeTool(prepared, currentContext)` to `executeToolBatch`, so every phase-two call crosses its own boundary; the other section 14 callbacks route each hook and durable write through `fx`. The rule is enforced by construction and by a test: any operation driven in manual mode performs zero storage writes and zero provider or tool calls while parked. -- `fx.streamAssistant` wraps exactly one section 14 `streamAssistant` request with authenticated dispatch. Its request-pipeline callbacks invoke `transform_context`, `before_payload`, and `after_response` through the same outer `fx.runHook`, so manual drive exposes them as nested hook actions. Assistant generation uses the lane event sink. Each request of structural generation calls `fx.streamAssistant` separately with a private sink, emits no public assistant-message lifecycle, and forces `deferred: false`; a deferred structural result is a defect. -- The `fx` implementation delegates deferred work through `Models`, resolves the provider/model from the exact source entry, and converts a rejected fetch into a `stopReason: "error"` assistant message, so expected provider failures stay in-band. Fetch settlement runs `after_response` through the same `fx` facade and completes `message_end` before returning to the redemption procedure. That procedure always supplies `{ wait: 0 }`; poll cadence stays with the caller. Unexpected rejections from durable appends fault the harness (section 4). - -### The lane mutation line - -Races arise when an `await` separates a state decision from its write. Each lane therefore has one process-local FIFO, and every state-dependent decision commits inside one job: - -```ts -let tail: Promise = Promise.resolve(); - -function mutateLane(job: () => Promise): Promise { - const result = tail.then(job); - tail = result.then(() => undefined, () => undefined); - return result; -} -``` - -A job validates live `LaneState`, makes at most one `Session.append()`, installs returned mutations, then publishes commit events in order. Appends normally contain one mutation; lane creation and labeled-navigation finish contain two. External effects and backoff run between jobs, so each commit revalidates. Concurrent jobs produce only `[A,B]` or `[B,A]`, never interleaving. - -The jobs, by caller: - -- **Lane surface** (ungated, enqueue directly): - - *Operation acceptance* — after pre-acceptance `fx.runHook("before_run", ...)` returns, validate idle, capture the pending `nextRun` items into `initialMessages`, write `operation_started`, set `state.operation`. The second of two concurrent acceptances sees the first and rejects `busy` with no write; its already-completed hook output is discarded. The hook ran outside the line on the prompt only, but still crossed `Effects` and is a manual action. - - *Configured lane creation* — validate the name and anchor, then call `Session.createLane()`, which appends `[lane create, seed lane_config]`; publish the lane and `lane_created` only after both logical mutations commit. - - *Queue acceptance* (`steer`, `followUp`) — validate an active, non-aborting run; write `queue_enqueued`. While the harness is open, `nextRun` validates its message but performs no active-operation check, writes its operation-independent enqueue, and starts no run. - - *Queue cancellation* (`cancelQueued`) — no `queue_enqueued` for the id: `Err(UnknownQueueItem)`; target entry exists: `already_consumed`; not pending (abort-drained or already cancelled): `already_cleared`; else write `queue_cancelled` and remove the item from its pending set. - - *Deferred-write acceptance* (lane-view tree writes) — run open: write `write_deferred`; structural operation open: wait for it to end, then re-enter; idle: append the entry directly. - - *Configuration setter* — derive one total replacement from the lane's current value, append `lane_config`, then update the current value. This runs immediately during any operation and survives abort. - - *Abort* — on the first call, write `abort_requested` and store the exact drained `pendingSteer`/`pendingFollowUp` payloads. After that job commits and leaves the line, emit `run_abort`, signal the active effect's `AbortController`, and cancel this lane's unreleased manual provider/tool/fetch/sleep actions without executing them. A call that finds the marker returns copies of those stored/derived payloads with the same run id and performs no write, event, signal, or gate cancellation. A call that finds the terminal record returns `NoActiveOperation`. - - *Resume admission* — reserve the lane's single execution slot; no write. -- **Procedure via `fx`** (gated in manual mode): - - `tryFinishRun` — if aborting or anything pending, write nothing and return `"continue"`; else write `operation_finished` and idle the lane. - - `consumeQueueItem` — if the item is still pending and the run is not aborting, emit its immediate message start/end, append its entry, and remove it; else `"skipped"` with no events. - - `applyPendingWrite` — same shape for deferred writes, including immediate message lifecycle when the target is a message; they apply even while aborting. - - `commitRunEndFollowUp` — write `queue_enqueued` only while the run is active and non-aborting; else `"dropped"`. - - `finishOperation` — terminal record unless preempted: a non-abort outcome returns `"continue"` when an abort marker exists before an uncommitted structural effect; a committed compaction result or navigation move instead completes. Completed navigation with an accepted label appends `[label fact, operation_finished]` atomically and publishes `fact_update` before the operation-end event after success. An `"aborted"` run outcome returns `"continue"` while deferred writes are still pending, so reconciliation applies them first. - - `settleAttemptResponse` — append the attempt's response exactly once; if the abort marker is already present, normalize the message to `stopReason: "aborted"` in this job. This is the response-append side of race 10. - - `startAttempt` / `startToolBatch` / `startTool` — append an external-effect intent only if abort has not already won; otherwise return `"aborted"`, and the procedure starts no provider/tool effect. Each still surfaces as an `append_record` action in manual drive. - - `prepareBranchSummary` — append the complete generated payload only if abort has not won and the lane is still at the navigation source; otherwise return `"aborted"`. It is an `append_record` action. - - `commitStructuralEntry` / `commitNavigationMove` — if abort already won, write nothing and return `"aborted"`; otherwise perform the compaction-entry append or navigation move that makes the structural operation irrevocably committed. A summarized move additionally requires its hook result or generated prepared record. - - `failStructuralStep` — append `step_failed` only if the generated step is still open and abort has not won; otherwise return `"aborted"` without a write. - - `startStep` — validate that no conflicting step transition or uncommitted-operation abort won. If abort won, return `"aborted"` without a write. Otherwise, for a generated source snapshot the required total configuration and normalized retry policy; for a hook source validate and persist the supplied complete typed result and optional usage id. Append one `step_started`, then install that exact record in state. This job is the generation-step side of race 9 only for generated sources. - - Plain `appendEntry`/`appendRecord`/`moveLane`/`setFact` — unconditional semantic effects, each delegated to one-mutation `Session.append()` and still serialized by the line; operation procedures use the conditional methods above at abort races. - -Two examples, both orders legal, nothing else possible: - -```text -steer vs finish abort vs before_run_end follow-up -[steer, finish]: [abort, commit]: - queue_enqueued; pendingSteer=[x] abort_requested; queues drained - tryFinishRun → "continue" commitRunEndFollowUp → "dropped" - run consumes the steer reconciliation; no record after abort -[finish, steer]: [commit, abort]: - operation_finished; lane idle queue_enqueued committed - steer → NoActiveRun, no write abort drains it; payload returned -``` - -### Race catalog - -The complete list. Each row names the two legal histories and the jobs that force them. Tier C (section 19) tests both orders of every row. - -| # | race | histories | mechanism | -|---|---|---|---| -| 1 | `prompt()` vs `prompt()` | one accepted; other `busy`, no write | acceptance job | -| 2 | `steer`/`followUp` vs run finish | consumed at a checkpoint · `NoActiveRun` | queue acceptance + `tryFinishRun` | -| 3 | deferred write vs run finish | applied before close · idle direct append | write acceptance + `tryFinishRun` | -| 4 | abort vs run finish | reconciliation, outcome `aborted` · `NoActiveOperation` | abort job + `tryFinishRun` | -| 5 | abort vs queue consumption | entry appended, not in abort payload · returned by abort, skipped | `consumeQueueItem` + abort drain | -| 6 | abort vs `before_run_end` follow-up | committed then drained by abort · dropped, nothing behind the marker | `commitRunEndFollowUp` | -| 7 | `nextRun` vs acceptance | captured by this run · belongs to the next | capture inside acceptance | -| 8 | deferred write vs abort finish | applied during reconciliation · applied before it | `finishOperation("aborted")` loops | -| 9 | config setter vs generation-step start | replacement captured by the new step · already-started step keeps its prior snapshot | config-setter job + generation-step start commit | -| 10 | abort vs in-flight provider/tool effect | response/result commits first and is preserved · marker commits first and settlement is normalized or synthetic | irreducible external race; marker precedes signal, response append revalidates, tool reconciliation owns missing planned results | -| 11 | cross-lane writes | any interleaving | storage `seq` linearization (section 13); lanes share no state | -| 12 | `cancelQueued` vs consumption | consumed first: `already_consumed` · cancelled first: consumption skips, the model never sees it | cancel job + `consumeQueueItem` | - -Row 10 is irreducible: an external effect may occur without a returned result. Section 5 intent plus replay policy handles it like a crash. - -### Drive modes - -`drive: "automatic"` passes `fx` through; zero overhead. `drive: "manual"` wraps the operation's `fx` in a gate: every method call parks before executing and surfaces a JSON-safe description. - -```ts -type ActionInfo = - | { kind: "append_entry"; entryType: Entry["type"]; entryId: string } - | { kind: "append_record"; recordType: LaneRecord["type"] } - | { kind: "move_lane"; to: string | null } - | { kind: "set_fact"; fact: "name" | "label" | "custom" } - | { kind: "try_finish_run"; outcome: "completed" | "failed" } - | { kind: "finish_operation"; outcome: "completed" | "declined" | "failed" | "aborted" } - | { kind: "commit_follow_up" } - | { kind: "consume_queue_item"; queue: "steer" | "followUp"; entryId: string } - | { kind: "apply_pending_write"; entryId: string } - | { kind: "stream_assistant"; step: "assistant" | "compaction" | "branch_summary"; attempt: number } - | { kind: "execute_tool"; toolCallId: string; toolName: string } - | { kind: "fetch_deferred" | "cancel_deferred"; provider: string; id: string } - | { kind: "hook"; name: HookName } - | { kind: "sleep"; delayMs: number }; -``` - -```ts -interface ParkedAction { - info: ActionInfo; - /** Starts the effect without awaiting it. A released parent can therefore - park a nested action that the same driver must release. */ - start(): void; - /** Present for an unreleased provider/tool/fetch/sleep action. Abort removes - it without running the external effect; sleep resolves `"aborted"`, and - the others reject to the procedure's internal Aborted path. */ - abortBeforeStart?(): void; - settled: Promise; -} - -class GatedEffects implements Effects { - private readonly queue: ParkedAction[] = []; - - private gate( - info: ActionInfo, - run: () => Promise, - abortBeforeStart?: (resolve: (value: T) => void, reject: (error: unknown) => void) => void, - ): Promise { - return new Promise((resolve, reject) => { - let started = false; - let settle!: () => void; - const settled = new Promise((done) => { settle = done; }); - this.queue.push({ - info, - start: () => { - if (started) throw new Error("action released twice"); - started = true; - void Promise.resolve().then(run).then(resolve, reject).finally(settle); - }, - abortBeforeStart: abortBeforeStart ? () => { - if (started) return; - started = true; - try { abortBeforeStart(resolve, reject); } finally { settle(); } - } : undefined, - settled, - }); - this.arrived(); // wakes a pending driver, including a released parent - }); - } - - appendRecord(record: NewRecord, telemetryContext: TelemetryContext) { - return this.gate({ kind: "append_record", recordType: record.type }, - () => this.inner.appendRecord(record, telemetryContext)); - } - startStep(start: StepStartIntent, telemetryContext: TelemetryContext) { - return this.gate({ kind: "append_record", recordType: "step_started" }, - () => this.inner.startStep(start, telemetryContext)); - } - // ... one wrapper per effect method. Conditional writes retain their - // specific ActionInfo kind; start/settle/prepare/fail record methods are - // individual append_record or append_entry actions, never one compound gate. -} -``` - -The public controls, on the lane (section 8): - -- `peekAction()` describes the next parked call, including pre-acceptance `before_run`. It returns `undefined` only when no parked or admitted work can produce an action. It is stable and side-effect free. -- `executeAction()` starts exactly the peeked call. It waits until that call or operation settles, or a nested action arrives, then returns the next action or `undefined`; it never hides descendants or releases twice. -- `runToCompletion()` repeats this, releasing nested actions before awaiting parents, until the operation or pre-acceptance call settles. -- Two concurrent drivers are a programmer defect, as is calling the controls in automatic mode. - -Semantics that make tests deterministic: - -- The gate is reentrant. Nested `fx` calls, notably request hooks inside `stream_assistant`, park independently. The driver releases them before their parent can continue, preserving each crash boundary without deadlock. -- The gate serializes source-ordered phase-two tool calls as separate actions. Manual mode runs them one at a time; automatic parallelism changes no durable log because finalization is source ordered. -- The lane surface remains ungated. While parked, `steer()`, `abort()`, and `session.appendMessage()` run immediately on the mutation line. Calling them before or after `executeAction()` constructs both race orders. -- First abort calls `abortBeforeStart()` on unreleased provider/fetch/tool/sleep actions, so none executes; their intent remains for reconciliation, and sleep returns `"aborted"`. Wrappers enqueued after the marker reject or resolve as aborted without parking or executing the external effect. Released effects instead receive normal cancellation and race the marker. Hooks/writes remain, with conditional commits revalidating abort. Repeated abort does not cancel again. -- Close rejects parked calls and local operation promises without further commits, leaving exactly the released-effect prefix for ordinary reopen/resume. Automatic close stops admission, signals in-flight work, settles admitted Session writes, drains storage, and releases its matching writer claim. Open operations remain resumable. - -### Live lane state - -```ts -interface TerminalFailureState { - entryId: string; - source: "assistant" | "deferred_fetch"; - /** stopReason error, or unmarked aborted at the applicable captured cap. */ - message: AssistantMessage; -} - -interface StepState { - started: StepStartedRecord; - attempts: StepAttemptRecord[]; // this stepId, source order - newestAttempt?: { - record: StepAttemptRecord; - response?: MessageEntry; // assistant/fetch only - usage?: UsageRecord; // exact preplanned id - }; - /** Generated branch summary only; complete payload before navigation move. */ - preparedBranchSummary?: BranchSummaryPreparedRecord; - /** Hook payload comes from started; this bit covers its preplanned usage. */ - hookUsageExists: boolean; - resultExists: boolean; // structural typed result - failure?: StepFailedRecord; // generated structural terminal failure -} - -/** In-memory orchestration state per lane. Restore obtains it from the pure - bounded reduction in section 7; live commits apply the same transitions. - Tests, not production settlement, compare live state with fresh reduction. */ -interface LaneState { - lane: string; - leafId: string | null; - /** The newest total lane_config replacement. */ - configuration: LaneConfiguration; - operation: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - sourceLeafId: string | null; - intent: OperationStartedRecord["intent"]; - /** Sole durable cancellation request and the queue payloads it killed. - Null means ordinary execution may continue. */ - abort: null | { - record: AbortRequestedRecord; - steer: ProvisionedEntry[]; - followUp: ProvisionedEntry[]; - }; - /** Current durable step. Assistant/fetch responses remain here until - their post-persistence transition is represented by later state. */ - step: StepState | null; - toolBatch: null | ToolBatchState; - missingInitialMessages: ProvisionedEntry[]; - pendingSteer: ProvisionedEntry[]; - pendingFollowUp: ProvisionedEntry[]; - pendingWrites: ProvisionedEntry[]; - deferred: DeferredHandle | null; // unredeemed handle - overflowRecoveryUsed: boolean; // overflow step exists for current trigger - /** Newest entry this operation appended; pure predicates read it. */ - newestOwn: null | { entryId: string; type: Entry["type"]; - role?: AgentMessage["role"]; stopReason?: TerminalStopReason }; - targets: { result?: boolean; summary?: boolean }; // structural ops - }; - pendingNextRun: ProvisionedEntry[]; -} - -interface ToolBatchState { - plan: ToolBatchStartedRecord; - assistantEntryId: string; - calls: { // original source order - toolIndex: number; // source ordering and planned-result lookup only - toolCall: AgentToolCall; - resultEntryId: string; // from plan, never tool_started - started?: ToolStartedRecord; - result?: MessageEntry; - terminate?: boolean; // persisted on the result entry - }[]; - genuineLength: boolean; // accepted output-limit length; no tool executes - unresolved: boolean; // at least one planned result is absent -} - -type NextRunRecord = - | (QueueEnqueuedRecord & { queue: "nextRun"; runId?: never }) - | (QueueCancelledRecord & { runId?: never }); - -interface LaneRecordSlices { - /** Empty when idle. When present, starts with this exact open operation and - contains only records matching its runId, in chronological order. */ - openOperation: OperationStartedRecord | null; - operationRecords: readonly LaneRecord[]; - /** Exclusive boundary used for the independent nextRun query. */ - newestRunStartSeq: number | null; - nextRunRecords: readonly NextRunRecord[]; -} - -interface LaneEntryPlan { - /** Unique exact ids for the one Session.getEntries() call. */ - ids: readonly string[]; - /** Subset provisioned by the open operation; chronology comes from entry seq. */ - operationEntryIds: ReadonlySet; - /** Targets from the independent nextRun slice. */ - nextRunEntryIds: ReadonlySet; -} - -interface LaneReductionInput { - lane: string; - leafId: string | null; - /** The indexed newest total replacement. Required after main's one-time - initialization and for every created format-4 lane. */ - laneConfig: LaneConfigRecord; - records: LaneRecordSlices; - plan: LaneEntryPlan; - /** Immutable exact lookup result. Missing planned ids are omitted. */ - entries: ReadonlyMap; -} - -interface LaneReductionResult { - laneState: LaneState; - /** Existing planned entries retained beside LaneState so ordinary re-entry - can skip already-completed appends without another storage read. Live - entry commits update this map together with LaneState. */ - plannedEntries: ReadonlyMap; - /** Non-null only when newestOwn is an error response or an unmarked - aborted response at its applicable captured cap, produced by an - assistant or deferred-fetch attempt; never for an arbitrary deferred - write or a structural step_failed record. */ - terminalFailure: TerminalFailureState | null; -} - -function buildLaneEntryPlan(records: LaneRecordSlices): LaneEntryPlan; -function reduceLaneState(input: LaneReductionInput): LaneReductionResult; -``` - -Four control-flow signals travel by exception inside a procedure; none escapes to a caller. `RunFailed` carries a terminal failure into the drain-and-finish path. `Park` unwinds when a deferred handle remains unredeemed after a pending or interrupted poll; the lane suspends. `Aborted` unwinds only after the durable abort marker wins. `Overflow` routes a durable, fully accounted recoverable response (section 6) into the compact-and-retry path. Any other rejection faults the harness. - -```ts -class RunFailed { constructor(readonly error: OperationError) {} } -class Park { constructor(readonly handle: DeferredHandle) {} } -class Aborted {} -class Overflow { - constructor(readonly responseEntryId: string, readonly triggerMessageId: string) {} -} - -const newId = (): string => runtime.idGenerator.next(); - -/** Re-entry-safe everywhere: use the reducer's planned-entry presence, never - a new storage lookup. fx.appendEntry installs the committed entry in - plannedEntries and LaneState before it resolves. Existing entries emit no - replayed events. Callers emit any process-local message lifecycle before - invoking this helper for a missing message. */ -async function appendIfMissing(target: ProvisionedEntry): Promise { - const existing = plannedEntries.get(target.id); - if (existing) return verifyProvisionedContent(existing, target); - await fx.appendEntry(target); -} - -async function appendMessageIfMissing(target: ProvisionedEntry): Promise { - if (plannedEntries.has(target.id)) return appendIfMissing(target); - emitImmediateMessageLifecycle(target.message, target.id); - await appendIfMissing(target); -} - -/** Synthetic assistant settlement follows the same public lifecycle and - entry-before-usage order as a provider result, but performs no provider - effect. settleAttemptResponse rechecks the abort race and emits entry_added - after commit. */ -async function settleSyntheticResponse( - attempt: Extract, - message: SettledAssistantMessage, -): Promise { - emitImmediateMessageLifecycle(message, attempt.responseEntryId); - const response = await fx.settleAttemptResponse(attempt, message); - await fx.appendRecord(preplannedUsageRecord(attempt, response.message)); - return response; -} -``` - -### Dispatch - -```ts -async function resume(): Promise { - await fx.runHook("before_resume", beforeResumeEvent(state)); // per registration id (section 11) - emit({ type: "run_resume", runId: op.id, recovery: true }); - try { - // tagResume re-tags an operation Result as a ResumeResult: Ok gains - // { operation }, Err passes through unchanged. Provider/fetch/tool helpers - // call requireIdentity immediately before only the effect they will run. - switch (op.kind) { - case "run": return tagResume("run", await runProcedure()); - case "compaction": return tagResume("compaction", await compactionProcedure()); - case "navigation": return tagResume("navigation", await navigationProcedure()); - } - } catch (error) { - if (MissingIdentities.is(error)) return Result.err(error); - throw error; - } -} - -async function runProcedure(): Promise { - try { - for (const m of [...op.missingInitialMessages]) await appendMessageIfMissing(m); // never dropped - await persistMissingResponseUsage(op.step); // entry-before-usage crash prefix - if (op.abort) return await abortPath(); - - if (op.step?.started.step === "deferred_fetch" && - stepNeedsClassification(op.step, state)) { - const source = sourceNamedByNewestFetchAttempt(op.step, state); - const redeemed = routeDeferredClassification( - classifyDurableDeferredResponse(op.step, state), source); - // No fetch occurs before this already-persisted response is accounted - // and classified. Response-step lookup below uses this deferred step's - // copied active tool names. - if (hasToolCalls(redeemed)) await runToolBatch(redeemed); - } else if (op.deferred) { - const redeemed = await redeemDeferred(); // may throw Park, RunFailed, Aborted - if (hasToolCalls(redeemed)) await runToolBatch(redeemed); // copied fetch-step active names - } - if (op.toolBatch?.unresolved) await reconcileToolBatch(op.toolBatch); - - // A crash mid-step resumes that exact step before new checkpoint input - // is consumed (section 7). Live retry and recovery consume identically. - if (op.step?.failure) throw new RunFailed(op.step.failure.error); - if (op.step?.started.step === "assistant" && stepNeedsClassification(op.step, state)) { - const outcome = await runTurn(); - if (outcome) return outcome; - } else if (op.step?.started.step === "compaction" && !op.step.resultExists) { - await autoCompact(op.step.started.compactionReason, - overflowLinkFrom(op.step.started)); // exact link is recorded at step scope - } else if (op.step?.started.step === "branch_summary" && !op.step.resultExists) { - throw new Error("Run has a branch-summary step"); // corruption - } - - if (newestOwnMessageIsTerminalFailure(state)) { // error or capped interruption (section 7) - return await handleRunFailed(existingFailure(state)); - } - return await driverLoop(); - } catch (e) { - return await handleRunSignal(e); - } -} - -async function handleRunSignal(e: unknown): Promise { - if (e instanceof Park) { - emit({ type: "run_suspend", runId: op.id, deferred: e.handle }); // exactly once per park - return suspended(e.handle); // unwind invocation; lane parked - } - if (e instanceof Aborted) return await abortPath(); - if (e instanceof RunFailed) return await handleRunFailed(e.error); - throw e; // storage/defect → faulted harness -} -``` - - -**Fixed-point test invariant.** After each durable boundary, suspension, and finish, focused/manual tests run section 7 reads and reduction and compare with live `LaneState`. Production updates state directly without rereading. - -### The loop - -```ts -async function driverLoop(): Promise { - while (true) { - // checkpoint — each consumption is a conditional mutation-line job - for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); - for (const m of steeringForThisCheckpoint(op)) await fx.consumeQueueItem(op.id, "steer", m.id); - if (op.abort) return await abortPath(); - if (await contextOverLimit()) { - const compacted = await autoCompact("threshold"); // may throw RunFailed - if (compacted) continue; // fresh checkpoint after a committed compaction - // A threshold hook declined, or there was nothing useful to compact. - // Threshold is proactive, so continue this checkpoint without looping. - } - - if (needsAssistant()) { - const outcome = await runTurn(); - if (outcome) return outcome; - continue; // fresh checkpoint - } - - for (const m of followUpsForThisCheckpoint(op)) await fx.consumeQueueItem(op.id, "followUp", m.id); - if (needsAssistant() || hasPendingWork()) continue; - - // finish boundary - const r = await fx.runHook("before_run_end", { runId: op.id, messages: runMessages() }); - if (r?.followUp) { - await fx.commitRunEndFollowUp(op.id, provisionUserMessage(newId(), r.followUp)); - } - if (hasPendingWork()) continue; - - const done = await fx.tryFinishRun(op.id, "completed"); - if (done === "finished") return finished("completed"); - // "continue": accepted input or abort won the ordering — loop - } -} - -async function runTurn(): Promise { - let assistant: AssistantMessage; - try { - assistant = await assistantStep(); // may throw Park, RunFailed, Aborted, Overflow - } catch (e) { - if (e instanceof Overflow) return await recoverOverflow(e); - throw e; - } - if (op.abort) return await abortPath(); - if (hasToolCalls(assistant)) await runToolBatch(assistant); // uses this generation step's captured active names - return undefined; -} - -async function recoverOverflow(overflow: Overflow): Promise { - if (op.abort) return await abortPath(); - if (op.overflowRecoveryUsed) { // a linked compaction already used this trigger - // The second recoverable response is already durable and accounted. - return await handleRunFailed(truncationError()); - } - await autoCompact("overflow", { - supersededResponseEntryId: overflow.responseEntryId, - triggerMessageId: overflow.triggerMessageId, - }); // decline or empty preparation → RunFailed - return undefined; // driverLoop loops; needsAssistant is still true -} - -async function handleRunFailed(error: OperationError): Promise { - try { - // Drain accepted input. No before_run_end, no further model work - // unless consumed conversational input restarts the loop. - while (true) { - for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); - let consumed = 0; - for (const m of steeringForThisCheckpoint(op)) { - if (await fx.consumeQueueItem(op.id, "steer", m.id) === "consumed") consumed++; - } - if (consumed === 0) { - for (const m of followUpsForThisCheckpoint(op)) { - if (await fx.consumeQueueItem(op.id, "followUp", m.id) === "consumed") consumed++; - } - } - if (op.abort) return await abortPath(); - if (consumed > 0) return await driverLoop(); // input clears the failure - const done = await fx.tryFinishRun(op.id, "failed", error); - if (done === "finished") return finished("failed", error); - } - } catch (e) { - return await handleRunSignal(e); - } -} -``` - -`needsAssistant()`: the newest own message is a user, steering, follow-up, or tool-result message — except a completed tool batch in which every result persisted `terminate: true`, which does not by itself force another turn (section 4). `hasPendingWork()`: pending writes, pending queue items, or `needsAssistant()`. - -### Steps - -Every settled assistant attempt appends exactly one complete response, including retryable errors, overflow responses, deferred handles, terminal errors, and aborted responses. The attempt's second provisioned object is its usage record. No general attempt-outcome or classification record exists. - -```ts -async function persistMissingResponseUsage(step: StepState | null): Promise { - const a = step?.newestAttempt; - if (a?.response && !a.usage) { - await fx.appendRecord(preplannedUsageRecord(a.record, a.response.message)); - } -} - -async function assistantStep(): Promise { - let step = continuableAssistantStep(op.step, state); - if (!step) { - const started = await fx.startStep({ - id: newId(), runId: op.id, step: "assistant", - triggerMessageId: newestConsumedUserContextId(state), - }); - if (started === "aborted") throw new Aborted(); - step = installedStep(started); - } - - let retryStartingHere: number | undefined; - while (true) { - if (op.abort) throw new Aborted(); - await persistMissingResponseUsage(step); - let current = step.newestAttempt; - - if (current?.response) { - const final = current.response.message; - const classification = classifyDurableAssistantResponse({ - response: final, - step: step.started, - attempt: current.record, - abortRequested: op.abort !== null, - laterTransitions: transitionsLinkedTo(current.record.responseEntryId, state), - }); - if (retryStartingHere === current.record.attempt) { - emitRetryEnd(current.record, classification); // existing success/finalError contract - retryStartingHere = undefined; - } - if (classification.kind === "advanced") { - throw new Error("assistantStep entered after its response transition"); // dispatch defect - } - if (classification.kind === "abort") { - if (!op.abort) throw new Error("abort classification without abort_requested"); - throw new Aborted(); - } - if (classification.kind === "overflow") { - throw new Overflow(current.record.responseEntryId, step.started.triggerMessageId); - } - if (classification.kind === "suspend") { - throw new Park(final.deferred); - } - if (classification.kind === "failure") { - throw new RunFailed(final.stopReason === "aborted" - ? providerInterruptionError(final) - : messageError(final)); - } - if (classification.kind !== "retry") { - return final; // accepted stop, toolUse, or genuine length - } - - const nextAttempt = current.record.attempt + 1; - emitRetryScheduled(step.started, nextAttempt, retryErrorMessage(final)); - const slept = await fx.sleep(retryDelay(step.started.retryPolicy, current.record.attempt)); - if (slept === "aborted" || op.abort) throw new Aborted(); - retryStartingHere = nextAttempt; - current = undefined; - } else if (current) { - // The recorded provider effect may have happened. Never reuse its id - // or repeat it under the same attempt number. - if (current.record.attempt >= step.started.retryPolicy.maxAttempts) { - const response = await settleSyntheticResponse( - current.record, interruptedAssistantMessage()); - if (op.abort) throw new Aborted(); - throw new RunFailed(messageError(response.message)); - } - const nextAttempt = current.record.attempt + 1; - emitRetryScheduled(step.started, nextAttempt, "provider outcome unknown after interruption"); - const slept = await fx.sleep(retryDelay(step.started.retryPolicy, current.record.attempt)); - if (slept === "aborted" || op.abort) throw new Aborted(); - retryStartingHere = nextAttempt; - current = undefined; - } - - if (!current) { - const attempt = step.attempts.length + 1; - const model = runtime.identities.requireModel(step.started.configuration.model); - const options = await fx.runHook("before_request", - { model, step: "assistant", attempt, streamOptions }); - const limits = assistantRequestLimits(step.started, model, options); - const record = stepAttempt({ - id: newId(), runId: op.id, stepId: step.started.id, step: "assistant", attempt, - responseEntryId: newId(), usageRecordId: newId(), - intendedOutputLimit: limits.intendedOutputLimit, - contextWindow: limits.contextWindow, - }); - const startedAttempt = await fx.startAttempt(record); - if (startedAttempt === "aborted" || op.abort) throw new Aborted(); - if (retryStartingHere === attempt) emitRetryStart(step.started, attempt); - - // fx.streamAssistant emits message_start/update*, runs after_response, - // and emits message_end. Only then may the response entry commit. - const streamed = await fx.streamAssistant( - assistantRequest(step.started, startedAttempt, model, options)); - const response = await fx.settleAttemptResponse(startedAttempt, streamed); // entry_added after commit - await fx.appendRecord(preplannedUsageRecord(startedAttempt, response.message)); - installedAttempt(startedAttempt, response.message); - // The next iteration performs the pure durable classifier. No hook, - // tool plan, retry, overflow step, suspension, or finish can precede it. - } - } -} -``` - -`continuableAssistantStep` returns an existing step only while its newest attempt needs settlement, usage repair, classification, or retry. After acceptance and a newer user or tool-result message, it returns `undefined`, so `startStep` snapshots a new trigger/config rather than reclassifying. Reduced records/entries, not a continuation flag, determine this. - -Retry lifecycle remains exact. A durable retryable response or unknown effect below cap emits `retry_scheduled`, then waits through `fx.sleep`; abort during delay emits nothing for the unstarted attempt. After intent commits, that attempt emits `retry_start`; response, usage, and classification precede `retry_end`. Marker-backed abort closes an active bracket. A retryable attempt ends unsuccessfully before scheduling the next. First-attempt success emits none. Resume may re-emit a lost schedule but never `retry_end` without this process's `retry_start`. - -`classifyDurableAssistantResponse` implements section 6 using only the response and attempt limits: context-error patterns, reported input/cache-read beyond the window, Xiaomi zero-output pressure, and recoverable `length`. Existing abort/linked transitions win; otherwise overflow precedes unmarked-`aborted` interruption and retryable error. At cap, unmarked `aborted` fails, never aborts. The classifier writes no metadata. Projection separately omits `error`, `aborted`, and `deferred`, retains genuine `length`, and relies on linked compaction for exact overflow omission. - -Generated request construction reads the `LaneConfiguration` and normalized `RetryPolicy` on `step_started`, never newer harness or lane values. `summaryStep(kind, reason, resultEntryId, overflowLink?)` accepts only a generated structural source whose start has the typed result id, captured configuration and policy, and compaction reason when applicable. An overflow compaction requires the link, persists both fields on that start, and builds summary preparation and retained-tail data with `overflowLink.supersededResponseEntryId` omitted; other structural steps reject a link. - -```ts -async function summaryStep( - kind: "compaction" | "branch_summary", - reason: "manual" | "threshold" | "overflow" | undefined, - resultEntryId: string, - overflowLink?: OverflowCompactionLink, -): Promise { - const step = requireGeneratedStructuralStep(op.step, kind, reason, - resultEntryId, overflowLink); - let retryPrepared = false; - - while (true) { - if (op.abort) throw new Aborted(); - const previous = step.newestAttempt; - if (previous && !retryPrepared) { - // The caller invokes summaryStep only while its result/prepared payload - // is absent. Re-entry with an attempt therefore means unknown work. - if (previous.record.attempt >= step.started.retryPolicy.maxAttempts) { - const failed = await fx.failStructuralStep(stepFailedFromUnknown(previous.record)); - if (failed === "aborted") throw new Aborted(); - throw new RunFailed(failed.error); - } - const next = previous.record.attempt + 1; - emitRetryScheduled(step.started, next, "structural provider outcome unknown after interruption"); - const slept = await fx.sleep(retryDelay(step.started.retryPolicy, previous.record.attempt)); - if (slept === "aborted" || op.abort) throw new Aborted(); - } - retryPrepared = false; - - const attempt = step.attempts.length + 1; - const record = stepAttempt({ - id: newId(), type: "step_attempt", runId: op.id, - stepId: step.started.id, step: kind, attempt, - }); - const started = await fx.startAttempt(record); - if (started === "aborted" || op.abort) throw new Aborted(); - if (attempt > 1) emitRetryStart(step.started, attempt); - - // Split-turn compaction may invoke request() twice. Each invocation runs - // before_request through fx, crosses one fx.streamAssistant action with a - // private sink, and writes reported usage before request() resolves. - const outcome = await runGeneratedSummaryAttempt(kind, preparationFor(step), { - request: async (messages) => { - const model = runtime.identities.requireModel(step.started.configuration.model); - const options = await fx.runHook("before_request", - { model, step: kind, attempt, streamOptions: structuralStreamOptions() }); - const response = await fx.streamAssistant(structuralRequest( - messages, step.started, started, model, options, privateEventSink)); - if (hasReportedUsage(response)) { - await fx.appendRecord(structuralUsageRecord( - op.id, step.started.id, resultEntryId, attempt, kind, response)); - } - return response; - }, - }); - if (attempt > 1) emitRetryEnd(started, - op.abort ? { kind: "abort" } : outcome); - if (op.abort) throw new Aborted(); // usage above remains billed - - if (outcome.kind === "success") { - return outcome.result; // durable commit is the caller's next action - } - if (outcome.kind === "retry" && attempt < step.started.retryPolicy.maxAttempts) { - emitRetryScheduled(step.started, attempt + 1, outcome.error.message); - const slept = await fx.sleep(retryDelay(step.started.retryPolicy, attempt)); - if (slept === "aborted" || op.abort) throw new Aborted(); - retryPrepared = true; - continue; - } - - const failed = await fx.failStructuralStep(stepFailedFromOutcome(started, outcome)); - if (failed === "aborted") throw new Aborted(); - throw new RunFailed(failed.error); - } -} -``` - -Structural `step_attempt` precedes its first request. An attempt may make one or two non-deferred requests; each crosses `fx.streamAssistant` and immediately writes reported usage. Its private sink emits no public assistant lifecycle. A crash before the result boundary makes the whole attempt unknown and advances only under captured policy. - -Generated compaction immediately tries to commit its in-memory result under `step_started.resultEntryId`; no prepared record exists. Generated branch summary first persists its complete provisioned entry, `fromHook: false`, and successful attempt number via `fx.prepareBranchSummary`. After that record, navigation may move and no provider request repeats. - -At cap or terminal failure, `failStructuralStep` conditionally appends `step_failed` and throws `RunFailed`; if abort wins before that conditional append, it returns `"aborted"` and writes no failure. Assistant errors never occupy structural result ids. For hook output, the harness builds the complete typed entry with `fromHook: true`, preparation, details, and immutable usage, then stores it on `step_started` with a usage id exactly when needed. Re-entry repairs that `cause: "hook"` record before entry commit and never reruns the hook. Overflow hook starts also carry the exact response/trigger link and consume the same allowance. - -### Deferred redemption - -```ts -function routeDeferredClassification( - classification: DeferredResponseClassification, - source: MessageEntry, -): SettledAssistantMessage { - if (classification.kind === "abort") throw new Aborted(); - if (classification.kind === "pending") throw new Park(classification.handle); - if (classification.kind === "interrupted") { - throw new Park(source.message.deferred!); // exact source remains unredeemed - } - if (classification.kind === "failure") throw new RunFailed(classification.error); - if (classification.kind === "advanced") { - throw new Error("deferred response already has a later transition"); - } - return classification.message; // ready -} - -async function redeemDeferred(): Promise { - let step = op.step?.started.step === "deferred_fetch" ? op.step : undefined; - if (!step) { - // This bounded reduced-state lookup happens only while creating F. Once - // step_started commits, every later process reads F's copies. - const original = generationStepForInitialDeferredResponse(state); - const started = await fx.startStep({ - id: newId(), runId: op.id, step: "deferred_fetch", - configuration: copyLaneConfiguration(original.configuration), - retryPolicy: copyRetryPolicy(original.retryPolicy), - }); - if (started === "aborted") throw new Aborted(); - step = installedStep(started); - } - - await persistMissingResponseUsage(step); - const source = newestDeferredSourceEntry(state); - const sourceAttempts = attemptsForDeferredSource(step, source.id); - const prior = sourceAttempts.at(-1); - if (prior && !prior.response && sourceAttempts.length >= step.started.retryPolicy.maxAttempts) { - const response = await settleSyntheticResponse( - prior.record, interruptedDeferredMessage(source)); - if (op.abort) throw new Aborted(); - throw new RunFailed(providerInterruptionError(response.message)); - } - if (prior && (!prior.response || - (prior.response.message.stopReason === "aborted" && !op.abort))) { - const slept = await fx.sleep(retryDelay(step.started.retryPolicy, sourceAttempts.length)); - if (slept === "aborted" || op.abort) throw new Aborted(); - } - - const attempt = step.attempts.length + 1; // never reuse an unknown poll - const record = stepAttempt({ - id: newId(), runId: op.id, stepId: step.started.id, - step: "deferred_fetch", attempt, sourceEntryId: source.id, - responseEntryId: newId(), usageRecordId: newId(), - }); - const startedAttempt = await fx.startAttempt(record); - if (startedAttempt === "aborted" || op.abort) throw new Aborted(); - - // The exact source supplies identity. fx.fetchDeferred performs one wait:0 - // check, runs response hooks, and emits message_end before returning. - const fetched = await fx.fetchDeferred(source, { wait: 0 }); - const response = await fx.settleAttemptResponse(startedAttempt, fetched); - await fx.appendRecord(preplannedUsageRecord(startedAttempt, response.message)); - const settledStep = installedAttempt(startedAttempt, response.message); - return routeDeferredClassification( - classifyDurableDeferredResponse(settledStep, state), source); -} -``` - -`classifyDurableDeferredResponse` uses the configuration and policy copied onto F and the exact source lineage. It performs the same complete-handle check for a durable pending response before making that response the new source. A below-cap unmarked `aborted` response is itself the durable suspension transition but retains its source; a capped one is terminal failure. Neither becomes operation abort without the marker. - -One `resume()` makes at most one `wait: 0` fetch. Pending appends response/usage and re-parks on the new entry, even with an equal handle. Unmarked interruption also persists but retains its source; a later resume backs off and may retry below the per-source cap. Neither emits retry events. Ready uses F's active tools. Returned/rejection-converted terminal errors enter normal failure drain. An unknown poll effect uses a later attempt below cap or synthetic interruption under the missing attempt's planned response id at cap; marker-backed abort instead writes synthetic `aborted` there and never fetches. - -### Tools - -Live execution commits the full plan before `executeToolBatch`; the batch is never one gated action. Provider `toolCallId` values are response-unique. Callbacks retain the source call object so private lookup finds its planned result without exposing an index. Ordinary batches resolve only active names captured by the response's assistant step or copied fetch step. Genuine `length` resolves no tools. Every callback, hook, and phase-two call crosses `fx`: - -```ts -async function runToolBatch(assistant: AssistantMessage, telemetryContext: TelemetryContext): Promise { - const assistantEntryId = newestAssistantEntryId(state); - let batch = op.toolBatch?.assistantEntryId === assistantEntryId ? op.toolBatch : undefined; - if (!batch) { - const sourceCalls = toolCallsWithSourceIndexes(assistant); - const plan = await fx.startToolBatch({ - id: newId(), type: "tool_batch_started", runId: op.id, assistantEntryId, - calls: sourceCalls.map(({ toolIndex }) => ({ toolIndex, resultEntryId: newId() })), - }, telemetryContext); - if (plan === "aborted") throw new Aborted(); - batch = installedToolBatch(plan, assistant); // exact source-index mapping - } - - const plannedCallFor = (call: AgentToolCall) => - batch!.calls.find((item) => item.toolCall === call)!; - - const tools = assistant.stopReason === "length" - ? [] - : runtime.identities.requireToolsForResponseStep(assistant, state); - await executeToolBatch(assistant, tools, { - beforeToolCall: async (call, args) => { - return await fx.runHook("before_tool", - { toolCallId: call.id, toolName: call.name, args }, telemetryContext); - }, - onToolStart: async (call, effectiveArgs) => { - const planned = plannedCallFor(call); // id stays on the plan - const started = await fx.startTool(toolStarted(op.id, { - assistantEntryId, toolIndex: planned.toolIndex, - toolCallId: call.id, toolName: call.name, - effectiveArgs, replay: declaredReplay(call), - }), telemetryContext); - if (started === "aborted") throw new Aborted(); - }, - executeTool: (prepared) => { - if (op.abort) throw new Aborted(); - return fx.executeTool(prepared, telemetryContext); - }, - afterToolCall: (call, args, result, isError) => - fx.runHook("after_tool", - { toolCallId: call.id, toolName: call.name, args, ...result, isError }, - telemetryContext), - onToolResult: async (call, message, terminate) => { - const planned = plannedCallFor(call); // blocked/invalid use this too - if (message.usage) { - await fx.appendRecord(toolUsageRecord(op.id, planned.resultEntryId, - call.id, message.usage), telemetryContext); - } - await appendIfMissing(resultEntry(planned.resultEntryId, message, terminate)); - }, - }, { toolExecution: config.toolExecution }, emitLaneEvents, telemetryContext, abortSignal); -} -``` - -Ordinary re-entry invokes `reconcileToolBatch` for missing results; no recovery dispatcher exists. In source order, `runPlannedToolCall` composes clearance, start record, effect, finalization, optional usage, immediate message lifecycle, and planned append without allocating ids. Real results show only that execution's usage; synthetics show none and prior usage stays ledger-only. Unstarted calls rerun clearance; started calls never do: - -```ts -async function appendReconciledToolResult(target: ProvisionedEntry): Promise { - if (plannedEntries.has(target.id)) return appendIfMissing(target); - emitImmediateMessageLifecycle(target.message, target.id); - await appendIfMissing(target); -} - -async function reconcileToolBatch(batch: ToolBatchState, - telemetryContext: TelemetryContext): Promise { - if (op.abort) throw new Aborted(); // abortPath owns this state - if (batch.genuineLength) { // accepted length: never execute - for (const call of batch.calls) { - if (!call.result) { - await appendReconciledToolResult(incompleteArgumentsToolResult( - call.resultEntryId, call.toolCall)); - } - } - return; // planned errors force another turn - } - - for (const call of batch.calls) { - if (call.result) continue; - - if (!call.started) { // X2/X3: full clearance path - await runPlannedToolCall(call, telemetryContext); // all hooks/effects/writes use fx - continue; // same plan; no fresh result id - } - - // Missing current implementation means no safe replay and needs no - // identity error because the synthetic path performs no tool effect. - const currentReplay = runtime.identities.replayDeclaration(call.started.toolName); - if (call.started.replay === "safe" && currentReplay === "safe") { - const prepared = { kind: "prepared", toolCall: call.toolCall, - tool: runtime.identities.requireTool(call.started.toolName), - args: call.started.effectiveArgs }; // persisted, not re-derived - const executed = await fx.executeTool(prepared, telemetryContext); - const finalized = await finalizeToolCall(prepared, executed, - { afterToolCall: fxWiredAfterTool(telemetryContext) }, - telemetryContext, abortSignal); - if (finalized.result.usage) { - await fx.appendRecord(toolUsageRecord(op.id, call.resultEntryId, - call.toolCall.id, finalized.result.usage), telemetryContext); // replay's own cost - } - await appendReconciledToolResult(resultEntry(call.resultEntryId, - createToolResultMessage(finalized), finalized.result.terminate === true)); - } else { - await appendReconciledToolResult( - syntheticResult(call.resultEntryId, "interrupted")); - } - } -} -``` - -### Abort - -`abort()` itself is the idempotent lane-surface job described above: first marker, stable queue drain, one signal, resolve. Reconciliation is procedure work. A running procedure reaches it after any already-started in-process provider/tool settlement has passed through its conditional append/finalization. If the operation was suspended with no procedure running, the first or repeated `abort()` starts or joins one abort path; manual mode leaves it parked at its first action. - -```ts -async function settleAbortedProviderAttempt(): Promise { - const step = op.step; - const current = step?.newestAttempt; - if (!current || (step.started.step !== "assistant" && - step.started.step !== "deferred_fetch")) return; - - if (current.response) { - if (!current.usage) { - await fx.appendRecord(preplannedUsageRecord(current.record, current.response.message)); - } - emitRetryEndIfActive(current.record, { kind: "abort" }); - return; // preserve its committed stop reason - } - - // The prior provider effect is unknown. Abort forbids a later attempt. - const synthetic = syntheticAbortedMessage(step.started, current.record); // zero usage; - // identity comes from durable data - await settleSyntheticResponse(current.record, synthetic); - emitRetryEndIfActive(current.record, { kind: "abort" }); -} - -async function abortPath(): Promise { - await settleAbortedProviderAttempt(); - if (op.deferred) { - const source = newestDeferredSourceEntry(state); - await bestEffortCancelDeferred(source); // internally calls fx.cancelDeferred when resolvable; - // failures are telemetry only - } - - while (true) { - // Live started effects have already finalized. A missing started result is - // therefore an unknown crashed effect and is never replayed after abort. - for (const call of op.toolBatch?.calls ?? []) { - if (call.result) continue; - await appendReconciledToolResult(syntheticResult( - call.resultEntryId, call.started ? "interrupted" : "aborted")); - } - for (const w of [...op.pendingWrites]) await fx.applyPendingWrite(op.id, w.id); - - const done = await fx.finishOperation(op.id, "aborted"); - if (done === "finished") return finished("aborted"); // optional final assistant fields - // "continue": a deferred write arrived meanwhile — apply it before the terminal record - } -} -``` - -Neither helper resolves model/tool implementations. Synthetic assistants use captured model references; tool results use stored calls/ids. Best-effort deferred cancellation runs only when resolvable and suppresses expected provider failure after telemetry; close/fault still unwind. Abort never calls `newId()` for an assistant response. - -### Close - -Close is process lifecycle, not abort: it writes no marker/finish and never runs `abortPath()`. - -```ts -async function closeHarness(): Promise { - closeAdmission(); // all new public calls now observe Closed - signalRunningProviderAndToolEffects(); // process-local signal only; no durable marker - gatedEffects.rejectAllParked(HarnessClosed); // includes nested and pre-acceptance actions - rejectLocalOperationPromises(HarnessClosed); - - await laneMutationLines.settleAdmittedJobs(); // an append already entered may finish; - // no procedure may enqueue its next effect - await ownedSession.close(); // drains Session/storage queues, stops renewal, - // releases only this owner/fence claim -} -``` - -A close-signalled provider/tool cannot append after admission closes; prior intent remains the crash prefix. Already-entered Session appends and live-state updates settle before Session close. Manual close rejects unreleased actions without running them. Reopen reduces the prefix and ordinary resume continues; no shutdown record/procedure exists. - -### Structural operations - -```ts -async function persistHookStructuralUsage(step: StepState | null): Promise { - if (!step || step.started.step === "assistant" || - step.started.step === "deferred_fetch" || - step.started.source !== "hook" || step.hookUsageExists) return; - const { hookResult, hookUsageRecordId } = step.started; - if (hookResult.usage) { - await fx.appendRecord(hookUsageRecord( - hookUsageRecordId!, op.id, hookResult.id, hookResult.usage)); - } -} - -async function compactionProcedure(): Promise { - try { - await persistHookStructuralUsage(op.step); - if (op.abort && !op.targets.result) return await abortStructural(); - if (op.step?.failure) throw new RunFailed(op.step.failure.error); - - let step = op.step; - if (!step && !op.targets.result) { - const prep = preparation(state); - const hook = await fx.runHook("before_compaction", { - reason: "manual", preparation: prep, - customInstructions: op.intent.customInstructions, - }); - if (hook?.decline) return await finishStructural("declined"); - const start = hook?.compaction - ? hookStructuralStart("compaction", op.intent.resultEntryId, - compactionEntry(op.intent.resultEntryId, hook.compaction, true, - { preparation: prep })) - : generatedStructuralStart("compaction", op.intent.resultEntryId); - const started = await fx.startStep({ - id: newId(), runId: op.id, compactionReason: "manual", ...start, - }); - if (started === "aborted") return await abortStructural(); - step = installedStep(started); - await persistHookStructuralUsage(step); - } - - if (!op.targets.result) { - const entry = step!.started.source === "hook" - ? step!.started.hookResult - : compactionEntry(op.intent.resultEntryId, - await summaryStep("compaction", "manual", op.intent.resultEntryId), false); - const commit = await fx.commitStructuralEntry(entry); - if (commit === "aborted") return await abortStructural(); - } - return await finishStructural("completed"); - } catch (e) { return await handleStructuralSignal(e); } -} - -interface OverflowCompactionLink { - supersededResponseEntryId: string; - triggerMessageId: string; -} - -/** Inside a run, at a checkpoint or after an overflow response. Same hook, - source records, durable attempts, and cap as manual compaction; no nested - operation records. Overflow always carries the exact response and trigger. - A decline or empty preparation throws RunFailed because the request cannot - fit without compaction. */ -async function autoCompact( - reason: "threshold" | "overflow", - requestedLink?: OverflowCompactionLink, -): Promise { - let step = op.step?.started.step === "compaction" ? op.step : undefined; - const resultEntryId = step?.started.resultEntryId ?? newId(); - const link = step ? overflowLinkFrom(step.started) : requestedLink; - requireLinkExactlyForOverflow(reason, link); - - if (!step) { - const prep = preparation(state, - link ? { omitEntryId: link.supersededResponseEntryId } : undefined); - if (nothingToCompact(prep)) { - if (reason === "overflow") throw new RunFailed(truncationError()); - return false; - } - const hook = await fx.runHook("before_compaction", { reason, preparation: prep }); - if (hook?.decline) { - if (reason === "overflow") throw new RunFailed(truncationError()); - return false; - } - const start = hook?.compaction - ? hookStructuralStart("compaction", resultEntryId, - compactionEntry(resultEntryId, hook.compaction, true, { preparation: prep })) - : generatedStructuralStart("compaction", resultEntryId); - const started = await fx.startStep({ - id: newId(), runId: op.id, compactionReason: reason, ...link, ...start, - }); - if (started === "aborted") throw new Aborted(); - step = installedStep(started); - } - - await persistHookStructuralUsage(step); - if (op.abort) throw new Aborted(); - const entry = step.started.source === "hook" - ? step.started.hookResult - : compactionEntry(resultEntryId, - await summaryStep("compaction", reason, resultEntryId, link), false); - const commit = await fx.commitStructuralEntry(entry); - if (commit === "aborted") throw new Aborted(); - return true; -} - -async function navigationProcedure(): Promise { - try { - let moved = navigationMoveCommitted(state); // target or summary-entry leaf - await persistHookStructuralUsage(op.step); - if (op.abort && !moved) return await abortStructural(); - if (op.step?.failure) throw new RunFailed(op.step.failure.error); - - let step = op.step; - if (op.intent.summarize && !step) { - if (moved) throw new Error("Navigation moved without a durable summary payload"); - const prep = navigationPreparation(op.sourceLeafId, op.intent.targetId); - const hook = await fx.runHook("before_navigation", { - targetId: op.intent.targetId, - preparation: prep, - customInstructions: op.intent.customInstructions, - }); - if (hook?.decline) return await finishStructural("declined"); - const start = hook?.summary - ? hookStructuralStart("branch_summary", op.intent.summaryEntryId!, - branchSummaryEntry(op.intent.summaryEntryId!, hook.summary, true, prep)) - : generatedStructuralStart("branch_summary", op.intent.summaryEntryId!); - const started = await fx.startStep({ - id: newId(), runId: op.id, ...start, - }); - if (started === "aborted") return await abortStructural(); - step = installedStep(started); - await persistHookStructuralUsage(step); - } - - if (op.intent.summarize && !structuralPayload(step!)) { - const generated = await summaryStep( - "branch_summary", undefined, op.intent.summaryEntryId!); - const prepared = await fx.prepareBranchSummary(branchSummaryPrepared({ - id: newId(), runId: op.id, stepId: step!.started.id, - attempt: step!.attempts.at(-1)!.attempt, - result: branchSummaryEntry( - op.intent.summaryEntryId!, generated, false, - navigationPreparation(op.sourceLeafId, op.intent.targetId)), - })); - if (prepared === "aborted") return await abortStructural(); - } - - if (!moved) { - const commit = await fx.commitNavigationMove(op.intent.targetId); - if (commit === "aborted") return await abortStructural(); - moved = true; - } - if (op.intent.summarize && !op.targets.summary) { - await appendIfMissing(structuralPayload(op.step!)!); // exact hook/prepared payload - } - // finishOperation atomically appends the accepted label fact with the - // terminal record when this completed navigation is labeled. - return await finishStructural("completed"); - } catch (e) { return await handleStructuralSignal(e); } -} - -async function finishStructural(outcome: "completed" | "declined") { - const done = await fx.finishOperation(op.id, outcome); - if (done === "continue") return await abortStructural(); // abort won before commit - return structuralOutcome(outcome); // committed structure wins later abort -} - -async function abortStructural() { - // Called only before the compaction-entry/navigation-move commit. Persisted - // hook usage is accounting, but no structural result or assistant entry is added. - await persistHookStructuralUsage(op.step); - emitRetryEndIfActive(op.step?.newestAttempt?.record, { kind: "abort" }); - const done = await fx.finishOperation(op.id, "aborted"); - if (done !== "finished") throw new Error("aborted structural operation did not finish"); - return structuralOutcome("aborted"); -} - -async function handleStructuralSignal(e: unknown) { - if (e instanceof Aborted) return await abortStructural(); - if (e instanceof RunFailed) { - const done = await fx.finishOperation(op.id, "failed", e.error); - return done === "continue" ? await abortStructural() : structuralOutcome("failed", e.error); - } - throw e; -} -``` - -Hook wiring: - -| harness hook | insertion point | -|---|---| -| `before_run` | pre-acceptance lane call; output is consumed by the ungated acceptance job | -| `before_resume` | `resume()` dispatch, before any other procedure effect | -| `before_run_end` | `driverLoop` finish boundary; result committed via `fx.commitRunEndFollowUp` | -| `transform_context` | nested inside `fx.streamAssistant` (`StreamAssistantConfig.transformContext`) | -| `before_request` | before each `fx.streamAssistant`, patches stream options | -| `before_payload` | nested inside the stream function, provider level | -| `after_response` | nested on the stream result, before `message_end` and the later entry append | -| `before_tool` | `ToolCallbacks.beforeToolCall` (phase 1) | -| `after_tool` | `ToolCallbacks.afterToolCall` (phase 3) | -| `before_compaction` | manual/threshold/overflow decision before conditional `step_started` | -| `before_navigation` | summarized-navigation decision before conditional `step_started` | -| — (record/entry writes) | `ToolCallbacks.onToolStart` / `onToolResult` via `fx` | - -Notes: - -- Auto-compaction uses the run's records, not a nested operation. -- No program counter marks mid-step crashes. An assistant response missing without abort advances attempts or gets synthetic interruption at cap; with abort it gets synthetic `aborted` under the same planned response id. Missing generated structural results advance or end in `step_failed` unless pre-commit abort wins. Hook decisions never repeat after their start. -- Plans precede phase 1; real calls get source-ordered starts before individual dispatch. Crashes may leave starts beyond unresolved calls, but committed results form a source-order prefix. Section 6 reduces each index independently. -- Every `aborted` assistant skips tools. A marker routes missing planned results through `abortPath()` without new assistant ids; without one, classification retries or fails at cap. -- Abort before structural commit suppresses it and finishes aborted; abort after commit completes remaining writes as completed. -- After a summarized navigation move, recovery appends the exact hook/prepared summary and never invokes hook/provider. - -## 16. pi-ai: deferred requests - -These pi-ai deferred/authenticated Models APIs are already landed. H8 only integrates them; it adds no pi-ai work. - -Everything is per-request; batch APIs can implement the same shape through a custom provider. - -```ts -// Request. Providers map this to their native mechanism, e.g. -// background: true on a Responses API, or a batch submission. -interface SimpleStreamOptions extends StreamOptions { - deferred?: boolean | { window?: "15m" | "1h" | "24h" }; - // ... other options -} - -// Response. A deferred request resolves quickly with a handle instead of -// content. The message is persisted like any assistant message; the handle -// is the durable fact recovery needs. -type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred"; -// Agent-side settled-result narrowings. -type TerminalStopReason = Exclude; -type SettledAssistantMessage = AssistantMessage & { stopReason: TerminalStopReason }; - -interface DeferredHandle { - provider: string; - modelId: string; - api: string; - id: string; // provider token: response id, batch id + row - expiresAt?: number; // Unix ms - pollAfterMs?: number; // provider hint - data?: JsonValue; // provider conversion data -} - -interface AssistantMessage { - // ... other fields - stopReason: StopReason; - deferred?: DeferredHandle; // present iff stopReason === "deferred" -} - -// Authenticated HTTP request plumbing shared by stream, image, and deferred -// provider operations. Generation and streaming-transport controls are not -// part of this interface. -interface ProviderRequestOptions> { - signal?: AbortSignal; - /** Explicit parent for this logical pi-ai operation. Inherited by stream, - simple-stream, deferred fetch/cancel, and image options. */ - telemetryContext?: TelemetryContext; - apiKey?: string; - fetch?: FetchFunction; - env?: ProviderEnv; - onPayload?: (payload: unknown, model: TModel) => - unknown | undefined | Promise; - onResponse?: (response: ProviderResponse, model: TModel) => void | Promise; - headers?: ProviderHeaders; - timeoutMs?: number; - maxRetries?: number; - maxRetryDelayMs?: number; -} - -interface DeferredFetchOptions extends ProviderRequestOptions> { - /** Maximum provider long-poll duration. Omitted or zero checks once. */ - wait?: number; -} - -type DeferredCancelOptions = ProviderRequestOptions>; - -// Redemption lives on the provider. The two methods are optional: their -// presence is the capability signal. A provider without them never returns -// stopReason "deferred" and ignores the deferred request option. -export interface ProviderStreams { - stream(model: Model, context: Context, options?: StreamOptions): AssistantMessageEventStream; - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; - - /** Redeem a handle. Same return type as streamSimple; downstream code is - identical. Polls or re-attaches until terminal, then emits the normal - events and final message. Resolution states, all in-band: - - ready: normal message (stop | toolUse | length) - - still pending: stopReason "deferred" with the same handle (after - `wait` expires; wait: 0 checks once) - - terminal: stopReason "error" (expired, unknown, consumed) */ - fetchDeferred?(model: Model, handle: DeferredHandle, - options?: DeferredFetchOptions): AssistantMessageEventStream; - - /** Best effort; providers without cancellation omit it. */ - cancelDeferred?(model: Model, handle: DeferredHandle, - options?: DeferredCancelOptions): Promise; -} -``` - -All stream, deferred, and image options inherit `ProviderRequestOptions.telemetryContext`; providers, Models, ImagesModels, direct dispatch, and `buildBaseOptions()` preserve it unchanged. - -`pending` exists only in mutable live streams. Wrapper results and harness entries use `SettledAssistantMessage`; durable usage records and settled `pi.ai.request` spans cannot contain `pending`. Telemetry spells `toolUse` as `tool_use`. - -The harness uses the authenticated `Models` dispatch surface rather than talking to a provider object directly: - -```ts -type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms; -type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms; - -interface Models { - // other methods - fetchDeferred(model: Model, handle: DeferredHandle, - options?: ModelsDeferredFetchOptions): Promise; - cancelDeferred(model: Model, handle: DeferredHandle, - options?: ModelsDeferredCancelOptions): Promise; -} -``` - -Models deferred methods use normal model resolution/authentication and preserve HTTP settings, callbacks, transforms, and fetch wait. Providers returning `deferred` must implement fetch; cancel is optional. Harness redemption always uses `wait: 0`; pending re-parks for an application-scheduled resume, optionally using `pollAfterMs`. - -A terminal fetch appends an error and fails without replacement generation; rejected fetches convert to the same in-band error message. Unmarked returned `aborted` re-parks on its source below the copied cap, allowing one later poll per resume. - -For another `deferred` response, **complete handle equality** requires equal `provider`, `modelId`, `api`, and `id`; equal presence/value of `expiresAt` and `pollAfterMs`; and equal presence plus JSON-deep equality of `data` (object order ignored, array order preserved). Persist/account first, then check during classification. Unmarked mismatch is a durable defect; handle rotation is unsupported. Equal handles still create distinct entries, newest becoming the next source. - -Deferred assistant messages carry a handle, not content. Session context projection omits them from provider context; durable suspension and redemption use the persisted handle. - -Adapters normalize stop reasons and guarantee response-local unique `toolCallId`; core adds no duplicate handling. OpenAI Responses maps `max_output_tokens` incomplete details to `length` and `content_filter` to non-retryable `error`. Adapters may retain `rawStopReason`; core ignores it. - -## 17. Forks and subagents - -Repository `fork` captures one immutable snapshot of selected committed entries, latest facts, lane pointers, and total configs. Memory/JSONL use one source-queue job; SQLite uses one read transaction. Later writes are absent from every category, preventing mixed-time copies. - -```ts -type ForkOptions = - | { scope?: "branch"; entryId?: string; position?: "before" | "at" } // one path, root to fork point - | { scope: "tree" }; // all entries, every branch - -repo.fork(source, options & { id?, parentSessionId? }): Promise; -repo.create({ id?, parentSessionId? }): Promise; -``` - -- Copy conversation entries without source lanes, including non-projecting responses, but no orchestration/classification/usage records. A copied compaction's self-contained tail preserves exact overflow omission without its link. A fork before it uses entry-local projection: omit `error`/`aborted`/`deferred`, retain `stop`/general `length`. The fork is idle with zero token/cost ledger, visible entry usage snapshots, and `messageCount` from copied messages. -- `scope: "branch"` creates only `main` at the fork point with a fresh config equal to snapshot source `main`. `scope: "tree"` copies every lane name/leaf and gives each its snapshot config. Atomic configured-lane creation writes new destination records, never source history or anchor-derived config. No other lane records copy, so all lanes are idle. -- Tree scope copies current name, labels, and custom facts. Branch scope copies name/custom facts and labels only for copied targets. Deletions stay absent; custom JSON null remains. Destination writes fresh history; facts have no ids. -- Any message may be the fork point. A mid-tool-batch tip remains promptable because pi-ai inserts empty results for orphaned calls at request build. -- Forking leaves the source untouched and copies only its coherent committed prefix, including committed open-operation entries but not its records or promised output. -- Linkage is `parentSessionId`, set by `fork()` and settable on `create()` — the basis for subagent parent/child tracking and export bundles. -- **Non-normative example.** Harness has no subagent tool. An application may derive a child session id from parent id plus provider `toolCallId`, so safe replay reopens rather than duplicates it. Core does not depend on this. -- Policy, restated from Part I: a platform thread that shares history with its channel is a lane; a fork is for isolation — subagents, exports, clones. A subagent can also run on a lane of its parent's session when isolation is not wanted. - -## 18. Telemetry - -Telemetry passes context explicitly; core uses no `AsyncLocalStorage`, global current span, or runtime-specific context. Adapters may activate ambient context internally, for example for OTel HTTP instrumentation, but pi always supplies the parent. - -Pi ships no exporter. `InMemoryTelemetryContext` is the deterministic reference; applications may use it or bridge `TelemetryContext` to another backend. Adapters own backend ids/native contexts and must obey the callback contract; core carries no trace ids. - -### Package ownership - -`@earendil-works/pi-telemetry` owns/exports the generic contract, schema machinery, no-op, and memory reference; `/testing` exports runner-independent conformance. Pi-ai imports only `TelemetryContext` for options and emits no spans. Agent `harness/telemetry.ts` owns AI/harness schemas and starters plus their readonly composition tuple. Agent root re-exports them and the generic surface: one generic contract, one domain-schema owner. - -`AgentHarnessOptions.telemetryContext` defaults to the no-op context, and the agent-side request wrapper emits `pi.ai.request` through the agent-owned AI schema. - -Schemas use pi-owned `pi.ai.*`, `pi.harness.*`, `pi.session.*`, and `pi.*` attributes, not external semantic conventions. Adapters may translate; emitted vocabulary stays stable. - -### Context contract - -```ts -type AttributeValue = - | string - | number - | boolean - | readonly string[] - | readonly number[] - | readonly boolean[]; - -interface SpanAttributes { - [name: string]: AttributeValue | undefined; -} - -interface SpanOptions { - name: string; - attributes?: SpanAttributes; -} - -type SpanStatus = - | { status: "ok" } - | { status: "error"; error?: { name: string; message: string } }; - -interface TelemetryContext { - startSpan( - options: SpanOptions, - callback: (span: TelemetrySpan) => T | Promise, - ): Promise; -} - -interface TelemetrySpan extends TelemetryContext { - addEvent(name: string, attributes?: SpanAttributes): void; - setAttributes(attributes: SpanAttributes): void; - setStatus(status: SpanStatus): void; -} -``` - -Telemetry exports shared no-op and memory contexts; harness and compatibility wrapper default to no-op. `startSpan()` creates a child and invokes its callback synchronously exactly once before returning a promise, keeping the span open until settlement: - -- return or resolve: default status `ok`, then automatic end; -- synchronous throw: return a promise rejected with the same thrown value, after automatic error status and end; -- asynchronous rejection: automatic error status and end, then rejection with the same value; -- expected failure represented by a value: the callback calls `setStatus({ status: "error", ... })` before returning; -- repeated `setStatus()` calls are last-write-wins; automatic completion never overwrites an explicit status; -- `setAttributes()` merges keys; a later defined value overwrites an earlier one and `undefined` is ignored; -- calls on a settled span are inert and never throw. - -Adapters preserve callback results/errors. Recording is synchronous, passive, and nonthrowing; exporters buffer asynchronously. Native telemetry failure is suppressed atomically with no-op behavior while business callback still runs once. Nonconformance is an application defect. No-op uses one shared inert span, allocates nothing per span, and neither inspects nor retains attributes. Applications flush real adapters. - -Harness passes context explicitly to every effectful boundary; core never looks it up: - -```ts -streamAssistant(messages, configWithTelemetryContext, emit); -prepareToolCall(call, tools, callbacks, telemetryContext, signal); -executeToolCall(prepared, emit, telemetryContext, signal); -finalizeToolCall(prepared, executed, callbacks, telemetryContext, signal); -fx.appendEntry(entry, telemetryContext); -fx.runHook(name, event, telemetryContext); -``` - -A `TelemetrySpan` is also a child `TelemetryContext`. Passing it down creates nesting through normal calls. Typed starters automate this handoff without ambient state. Every `Effects` method receives its parent; parallel tools get separate child contexts. - -### Typed schema - -The low-level adapter accepts the open `SpanAttributes` bag. Pi instrumentation never constructs untyped span names or attribute bags directly. The agent package exports the two plain, serializable domain schema objects and their typed helpers for that purpose. - -```ts -type TelemetryAttributeType = - | "string" - | "number" - | "boolean" - | "string[]" - | "number[]" - | "boolean[]"; - -interface TelemetryAttributeMetadata { - description: string; - sensitive?: boolean; - cardinality?: "low" | "high"; -} - -type TelemetryAttributeDefinition = TelemetryAttributeMetadata & ( - | { type: "string"; values?: readonly string[]; examples?: readonly string[] } - | { type: "number"; values?: readonly number[]; examples?: readonly number[] } - | { type: "boolean"; values?: readonly boolean[]; examples?: readonly boolean[] } - | { type: "string[]"; elementValues?: readonly string[]; examples?: readonly (readonly string[])[] } - | { type: "number[]"; elementValues?: readonly number[]; examples?: readonly (readonly number[])[] } - | { type: "boolean[]"; elementValues?: readonly boolean[]; examples?: readonly (readonly boolean[])[] } -); - -type TelemetryStartAttributeDefinition = TelemetryAttributeDefinition & { required: boolean }; -type TelemetryEventAttributeDefinition = TelemetryAttributeDefinition & { required: boolean }; - -interface TelemetryEventDefinition { - description: string; - attributes: Record; -} - -type TelemetryParentDefinition = - | { kind: "any" } - | { kind: "root_or_external" } - | { kind: "spans"; spans: readonly string[] }; - -interface TelemetrySpanDefinition { - description: string; - /** Exhaustive allowed-parent rule. "external" means a caller-owned span - outside the pi schemas. */ - parents: TelemetryParentDefinition; - startAttributes: Record; - /** Completion enrichment only. Every end attribute is optional; startSpan() - owns ending the span regardless of which attributes were set. */ - endAttributes: Record; - events?: Record; - status: { default: "ok"; errorWhen: string }; -} - -interface TelemetrySchemaDefinition { - version: number; - spans: Record; -} - -declare function defineTelemetrySchema(schema: T): T; -``` - -`defineTelemetrySchema()` is a typed identity over serializable data, not runtime validation. Types infer names, attributes, requirements, and literals. The tables are normative; `telemetry-schema.md` is generated. - -`createTypedSpanStarter(context, schemas)` binds a parent to a non-empty readonly tuple's combined vocabulary. Schemas retain separate ownership/versioning; the tuple is not a merged schema. Duplicate span names fail compilation. Values drive types only and are not retained at runtime. - -`TypedSpanStarter` accepts a declared literal name and its exact start attributes; union names require narrowing. Its callback receives a schema-scoped span and same-tuple child starter bound to that span, creating explicit nesting and independent concurrent starters: - -```ts -const AGENT_TELEMETRY_SCHEMAS = [ - AI_TELEMETRY_SCHEMA, - HARNESS_TELEMETRY_SCHEMA, -] as const; - -const startSpan = createTypedSpanStarter( - telemetryContext, - AGENT_TELEMETRY_SCHEMAS, -); - -await startSpan("pi.harness.step", stepAttributes, async (stepSpan, startChildSpan) => { - stepSpan.setAttributes({ "pi.step.outcome": "succeeded" }); - return startChildSpan("pi.ai.request", requestAttributes, async (requestSpan) => { - requestSpan.setAttributes({ "pi.ai.response.stop_reason": "stop" }); - }); -}); -``` - -The span retains generic `startSpan()` for intentionally crossing schema tuples. Starter creation adds no span, runtime validation, parent enforcement, or durability. - -The following tables are normative input to the schema objects. `!` means a required start attribute; `?` means an optional start attribute. Every end attribute is optional enrichment. Array element sets use `elementValues`; all other closed sets use `values`. The automatic throw/reject rule from the context contract applies to every span in addition to the explicit status rule shown. - -#### AI request schema - -`AI_TELEMETRY_SCHEMA` declares no pi-written span events and one span. Its parent rule is `{ kind: "any" }`: - -| span | allowed parents | status | -|---|---|---| -| `pi.ai.request` | root or any caller span | error on throw/reject or a returned result with stop reason `error`; `aborted` and `deferred` are normal outcomes | - -| `pi.ai.request` start attribute | type | requirement | values / meaning | -|---|---|---|---| -| `pi.ai.operation` | string | ! | `stream`, `fetch_deferred`, `cancel_deferred`, `generate_images` | -| `pi.ai.provider` | string | ! | selected provider id | -| `pi.ai.model` | string | ! | requested model id | -| `pi.ai.api` | string | ! | provider API id | -| `pi.ai.streaming` | boolean | ! | whether this operation returns a stream | -| `pi.ai.deferred` | boolean | ? | whether the operation requests or participates in deferred execution | - -| `pi.ai.request` end attribute | type | values / meaning | -|---|---|---| -| `pi.ai.response.model` | string | concrete response model, when reported | -| `pi.ai.response.id` | string | provider response id; high cardinality | -| `pi.ai.response.stop_reason` | string | `stop`, `length`, `tool_use`, `error`, `aborted`, `deferred`; terminal `toolUse` normalizes to `tool_use`, and `pending` is never recorded | -| `pi.ai.http.status_code` | number | final HTTP status when exposed by the provider path | -| `pi.ai.usage.input_tokens` | number | reported input tokens | -| `pi.ai.usage.output_tokens` | number | reported output tokens | -| `pi.ai.usage.cache_read_tokens` | number | reported cache-read tokens | -| `pi.ai.usage.cache_write_tokens` | number | reported cache-write tokens | -| `pi.ai.usage.reasoning_tokens` | number | reported reasoning subset of output | -| `pi.ai.usage.total_tokens` | number | reported total tokens | -| `pi.ai.usage.cost` | number | reported total cost | -| `pi.ai.stream.chunk_count` | number | number of streamed update chunks, without chunk content | -| `pi.ai.stream.time_to_first_chunk_ms` | number | elapsed milliseconds to first update chunk | -| `pi.ai.error.type` | string | low-cardinality provider or transport error class | - -The schema declares no per-chunk telemetry event. The assistant stream carries live deltas while telemetry records only aggregate chunk count and first-chunk latency. Default telemetry never contains request or response content. - -#### Harness schema - -The three operation spans share `pi.session.id` (string, required, high cardinality), `pi.lane.name` (string, required, high cardinality), `pi.operation.id` (string, required, high cardinality), and `pi.operation.recovery` (boolean, required). Each also requires `pi.operation.kind` with only the literal matching that span. Operation error status may add optional end attributes `pi.error.code` and `pi.error.type`, both low-cardinality strings; free-form error messages are status diagnostics, not schema attributes. - -| span | allowed parents | start attributes | optional end attributes | explicit error status | -|---|---|---|---|---| -| `pi.harness.run` | root or application span | common operation attributes plus `pi.operation.kind`: `run` | `pi.operation.outcome`: `completed`, `aborted`, `failed`, `suspended` | outcome `failed` | -| `pi.harness.compaction` | root or application span | common operation attributes plus `pi.operation.kind`: `compaction` | `pi.operation.outcome`: `completed`, `declined`, `aborted`, `failed` | outcome `failed` | -| `pi.harness.navigation` | root or application span | common operation attributes plus `pi.operation.kind`: `navigation` | `pi.operation.outcome`: `completed`, `declined`, `aborted`, `failed` | outcome `failed` | -| `pi.harness.checkpoint` | `pi.harness.run` | `pi.lane.name`!, `pi.operation.id`!, `pi.checkpoint.kind`!: `normal`, `failure_drain`, `abort_reconcile` | none | only throw/reject | -| `pi.harness.turn` | `pi.harness.run` | `pi.lane.name`!, `pi.operation.id`!, `pi.turn.id`! string, high cardinality | none | only throw/reject | -| `pi.harness.step` | `pi.harness.run`, `pi.harness.turn`, `pi.harness.checkpoint`, `pi.harness.compaction`, or `pi.harness.navigation` | `pi.lane.name`!, `pi.operation.id`!, `pi.step.id`! string high-cardinality, `pi.step.kind`!: `assistant`, `deferred_fetch`, `compaction`, `branch_summary`; `pi.step.attempt`! number; `pi.compaction.reason`?: `manual`, `threshold`, `overflow` | `pi.step.outcome`: `succeeded`, `retry`, `failed`, `aborted`, `deferred`, `overflow` | outcome `retry` or `failed` | -| `pi.harness.tool` | `pi.harness.turn` for live work or `pi.harness.run` for reconciliation | `pi.lane.name`!, `pi.operation.id`!, `pi.turn.id`? string high-cardinality, `pi.tool.name`! string, `pi.tool.call_id`! string high-cardinality, `pi.tool.replay`!: `never`, `safe`; `pi.tool.recovery`! boolean | `pi.tool.is_error` boolean for the raw phase-2 execution result | `pi.tool.is_error: true` | -| `pi.harness.hook` | root or the current harness/AI scope | `pi.lane.name`!, `pi.operation.id`? string high-cardinality, `pi.hook.name`! string with values from `HookName`, `pi.hook.registration_id`? string | `pi.hook.outcome`: `completed`, `skipped`, `blocked`, `failed` | handler throw, including fail-closed `before_tool` | -| `pi.harness.sleep` | `pi.harness.run`, `pi.harness.compaction`, or `pi.harness.navigation` | `pi.operation.id`!, `pi.sleep.delay_ms`! number | `pi.sleep.outcome`: `elapsed`, `aborted` | only throw/reject | -| `pi.harness.event_handler` | root or the scope emitting the event | `pi.event.type`! low-cardinality string with the section 10 event discriminants, `pi.lane.name`? string high-cardinality | none | listener throw; the event system catches it after the span rejects | -| `pi.session.write` | root or the current harness scope | `pi.lane.name`!, `pi.operation.id`? string high-cardinality, `pi.session.mutation`!: `entry`, `record`, `lane`, `fact`, `multi`; `pi.session.item_type`? string | `pi.session.seq` number for a single mutation or the first sequence in a multi-write append | storage rejection | - -Parent text maps directly to `TelemetryParentDefinition`: root/application is `root_or_external`, root/current or any caller is `any`, and finite lists are exact `spans`. Tool spans wrap only phase-two `fx.executeTool` and report raw `is_error`, not final `terminate`; plans are session-write spans. Blocked, invalid, genuine-length, aborted-before-start, and interrupted-without-replay results emit no tool span; every live execution or safe replay emits one. Live tools parent to turn with turn id; reconciliation omits turn id and parents to resumed run. `pi.hook.name` contains exactly `before_run`, `before_resume`, `before_run_end`, `transform_context`, `before_request`, `before_payload`, `after_response`, `before_tool`, `after_tool`, `before_compaction`, and `before_navigation`; `pi.event.type` contains exactly section 10 discriminants. Each handler invocation has its own span/status without failing its parent. Harness schema initially declares no span events. - -One session-write span covers one append. Singles use their kind; arrays use `multi` and omit item type when mixed. Dynamic ids/names are attributes. One step span covers one in-process provider attempt; durable step id correlates attempts, and deferred fetch parents to resumed run. Hook structural sources emit hook/write but no step/AI span. Generated structural requests emit step/AI but no public message lifecycle. Prepared/post-move writes need no provider span. The schemas exhaust pi instrumentation vocabulary. - -Marker-interrupted active attempt spans may end `aborted`; response/usage appends are write spans. Unmarked `aborted` yields step `retry` below cap or `failed` at cap, never operation `aborted`; deferred interruption uses those outcomes without retry events. Synthetic recovery emits writes but no AI span. Abort outside an attempt creates no assistant step span. The operation ends `aborted`; deferred cancellation emits AI span only when attempted. - -Agent exports both schemas, `AGENT_TELEMETRY_SCHEMAS`, span-name unions, per-name start/end/combined attribute types, event types, discriminated span unions, and typed `startAiSpan()` / `startHarnessSpan()`. Telemetry exports `createTypedSpanStarter()` and `TypedSpanStarter`. Start helpers accept exact start attributes; scoped spans accept only declared optional end attributes/events. Compile time rejects missing/unknown/mistyped/invalid values and duplicate composed names. End setters are optional; `startSpan()` owns settlement. Scoped views erase to generic spans with no production validation. - -Schemas generate `packages/agent/docs/telemetry-schema.md` via `generate-telemetry-docs`/`check:telemetry-docs`; this repository doc is not packaged, while schemas export from agent root. Versions start at 1. Changelogs record compatible additions and breaking renames, removals, type changes, or meaning changes; add migration metadata only for a real translator. - -### Effects and nesting - -Telemetry wrappers follow ownership of ordinary work. Procedures wrap operation, checkpoint, turn, and in-process attempt scopes, passing callback spans downward. Effects wrap their atomic work. Telemetry is ungated and creates no durable crash boundary. - -```ts -async function assistantAttempt( - turnContext: TelemetryContext, - step: Extract, - record: Extract, -): Promise { - return startHarnessSpan( - turnContext, - "pi.harness.step", - { - "pi.lane.name": state.lane, - "pi.operation.id": op.id, - "pi.step.id": step.id, - "pi.step.kind": "assistant", - "pi.step.attempt": record.attempt, - }, - async (stepContext) => { - const started = await fx.startAttempt(record, stepContext); - if (started === "aborted") throw new Aborted(); - const final = await fx.streamAssistant( - assistantRequest(step, started), stepContext, - ); // message_end has fired - const response = await fx.settleAttemptResponse(started, final, stepContext); - await fx.appendRecord( - preplannedUsageRecord(started, response.message), stepContext, - ); - return response.message; // pure classification follows outside this request span - }, - ); -} -``` - -Section 14 `streamAssistant()` starts `pi.ai.request`, passes its span through Models request options, records only declared aggregates, and returns the same message. `Effects.executeTool()` wraps only phase 2; hook/event runners use the same explicit-parent pattern. - -| owner / method | target telemetry | -|---|---| -| operation dispatcher | `pi.harness.run`, `pi.harness.compaction`, or `pi.harness.navigation` | -| checkpoint / turn / step procedure scopes | corresponding `pi.harness.*` scope span | -| `appendEntry`, `appendRecord`, `startStep`, `prepareBranchSummary`, `moveLane`, `setFact`, and a conditional commit that appends | one `pi.session.write` per underlying `Session.append()` call; a conditional no-append result emits no write span | -| `streamAssistant`, `fetchDeferred`, `cancelDeferred` | `pi.ai.request` with the matching `pi.ai.operation` | -| `executeTool` | `pi.harness.tool` | -| `runHook` | one `pi.harness.hook` per registered handler | -| `sleep` | `pi.harness.sleep` | -| passive event delivery | one `pi.harness.event_handler` per listener | - -Contexts/native spans are process-local and never persisted in records, entries, snapshots, events, or deferred handles. - -### Span lifetime - -One operation span wraps each admitted in-process invocation. Initial operations start it after acceptance; `LaneBusy`, `InvalidMessage`, `InvalidNavigation`, `NothingToCompact`, and `UnknownTarget` emit none. Resume starts after lane reservation and progress-free checks. `MissingIdentities` may arise only when a needed effect is reached after identity-free repairs; it resolves without outcome/error enrichment. Resumes reuse operation id with recovery true, so deferred polls create correlated ordinary spans without new lifecycle/durable state. - -- a returned `completed`, `declined`, `aborted`, or `suspended` result resolves normally; instrumentation may enrich the span with the matching allowed outcome; -- a returned `failed` result explicitly sets error status and still resolves normally as the public API requires; it may also enrich the span with outcome `failed`; -- `close()`, a harness fault, or an invariant defect rejects the callback and therefore ends the local span as an error automatically; -- actual process death runs no cleanup, so the backend may lose or retain an incomplete span; the next process simply creates a new span on `resume()`. - -Run outcome never uses `declined`; only structural schemas do. Trace context is not durable or backend-coupled, though serving may link resumed spans externally. - -The span tree follows execution scopes: - -```text -pi.harness.run -├─ pi.harness.step deferred_fetch, numbered poll attempt -├─ pi.harness.checkpoint -│ └─ pi.harness.step compaction, numbered attempt -├─ pi.harness.turn -│ ├─ pi.harness.step assistant, attempt -│ │ └─ pi.ai.request provider, model, stop reason -│ └─ pi.harness.tool tool name, call id, replay -├─ pi.harness.sleep retry delay between attempts -├─ pi.harness.hook -├─ pi.harness.event_handler -└─ pi.session.write entry/record/lane/fact - -pi.harness.compaction manual operation -pi.harness.navigation -``` - -Procedures own orchestration scopes; Effects own writes, phase-two tools, hooks, and sleep; Models dispatch owns AI request; event delivery owns handler spans. Parents are explicit. - -### Safety and testing - -Default attributes include only declared ids, names, counts, durations, stop reasons, status codes, and usage—never prompts, completions, tool args/output, files, provider payloads, headers, or credentials. Future sensitive/high-cardinality fields must be flagged. - -Telemetry remains separate from events and hooks: - -- Events are public live observation. -- Hooks can change execution. -- Telemetry is passive process-local diagnostics. - -## 19. Testing strategy - -Three tiers. Each tests a different claim; none replaces another. - -### Tier A — reduction and resume - -Prefill a session with the records and entries of one section 6 crash state through low-level `Session.append()` calls, open the harness, call `resume()`, and assert the durable result. Keep separate procedure boundaries as separate calls; arrays are used only for contractually atomic states. - -```ts -await session.append({ kind: "record", record: laneConfig("main", Cseed) }); -await session.append({ - kind: "record", - record: opStarted("run", { originalPrompt, initialMessages: [userEntry] }), -}); -await session.append({ kind: "entry", lane: "main", entry: userEntry }); -await session.append({ kind: "record", record: stepStarted("assistant", { - id: "step-1", configuration: Cseed, retryPolicy, triggerMessageId: userEntry.id, -}) }); -await session.append({ kind: "record", record: stepAttempt("assistant", { - stepId: "step-1", attempt: 1, responseEntryId: "response-1", usageRecordId: "usage-1", - intendedOutputLimit: 4096, contextWindow: 128000, -}) }); -await session.append({ - kind: "entry", lane: "main", entry: { ...assistantWithToolCall, id: "response-1" }, -}); -await session.append({ kind: "record", record: assistantUsage({ - id: "usage-1", stepId: "step-1", attempt: 1, entryId: "response-1", -}) }); -await session.append({ kind: "record", record: toolBatchStarted({ - assistantEntryId: "response-1", calls: [{ toolIndex: 0, resultEntryId: "result-1" }], -}) }); -await session.append({ kind: "record", record: toolStarted({ - assistantEntryId: "response-1", toolIndex: 0, replay: "safe", -}) }); -// This durable prefix is X4: the planned, started call has no result. - -const { harness, suspended } = await AgentHarness.create(options); -expect(suspended).toHaveLength(1); -expect((await harness.resume()).ok).toBe(true); -``` - -Coverage: bounded restore with one exact batched entry-plan lookup and no branch/configuration walk; independent next-run reduction for idle, run-open, compaction-open, and navigation-open lanes; invalid navigation intents that target their source or attach a label to the root; `step_started` with no attempt; assistant attempts with no response below and at the captured cap; response without usage; response and usage before classification; response plus each linked transition; distinct response ids across retries; every settled stop reason remaining durable; unmarked `aborted` assistant responses retrying below and failing at the captured cap without operation abort; one self-contained deferred-fetch step with exact copied configuration/policy, consecutive attempts, repeated equal-handle pending entries that advance exact source lineage, ready/terminal responses, unknown poll effects, complete-handle mismatch rejection, and unmarked interruptions re-parking on the unchanged source below and failing at the copied per-source cap; structural source discriminants; complete hook results and preplanned hook-usage repair without hook replay; generated stable result ids, unknown attempts, and `step_failed`; generated compaction with no prepared record; generated branch-summary usage then `branch_summary_prepared`; every valid and invalid navigation source/target/summary leaf-result state with no post-move generation; every X1–X7 tool state, including no plan, plan-only, started, usage-without-result, and result; replay safe/never/changed declarations; every source-order position in a batch; genuine output-limit `length` batches proving no execution and one planned explanatory result per call; abort before assistant-step start, after attempt intent, on both sides of response append and usage, during retry delay, before/after tool planning and every tool start/result position, while deferred, around each pending write, and before/after each structural commit; repeated abort; abort-only recovery with missing model/tool implementations; effect-specific missing identities that do not block durable repairs or synthetic settlements; the terminal-failure marker with and without later consumed input; missing initial messages; pending, cancelled, and abort-killed queue items; deferred writes; attempt caps across restart including auto-compaction exhaustion; every overflow crash site from the section 6 table, including exact response/trigger link validation and omission from preparation and retained tail; all navigation states from the section 6 table, custom-instruction delivery, exact prepared-payload append, and completion-winning label rewrites; bounded section 5 validity rejections; and every half-completed recovery prefix created after an individual repair write. Each such prefix is closed, reopened, resumed, and compared with uninterrupted recovery; merely invoking recovery twice from its initial prefix is insufficient. - -The in-memory backend is the reference. The parity suite runs the same setups against memory, JSONL, and SQLite. Query instrumentation proves each restored lane uses indexed open/latest-run/config reads, one run-id-bounded operation slice, and one `getEntries` call for its exact plan, with no branch scan or other-lane record read. Separate cases keep next-run input visible under an open structural operation, run concurrent writes on two lanes and assert unique increasing `seq` plus identical `getLog()` order, and assert every backend rejects the same non-JSON payloads. - -### Tier B — writer conformance - -Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), lane move (`L`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, queue cancellation, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, durable overflow response and guard, hook-supplied compaction, manual compaction, navigation (move-first), deferred suspension, repeated equal-handle pending polls, and every fetch outcome. Navigation admission cases assert `InvalidNavigation` with no hook or durable write for the current leaf and for a labeled root target. Every provider-settled assistant/fetch case asserts `step_started → step_attempt → provider effect with message_start/message_update* → after_response → message_end → response entry/entry_added → preplanned usage → classification`; `message_end` carries only the provisioned intended id and never proves the later append. A synthetic settlement performs no provider effect, emits no update, and runs no response hook; its order is `message_start → message_end → response entry/entry_added → preplanned usage → classification`. Deferred cases additionally assert one fetch with `wait: 0` per resume, exact source advancement or retention, no original-step lookup after F starts, ready tool selection from F's copied active names, and no retry lifecycle events. Every structural case asserts one stable typed result id, no public assistant-message lifecycle, and `step_failed` only for terminal generated failure. Hook cases assert complete output on `step_started`, exact preplanned usage before entry, `fromHook: true`, and no hook replay. Generated navigation asserts usage and `branch_summary_prepared` before move, exact post-move append with `fromHook: false`, and no provider request after move; generated compaction asserts no prepared record. Every tool case asserts response usage → complete batch plan → source-ordered clearance → `tool_started` immediately before each real `fx.executeTool` → source-ordered finalization → result `message_start/message_end` → tool usage when present → planned result/`entry_added`. Parallel cases prove that effects overlap while starts and dispatches retain source order, result commits form a source-order prefix, and blocked/invalid calls have planned results but no start or tool effect. This tier catches the critical regression classes: an effect starting before its intent record, a response omitted for one stop reason, classification starting before usage is durable, or a result id allocated after clearance began. - -Abort writer-conformance traces additionally assert that marker-before-response normalizes the existing attempt response, response-before-marker preserves its stop reason, a missing response is synthesized under that attempt's planned response id only on recovery, repeated abort emits/writes once, planned unstarted tools get results while started real/error results survive, pending writes precede `operation_finished`, and between-turn, backoff, deferred, and structural abort append no assistant closure. Separate no-marker traces prove that `aborted` assistant/fetch responses are accounted interruptions, emit no `run_abort`, retry under captured policy when allowed, and finish failed rather than aborted at the cap. `operation_finished` is the only universal terminal write. - -Tier B also asserts provider-context projection and the append-only invariant (section 4) executably. Durable `error`, `aborted`, and `deferred` assistant responses never reach the provider; genuine output-limit `length` does, followed by its explanatory tool errors; and the exact response linked by overflow is absent from compaction preparation and retained tail. Within a run, every faux-provider request's message list otherwise extends the previous request's as an exact prefix, except across a compaction entry, the one sanctioned invalidation. This turns projection and KV-cache discipline into failing tests whenever a path includes a non-projecting response or inserts before the tail. - -### Tier C — deterministic interleavings - -`drive: "manual"` against the real `AgentHarness`, the faux provider, and a real backend. The gate is the only test hook; there is no second machine. - -```ts -const { harness } = await AgentHarness.create({ session, models, model, tools: [calc], drive: "manual" }); -const promptResult = harness.prompt("calculate"); - -while ((await harness.peekAction())?.kind !== "execute_tool") await harness.executeAction(); - -// X4: the batch plan and call intent are durable; the effect is still parked. -const plans = await session.findRecords({ lane: "main", type: "tool_batch_started" }); -const started = await session.findRecords({ lane: "main", type: "tool_started" }); -expect(started).toHaveLength(1); -expect(await session.getEntry(plans[0]!.calls[0]!.resultEntryId)).toBeUndefined(); - -expect((await harness.steer("focus on tests")).ok).toBe(true); // surface is ungated -await harness.runToCompletion(); -expect((await promptResult).ok).toBe(true); -``` - -Crash simulation is `close()` immediately before or after a chosen action, then reopening the same backend and resuming. Crash sites are derived mechanically, not hand-picked: drive each section 6 trace in manual mode, capture the backend before and after **every** `executeAction()` — atomic storage append, hook, provider/fetch, individual tool, or timer — and before and after every ungated lane-surface append, then reopen every boundary case and `resume()`. A multi-write append is one action and one crash boundary; no test or recovery prefix may expose one of its logical mutations without the others. For each reopened case, drive recovery through the same manual gate. Whenever recovery commits one entry, record, lane move, or fact, close immediately, reopen that new prefix, and continue; recovery effects also receive the ordinary before/after-action treatment. Every recovery write is therefore a crash boundary. Running recovery twice only after one whole recovery invocation is not a substitute. New effects or recovery writes added to a trace get crash coverage automatically. Coverage: **both orders of every race-catalog row (section 15)**, input injected between arbitrary actions, abort while a cancellable effect is parked and while it runs, and automatic versus manual drive producing identical durable logs and outcomes for the same scripted provider. - -Gate invariants, asserted across Tier C: - -- After every released durable action and `resume()` outcome, the test performs the bounded reads and fresh pure reduction; its `laneState` equals live `LaneState`. Production performs no such reread. -- Both orders of abort versus assistant response append and structural commit are driven explicitly; repeated abort while parked does not change the next action or durable log. -- `peekAction()` has no side effect and is stable until `executeAction()`. -- `executeAction()` releases exactly the peeked action, never a later one. -- Stopping before an action leaves exactly the preceding durable prefix. -- After each recovery write, close/reopen reduction skips that completed repair and selects the next ordinary action without duplicating an id, provider/tool effect, or persisted hook result. -- While parked, zero storage writes and zero provider or tool calls happen (construction rule, section 15). -- Every accepted operation gets exactly one `operation_finished` unless it suspends. -- A faulted append leaves a valid prefix and faults the whole harness. - -### Other suites - -- The telemetry reference adapter and every third-party adapter run the exported conformance cases for synchronous admission, result/rejection identity, automatic and explicit status, attribute merging, event order, post-settlement behavior, parentage, and unreadable-payload suppression. -- Runtime telemetry tests use the in-memory reference to assert exact schema-conforming span trees and independently valid start/end/event bags on every status path. Assistant, generated compaction, generated branch-summary, and deferred-fetch attempts carry the stable durable `pi.step.id`, correct kind, and numbered attempt. Hook-sourced structural results emit hook/write spans but no step or AI-request span; a prepared branch-summary write and post-move append likewise emit no provider span. Unmarked `aborted` responses produce retry/failed step outcomes and never an aborted operation outcome; marker-backed settlement produces the abort outcome. End attributes remain optional. Content and secret fixtures assert absence, not merely redaction. -- The existing `agent-loop` and `agent` suites pass unchanged — the section 14 compatibility criterion. -- Session/storage lifecycle and fork conformance runs against Memory, JSONL, and SQLite: one-mutation and non-empty-array `append()` returns, lane-free entry `LogItem`s with positional input correlation, consecutive non-interleaved sequence assignment; all-or-none validation, projection, and event publication in logical order; expanded `getLog()` results; exact immutable batched entry lookup; configured-lane `[lane, config]` and labeled-navigation `[fact, finish]` appends with neither half observable; separate built-in/custom fact namespaces; name/label/custom deletion versus JSON null; close during idle and queued appends; JSONL ordinary-object and physical array lines, whole torn-array removal, and complete-invalid transaction rejection; SQLite renewal stop and owner/fence-matched release; one coherent fork snapshot while source writes race; current facts/pointers/configurations from that snapshot; no copied orchestration or usage records; and zero fork token/cost totals with copied entry display usage intact. -- Event ordering per section 10: direct and tool-result messages emit immediate start/end before append; streamed assistant/fetch responses run `after_response` before `message_end`; every successful message append then emits `entry_added`, and a fault after `message_end` but before append emits no such confirmation. Multi-write appends emit nothing before full success and then publish in logical order; labeled navigation emits `fact_update` before its operation-end event. Abort cases cover one `run_abort`, optional assistant message lifecycle only for an existing attempt, required reconciliation `entry_added` events, then `run_end` with matching paired optional final fields. Internal compaction and branch-summary streams emit no assistant-message events; only the committed typed entry emits `entry_added`. -- Deferred polling: repeated pending responses with the same complete handle append distinct accounted entries and advance source lineage; interrupted/unknown polls retain the exact source and obey the copied per-source cap; each resume performs zero or one `wait: 0` fetch; ready tool calls use copied active names; returned/rejected terminal errors persist and never start replacement generation; unmarked complete-handle mismatches fault after persistence; no poll emits retry lifecycle events; abort cancels the newest persisted handle best-effort. -- Total lane configuration: fresh-main initialization, atomic configured lane creation in every backend, immediate setters during running and aborting operations, whole-value replacement, immutable seed use for later lanes, generation-step snapshots across retries, no anchor/source-lane inheritance, model resolution in `getModel()`, environmental tool implementations, and fork records containing values from the same coherent source snapshot but no source history. -- Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`, navigation custom-instruction delivery, complete hook summary persistence on `step_started`, no decision-hook replay after that record, durable `fromHook` provenance, and no harness interpretation of hook-owned summary details. -- Ledger completeness and the match invariant: every assistant-generation and deferred-fetch attempt that settles appends its response entry and then exactly its preplanned `usage` record, including retryable errors, overflow, aborted, deferred, and zero-usage pending polls; a response-without-usage crash reconstructs the same id and payload before classification; split-turn structural work writes two usage records per attempt; failed structural series retain reported cost even when their typed result never appears; hook usage is repaired from the complete step payload before entry or abort completion; generated branch-summary usage precedes its prepared payload; assistant/fetch and structural entry snapshots match their producing request records; a real tool writes its finalized execution's reported usage before its planned result and that result displays only its own `AgentToolResult.usage`; usage-without-result recovery retains the ledger charge, a replay adds its own record without folding either execution into the result snapshot, and a synthetic result has no usage snapshot; adjustments never alter entries and sum into read-time effective cost; `getStats()` token and cost fields equal the ledger sum and the `usage` event's totals after every commit; fork token and cost fields start at zero while `messageCount` includes all copied message entries; v3 conversion preserves totals through the aggregate import adjustment. -- Abort settlement and projection: marker-first active assistant/fetch settlement under the existing response id with normalized `aborted`, synthetic zero-usage recovery under that id when absent, response-first stop-reason preservation, no later attempt or tool plan after the marker, no assistant entry between steps/backoff/deferred/tool-only/structural cancellation, stable repeated-abort payloads, planned tool result completion without abort-time replay, pending-write completion before the terminal record, missing-identity abort-only recovery, and structural commit-point ordering. Without a marker, durable `aborted` assistant/fetch responses retry under captured policy and fail rather than abort at the cap. All durable aborted responses remain omitted from provider context. -- Response classification and projection: explicit context-limit error strings with non-overflow exclusions; `stop` responses whose reported input plus cache-read tokens exceed captured windows (268,009 of 272,000 and 81,217 of 84,500); the existing Xiaomi zero-output/full-window signal; non-zero reasoning-only output; cache-write-heavy usage; a Codex-style provider that rejects `max_output_tokens`; a genuine 1,024-token cap fully used and retained in provider context; one explanatory error per genuine-length tool call with zero tool effects; omission of `error`, `aborted`, and `deferred`; exact overflow-response omission from preparation and retained tail; no tool plan for overflow; and `length → length` stopping after exactly one linked recovery per `triggerMessageId`. Fork tests copy a self-contained compaction tail without classification records and verify that a fork before the compaction uses ordinary entry-local projection. -- v3 fixtures: labels, session info, and legacy model/thinking/active-tool entries mid-chain and at end of file; old `firstKeptEntryId` compactions; and preserved `fromHook` provenance on compaction and branch-summary entries — all open as one normalized idle `main` lane, with legacy configuration absent and the harness options seed used on attachment. - -## 20. Implementation status and work packages - -Work is limited to `packages/agent`, `packages/session-backends/sqlite-node`, `packages/telemetry`, and the telemetry request-option surface in `packages/ai`. Other package source is off limits. In particular, this plan does not migrate `packages/coding-agent`; I0's completed dependency wiring is the only exception. Coding-agent v3 compatibility means only that the new JSONL repository can read supported v3 sessions. - -Checked package entries are historical: they describe the contract that actually landed, even where this document later replaced that contract. An unchecked convergence or runtime package owns every delta to the final design; a checked package is never retroactively expanded by a later section rewrite. - -### Claiming and completing a package - -1. Sync with `main`. A package is claimable only when its checkbox is empty, every dependency is checked, and no active reservation owns the package or overlapping primary files. -2. Add `**Reserved: by @.**` immediately above the package entry. Land that change alone with commit message `docs(agent): reserve `. The package is claimed only after this commit reaches `main`; if another conflicting reservation lands first, remove yours and choose again. -3. Start from the reservation commit. Read the referenced design and primary files. -4. Work in this loop: - 1. Implement the package's described behavior within its primary files. Incomplete public operations keep rejecting with `HarnessNotImplemented`. - 2. Implement comprehensive focused tests that encode the package's acceptance criteria and every design invariant the package owns. Smoke tests and happy-path coverage alone are insufficient; each owned invariant must have an executable assertion. - 3. Iterate on the implementation and tests until the behavior is complete and all affected tests pass. - 4. If the design does not hold, stop and consult Mario on Discord. After agreement, update the design and package description, then return to step 1. -5. Run `npm run check`. The implementation PR or commit removes its reservation and changes the package checkbox to checked. If work is abandoned, remove the reservation without checking the package. - -### Track F — scaffold truth and public ownership - -- [x] **F0 — harden the scaffold.** Dependencies: none. - - Primary files: `packages/agent/src/harness/agent-harness.ts`, `packages/agent/test/harness/agent-harness-scaffold.test.ts`. - - Inventory every public method. Preserve only behavior that is genuinely correct without an operation runtime, such as immutable harness-global configuration copies and direct leaf reads. Make every other placeholder reject with `HarnessNotImplemented` instead of returning empty snapshots, idle state, or no-op drive/wait success. - - Before R3, `AgentHarness.create()` may open only a record-free session. It rejects any session containing records rather than reporting a false empty suspended list. - - Acceptance: a table-driven scaffold test covers every public method and proves no unfinished method reports plausible success. - -### Public method ownership - -This table is exhaustive. A package does not remove `HarnessNotImplemented` from a method until it owns the listed semantics and tests. - -| public surface | owning package | -|---|---| -| scaffold-safe `name`, `getLeafId`, record-free create, runtime settings | F0 | -| `AgentHarness.create()` restore and `suspended` inventory | R3 | -| `lane`, `createLane`, `lanes`, lane facades, lane-bound session reads and global facts | H0 | -| resources, stream/retry/compaction settings, queue modes | F0 | -| tool registry plus persisted active-tool selection | H4 | -| `prompt`, `skill`, `promptFromTemplate` | H1 | -| run `resume`, retries, terminal failure | H2 | -| `steer`, `followUp`, `nextRun`, `cancelQueued` | H3 | -| persisted model/thinking/active-tools, lane-view writes, `recordUsage` | H4 | -| `abort`, `waitForIdle`, `runWhenIdle`, close settlement | H5 | -| live tools and tool events | H6 | -| tool recovery through `resume` | H7 | -| deferred-handle `resume` and cancellation | H8 | -| `compact` and compaction resume | C1–C3 | -| `navigateTree` and navigation resume | N1 | -| `peekAction`, `executeAction`, `runToCompletion` primitives/integration | I5/H0 | -| hooks/events registration primitives and harness wiring | I1/I2/H0 | -| `watch`, `watchSession`, complete snapshots | O1 | - -### Track QA — legacy test salvage - -Implementation packages derive their tests from this design and do not use the promotion test matrix. The QA track alone owns `packages/agent/docs/harness-v2-test-matrix.md`. Old tests are evidence, not specification: QA ports a case only when it still expresses a target-design invariant and comprehensive current coverage does not already exist. - -- [x] **QA1 — inventory removed tests.** Dependencies: none. - - Inventory the tests removed by the harness promotion and record whether each case is covered, inapplicable, or blocked on a new implementation package. - - Acceptance: every removed case has a disposition in the matrix; no production or test code changes. - -- [x] **QA2 — salvage storage and query tests.** Dependencies: QA1, R0. - - Port worthwhile bounded-query, corruption, fork, immutable-read, lane, record-query, and recovery-query cases whose replacement APIs already exist. Skip deleted implementation details and behavior already covered by backend conformance. - - Acceptance: each reviewed storage/query case is covered by a cited current test, ported as a comprehensive invariant test, marked inapplicable, or left blocked on J1–J6. - -- [ ] **QA3 — salvage remaining legacy tests.** Dependencies: QA2, J6, O2. - - After the new storage and harness runtime are complete, review every matrix case still blocked or uncovered. Port only still-valid invariants against the new public APIs; do not restore deleted APIs or old implementation details. QA3 may change focused tests and the matrix, but no production code. - - Acceptance: every matrix row ends covered by a cited current test, ported by a comprehensive new test, or explicitly inapplicable; no row remains blocked or uncovered. - -### Track R — recovery query, reducer, and restore - -R0 → R1 → R2 land first and add a reducer module instead of growing `agent-harness.ts`. D0 then converges that landed reducer and the landed JSONL implementation on the final durable contract. R3 is the first package in this track that owns `agent-harness.ts` and therefore runs after both F0 and D0. - -- [x] **R0 — recovery-query contract.** Dependencies: none. - - Primary files: `packages/agent/src/harness/session/types.ts`, `session.ts`, `memory.ts`, SQLite record storage/repository files, backend conformance, and focused recovery-query tests. - - Landed `RecordQuery.operationKind` and `findOpenOperations(lane, { limit })` for the pre-convergence recovery contract. Memory maintains the projection, JSONL derives it during replay, and SQLite answers it from the lane open-operation projection. - - Proved that zero/one open operations are distinguishable, normal writes cannot start a second operation on a busy lane, and the latest run-kind start is an indexed query. Added the lane open-operation projection. - - Acceptance at landing: memory and SQLite had identical query behavior, invalid query combinations rejected, and restore no longer needed a full historical scan. - -- [x] **R1 — pure record-log validity.** Dependencies: R0. - - Primary files: `packages/agent/src/harness/reducer.ts`, `packages/agent/test/harness/reducer.test.ts`. - - Landed pure validity for the pre-convergence section 5 record log from discovered open starts, bounded records, and point-looked-up entries, with no writes or effects. - - Acceptance at landing: focused rejection tests covered that contract's validity bullets and valid prefixes from its section 6 crash catalog. - -- [x] **R2 — pure lane-state reduction.** Dependencies: R1. - - Primary files: `packages/agent/src/harness/reducer.ts`, `packages/agent/test/harness/reducer.test.ts`. - - Landed the pre-convergence `LaneReductionInput` → `LaneReductionResult` contract. It derived pending queues/writes, attempts, tool batches, deferred handles, structural targets, and idle next-run state into `laneState`, plus effective configuration and terminal-failure provenance from the then-current recovery inputs. - - Kept `LaneState` limited to orchestration state. Reduction owned all three outputs so later recovery code did not re-reduce tool or operation records. - - Acceptance at landing: table-driven tests covered idle and suspended states, configuration fallback/override, and terminal-failure provenance; reduction was deterministic and performed no writes. - -- [ ] **R3 — harness restore inventory.** Dependencies: F0, D0. - - Primary files: `packages/agent/src/harness/agent-harness.ts`, reducer integration helpers, and restore tests. - - Wire `AgentHarness.create()` to use indexed configuration/open-operation/newest-run discovery, one exact run-id-bounded open slice, the independent next-run slice for every lane, complete entry-plan construction, and one batched `getEntries` call per lane. Return accurate `SuspendedOperation[]` without walking an operation branch, reading tree configuration, starting effects, or writing during restore. - - Acceptance: idle and multi-lane restore write nothing; multiple open operations reject; suspended metadata and effect-specific missing identities are complete; next-run survives under open compaction/navigation; query instrumentation proves one entry batch and no branch, completed-history, or other-lane scan. `resume()` may still reject as unimplemented. - -### Track D — durable-contract convergence - -R1/R2 and J1–J3 landed before the final durable record contract in this document was approved. D0 is the single deliberate convergence package: it updates those landed foundations before restore or runtime integration builds on them. It owns the reducer and JSONL primary files while active; R3 and J4 must not overlap it. - -- [ ] **D0 — converge the durable contract.** Dependencies: R2, J3. - - Primary files: `packages/agent/src/harness/session/types.ts`, `session.ts`, `memory.ts`, `packages/agent/src/harness/session/jsonl/**`, `packages/agent/src/harness/reducer.ts`, their focused tests, and only the SQLite storage/conformance files required for backend-neutral parity. - - Converge the landed session/storage foundation on sections 7, 12, and 13: replace individual low-level write methods with the overloaded atomic `append(SessionMutation | NonEmptySessionMutations)` contract and expanded `LogItem` returns; implement one-job Memory arrays, ordinary-object/physical-array JSONL lines with whole-tail truncation, and one-transaction SQLite arrays; indexed latest total `lane_config`; exact immutable `getEntries(ids)`; run-id-bounded open slices and independent newest-run/next-run reads; separate built-in and custom facts with name/label/custom deletion distinct from JSON null and no fact ids; `[lane create, initial config]` configured lanes; draining close and SQLite fenced lease release; coherent forks with fresh current configurations/facts, no orchestration or ledger records, and zero cost totals; and matching backend behavior. - - Replace the landed orchestration record shapes with the final sections 5–7 contract: operation starts without duplicated configuration and navigation intents forbid a label on the root target; stable `step_started`, numbered `step_attempt`, and structural `step_failed`; assistant/fetch response and usage ids provisioned before effects; exact overflow response/trigger links and request limits; complete `tool_batch_started` plans plus effect-only `tool_started`; authoritative marker-only abort; self-contained deferred-fetch configuration, policy, and source lineage; complete hook structural results; and generated `branch_summary_prepared` with no compaction equivalent. Update the record union, append validation, entry planning, bounded validity/reduction, JSONL format-4 codec/replay, usage-ledger projections, and backend parity together. - - Add no format-4 migration or compatibility decoder. Do not wire restore or operation execution; R3 and later packages own those semantics. No later package may temporarily accept both the old and final contracts. - - Acceptance: every final record and mutation variant round-trips through format-4 storage; single and non-empty-array appends return expanded ordered `LogItem`s, entry items contain no lane while remaining positionally correlated with their routing mutations, assign consecutive non-interleaved sequences, publish all-or-none, and expand through `getLog()` identically across backends; JSONL accepts ordinary-object and physical-array lines, discards a whole torn final array, and rejects complete-invalid or malformed interior transactions; every section 19 Tier A prefix reduces or rejects exactly as specified; exact configuration, query, fact, configured-lane, close, fork, and ledger conformance agrees; stale old durable fields are absent outside v3 decoder vocabulary; and the package leaves one final reducer/storage contract for R3, I3, J4, and H0. - -### Track J — JSONL storage - -**In progress and reserved: @davidbrai.** The work began before this plan was split into J0–J6. Because D0 edits the same JSONL files, it is part of this serial reservation and lands after J3 before J4. Other agents must not pick D0 or a J package while this ownership marker remains. - -These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete `JsonlSessionRepo` export, and `packages/agent/test/harness/session/jsonl*.test.ts`. Their serial order is J0 → J1 → J2 → J3 → D0 → J4 → J5 → J6; tracks L and non-overlapping I packages may proceed independently. - -- [x] **J0 — JSONL metadata and codec contracts.** Dependencies: R0. - - Primary files: JSONL type/codec modules and focused codec tests; no public repository export yet. - - Implement the `JsonlSessionMetadata`, create/list options, format-4 header, line discriminants, `modifiedAt`, metadata, and parent-id/legacy-parent-path rules from section 13. - - Acceptance: type and codec round trips cover every header field and line kind; no filesystem lifecycle yet. -- [x] **J1 — format-4 per-session storage.** Dependencies: J0. - - Landed one-session replay/write support for the pre-convergence entries, records, lanes, facts, statistics, branch queries, operation-kind queries, and open-operation projection. - - Kept it internal; did not export a partially implemented repository. - - Acceptance at landing: focused round-trip tests covered every then-current mutation, shared `seq`, query bounds, immutable reads, and JSON validation. -- [x] **J2 — format-4 repository lifecycle and forks.** Dependencies: J1. - - Landed create/open/list/delete, one writer queue per session, metadata ordering/filtering, the pre-convergence branch/tree forks, and the concrete public `JsonlSessionRepo` export. - - Acceptance at landing: the then-current backend-neutral conformance suite passed against JSONL, including concurrent lane writes and forks. -- [x] **J3 — format-4 crash and corruption behavior.** Dependencies: J2. - - Add torn-tail truncation, malformed-interior rejection, missing-reference rejection, and lifecycle/concurrency edge cases. - - Acceptance: acknowledged writes survive reopen and malformed non-tail data is never silently repaired. -- [ ] **J4 — read-only v3 normalization.** Dependencies: D0. - - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, discarded legacy model/thinking/active-tool entries without using them as lane configuration, discarded-entry reparenting, old compactions, summary `fromHook` provenance, timestamps, parent mapping, and idle unconfigured `main` at the final retained logical entry. - - A read-only open must not modify the physical file. No coding-agent source or test is changed. - - Acceptance: fixture tests cover every normalization rule in section 12, including `fromHook` true and false plus absent v3 values normalizing to false, and malformed v3 input. -- [ ] **J5 — first-write v3 conversion.** Dependencies: J4. - - Rewrite through a temporary format-4 file on the first mutation, preserve metadata/facts/tree and resolved or legacy parent linkage, add the aggregate v3 usage adjustment, and persist a harness-initializing `lane_config` as an ordinary new record without reviving discarded legacy configuration. - - Acceptance: crash-safe conversion tests cover failure before rename, successful reopen with the immutable options seed, statistics preservation, unresolved legacy parent paths, and no second conversion. - -- [ ] **J6 — schema-based durable payload validation.** Dependencies: J5. - - Define shared TypeBox schemas for format-4 JSON and derive session types from them, including the entry/record/fact/lane `SessionMutation` union, non-empty JSONL mutation arrays, expanded lane-free entry `LogItem`s, navigation intents that forbid labels on the root target, discriminated stable-step/attempt/failure records, generated versus complete hook-result structural sources with preplanned hook usage, dedicated generated branch-summary prepared records and no prepared compaction variant, copied deferred-fetch retry policy, required exact-response/trigger fields only on overflow compaction starts, assistant/fetch request fields and preplanned ids, complete source-indexed tool-batch plans, effect-only tool starts, separate built-in/custom fact variants whose custom `value` omission is distinct from JSON null, plus runtime schema registration for application-defined `AgentMessage` variants. - - Acceptance: malformed durable payloads, invalid step/attempt variants, and incomplete or mismatched tool plans/starts are rejected consistently and JSONL decoding uses the shared schemas. - -### Track I — primitives - -I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins after D0 fixes the final `LaneState` shape. These packages use separate modules with focused unit tests; I5 remains primitive-only and does not edit `agent-harness.ts`. - -- [x] **I0 — telemetry contracts, typed schemas, and no-op context.** Dependencies: none. - - Primary files: `packages/telemetry/src/index.ts`, `packages/telemetry/src/memory.ts`, `packages/telemetry/src/testing/`, and focused tests; pi-ai request-option types/propagation and focused tests; `packages/agent/src/harness/telemetry.ts`, `packages/agent/src/index.ts`, focused tests, package scripts, `packages/agent/scripts/generate-telemetry-docs.ts`, and generated `packages/agent/docs/telemetry-schema.md`. Do not edit `agent-harness.ts`; its canonical context type is landed, while H0 owns option renaming/defaulting/storage and execution threading after convergence. - - In telemetry, implement the one canonical section 18 callback-based `TelemetryContext` / `TelemetrySpan` contract, shared no-op context, deterministic in-memory reference adapter, runner-independent adapter conformance cases, serializable `defineTelemetrySchema()` machinery, and `createTypedSpanStarter(context, schemas)` composition with child-bound starters. - - In pi-ai, add optional `telemetryContext` to `ProviderRequestOptions` so every stream, deferred, and image option inherits it; provider, `Models`, `ImagesModels`, direct dispatch, and simple-option conversion preserve it. Pi-ai owns no domain schema or helper. - - In agent, define the landed `AI_TELEMETRY_SCHEMA` and `HARNESS_TELEMETRY_SCHEMA`, their inferred types, the readonly `AGENT_TELEMETRY_SCHEMAS` composition tuple, and typed `startAiSpan()` / `startHarnessSpan()` helpers. Export both schemas, the tuple, and helpers, and re-export the generic telemetry surface from the agent package root. Do not duplicate the generic contract and do not adopt OTel or another external semantic convention. - - Generate the combined repository-only Markdown reference from the runtime schema values with the named agent package scripts. Production helpers perform no runtime schema validation; schemas compile-time-check each pi-written start/end/event call and remain importable as machine-readable data. - - Wire telemetry before pi-ai in workspace, local-release, publish, profiling, and coding-agent binary build order; add source-test aliases and refresh workspace/generated dependency locks. - - Landed coverage: focused tests exercise no-op synchronous admission, returned-value and sync/async rejection preservation, explicit no-op child propagation, one shared frozen inert span with no payload inspection, exact start/optional-end inference, multi-schema vocabulary composition, child-starter parent propagation, rejection of duplicate span names and missing, unknown, empty-schema, and invalid closed-set attributes, absence of declared span events, schema JSON serialization, the in-memory reference against every exported adapter conformance case, option propagation across provider/`Models` stream and deferred dispatch, direct and `ImagesModels` image dispatch, built-in simple-option conversion, and generated-document freshness. O2 will use the reference adapter to test pi's runtime status and nesting behavior with captured spans. -- [ ] **I1 — hook registry and runner.** Dependencies: none. - - Primary files: `packages/agent/src/harness/hooks.ts`, `packages/agent/test/harness/hooks.test.ts`. - - Implement typed registration, stable-id validation, ordered aggregation, error isolation, fail-closed `before_tool`, and per-id resume data handling. - - Acceptance: focused tests cover every section 11 aggregation and failure rule; no operation wiring yet. - -**Reserved: I2 by @vegarsti.** - -- [ ] **I2 — passive events and watch buffering.** Dependencies: none. - - Primary files: `packages/agent/src/harness/events.ts`, `packages/agent/test/harness/events.test.ts`. - - Implement passive listener isolation and the snapshot/start/unsubscribe buffer primitive used by lane and session watchers. - - Acceptance: no snapshot/event gap, ordered one-time flush, independent watchers, and `handler_error` recursion safety; no operation wiring yet. -- [ ] **I3 — lane mutation line.** Dependencies: D0. - - Primary files: `packages/agent/src/harness/lane-runtime.ts`, focused mutation-line tests. - - Implement the per-lane FIFO and state-update discipline with test-only jobs for every conditional history in section 15, including immediate total configuration replacement versus generation-step start. - - Acceptance: jobs never interleave, rejected jobs do not poison the queue, configuration snapshots see exactly one whole replacement, and no external effect runs inside a job. -- [ ] **I4 — automatic `Effects` implementation.** Dependencies: I0, I1, I3, L3. - - Primary files: `packages/agent/src/harness/effects.ts`, focused effects tests. - - Implement semantic durable effects over `Session.append()`, atomic abort-aware `startStep` configuration/policy capture or complete hook-result persistence, abort-aware attempt/tool intents and structural `step_failed`, assistant/fetch response settlement, generated branch-summary preparation, structural commit races, conditional finishes including `[label fact, operation_finished]`, provider/tool/hook adapters, sleep, fault propagation, and ordered live-state/event updates after append success. - - Acceptance: every external effect and storage append crosses `Effects`, semantic methods recover typed entries/records from `LogItem`, multi-write finish is one gate/crash/telemetry boundary, `step_started` snapshots exactly one total configuration at its commit, and a failed append faults the whole harness without partial publication. -- [ ] **I5 — manual gate primitive.** Dependencies: I4. - - Primary files: `packages/agent/src/harness/gated-effects.ts`, focused gate tests. - - Implement `GatedEffects` action descriptions, stable peek, exactly-one release, reentrant nested actions, run-through, and parked rejection without wiring public lane controls yet. - - Acceptance: zero effects while parked, nested hook actions surface without deadlocking their released parent, and durable-prefix close simulations pass at the primitive boundary. - -### Track L — agent-loop building blocks - -These packages all own `packages/agent/src/agent-loop.ts` and therefore merge strictly L1 → L2 → L3. Existing `agent-loop` and `agent` tests pass unchanged after each package. - -**Reserved: L1 by @cristinaponcela.** Other agents must not pick L1 while this ownership marker remains. - -- [ ] **L1 — extract assistant streaming.** Dependencies: I0. - - Add `streamAssistant()` and `StreamAssistantConfig`, including explicit telemetry context; route the compatibility loop's request path through it without changing events or results. - - Acceptance: focused stream tests cover settled-result narrowing (a final `pending` value is a defect), plus unchanged existing loop tests. -- [ ] **L2 — extract tool-call phases.** Dependencies: L1. - - Add `prepareToolCall()`, `executeToolCall()`, `finalizeToolCall()`, result helpers, replay declaration, explicit telemetry contexts, and durability callbacks that retain the original source call object without changing batch behavior. - - Acceptance: phase tests cover validation, blocking, abort, callback failure, updates, patches, and independent phase-two invocation. -- [ ] **L3 — compose tool batches and compatibility wrappers.** Dependencies: L2. - - Add `executeToolBatch()` with sequential source preparation, optional injected phase-two dispatch per call, source-ordered parallel dispatch and finalization, genuine output-limit `length` producing one explanatory error per call with no clearance or execution, abort, and `terminate` rules. Make every legacy loop export a thin composition using the no-op context and default direct phase-two dispatch. The harness does not call the batch for an overflow-classified response. - - Acceptance: concurrent settlement with source-ordered starts/results, one injected phase-two call per real invocation, blocked/invalid/length calls with no phase-two call, and unchanged `agent-loop` and `agent` suites. - -### Track H — harness integration and run execution - -H0 converges restore and primitives into `agent-harness.ts`. H0–H8 then merge strictly in order. Each package adds its Tier A recovery cases, Tier B exact trace, relevant events/hooks, and Tier C interleavings rather than deferring testing to the end. - -- [ ] **H0 — lane facades and primitive integration.** Dependencies: R3, I2, I5. - - Capture the immutable total lane seed, initialize a fresh or normalized-v3 `main`, and wire durable lane lookup plus `[lane create, initial config]` creation/inventory, equivalent name-bound facades, canonical hook/event/telemetry types, separate built-in/custom global-fact APIs and events over `Session.append()`, rename `AgentHarnessOptions.context` to `telemetryContext` with the no-op default and stored root context, public manual-drive controls, and Harness/Session/Storage ownership and close plumbing. Existing configured lanes read only their latest `lane_config`; anchors and other lanes never initialize them. - - Acceptance: repeated facades are equivalent, lanes remain isolated and start with the captured seed, configured creation exposes neither half alone, custom-fact deletion differs from JSON null, public drive controls match gate actions, a closed session releases only its own writer claim after admitted writes drain, and no placeholder operation is accidentally enabled. -- [ ] **H1 — one successful no-tool run.** Dependencies: H0, L3, I1. - - Implement `prompt`, skill/template expansion, run acceptance, capture of already-pending next-run items, initial appends, one assistant `step_started` with stable id/configuration/policy/trigger, one `step_attempt` with preplanned response and usage ids plus request limits, `after_response` transformation, `message_end` before persistence, complete response entry plus `entry_added`, usage commit, conditional finish, result, and basic run/turn/message events/hooks. - - H3 later owns public next-run enqueue/cancel/race behavior; H1 owns capture into `operation_started.initialMessages`. - - Acceptance: the exact order is step start, attempt intent, provider effect, response entry, usage, then finish; automatic/manual durable logs are identical; closing after every released action restores the expected suspended prefix, including response without usage. -- [ ] **H2 — retry, run resume, and terminal failure.** Dependencies: H1. - - Add numbered attempts under one stable assistant step, captured retry policy/backoff/events, durable responses for retryable and terminal errors, response-without-usage repair, post-usage pure classification with linked-transition detection, unknown-effect resume under fresh ids, synthetic interruption under the already-provisioned id at the cap, unmarked `aborted` responses as retryable provider interruptions rather than operation abort, terminal-failure drain, and test-only live/reducer fixed-point assertions for these states. Provider context omits durable error and aborted responses. - - Acceptance: retry caps survive reopen; unmarked `aborted` retries below the cap and fails at the cap without `run_abort` or outcome `aborted`; every settled attempt has one message entry and its exact preplanned usage record; no response or terminal id is invented after an attempt; half-completed recovery is idempotent. -- [ ] **H3 — queues and checkpoints.** Dependencies: H2. - - Add next-run/steer/follow-up acceptance and modes, cancellation, checkpoint consumption, queue events, and finish-boundary conditionals. Keep `QueueResult`/`NoActiveRun` on steer and follow-up only; `NextRunResult` works idle or during any operation and its acceptance starts no run. Consume the final queue state produced by D0's converged reducer. - - Acceptance: both orders of race rows 2, 5, 7, and 12; provider context grows only at the tail. -- [ ] **H4 — deferred tree writes, total lane configuration, and adjustments.** Dependencies: H3. - - Add deferred lane-view tree writes, direct idle tree writes, immediate total `lane_config` setters/getters for model/thinking/active-tool names, `recordUsage`, pending-write snapshots/events, and finish conditionals. Direct and applied message writes emit message start/end before append and `entry_added` after commit; all other committed entries also emit `entry_added`. Keep `setTools()` limited to the environmental implementation registry and keep `getModel(): Promise`. - - Acceptance: both orders of race rows 3 and 9; accepted tree writes and immediate configuration replacements survive crashes and abort markers; retries retain their generation-step snapshot; adjustments affect ledger totals but never entries. -- [ ] **H5 — abort, wait, run-when-idle, and close.** Dependencies: H4. - - Add one authoritative, idempotent abort marker; stable queue draining; one signal/event; pending-write application; marker-backed active assistant settlement under its planned response id; synthetic missing-attempt settlement under that same response id; preserve H2's unmarked `aborted` interruption classification; between-turn/backoff and suspended abort without an assistant closure; missing-identity abort-only recovery; optional aborted-run final message fields; idle waiters/callbacks; and process-local close settlement. Close stops admission, signals effects, rejects parked/local operation promises, drains admitted storage writes through `Session.close()`, and leaves durable operations open. Never start a provider request or allocate an assistant response id for termination. - - Acceptance: both orders of race rows 4, 6, 8, and 10; repeated abort returns the same payload without a write/event/signal; crash/reopen after every abort action; every marker-backed aborted run ends in `operation_finished` after required writes and may have no assistant response; close releases only the matching writer claim after queue drain; an unmarked `aborted` response never enters this path. -- [ ] **H6 — live durable tool batches.** Dependencies: H5. - - After response accounting and classification, commit one `tool_batch_started` with every source-index/result-id pair before clearance. Wire section 14 callbacks through `Effects`; write effect-only `tool_started` immediately before each individual `fx.executeTool`, emit each finalized tool-result message start/end before persistence, write reported usage before the planned result, emit `entry_added` after that result commits, persist finalized `terminate`, and emit existing tool events without exposing source indices. On live abort, signal started effects and preserve their finalized real/error results while appending planned synthetic aborted results for calls that never started; start no assistant request for closure. A genuine output-limit `length` response appends its planned explanatory errors in source order, starts no clearance or tool effect, and forces another assistant turn; an overflow-classified response gets no plan. - - Acceptance: exact one-tool, blocked, invalid, genuine-length, abort, and parallel-batch traces; no result id is allocated in a callback; no blocked/invalid/genuine-length call writes `tool_started`; a real result displays only its finalized execution's own usage and synthetics display none; phase-two effects overlap but dispatch, finalization, usage, and results obey the specified source ordering. -- [ ] **H7 — tool recovery.** Dependencies: H6. - - Consume D0's final X1–X7 planned-call state and reconcile it in source order. Without abort, a no-start call reruns clearance against its existing id and a started call replays persisted args only when persisted and current declarations are safe, otherwise it gets the planned interrupted result. With abort, never replay: an unresolved started call gets its planned interrupted result and an unstarted call gets its planned aborted result. Retain usage-without-result in the ledger without folding it into a later entry, keep a replay result's display snapshot to that replay's own usage, complete usage-free genuine-length and aborted planned synthetics without execution, and do not duplicate reducer logic. - - Acceptance: complete tool crash matrix, changed replay declarations, blocked/invalid decisions rerun under the same id, parallel starts beyond the result prefix, usage-before-result crashes, and idempotent second recovery. -- [ ] **H8 — deferred provider redemption.** Dependencies: H7. - - Primary files: `packages/agent/src/harness/agent-harness.ts` and focused deferred harness tests. The deferred provider, fetch, cancellation, and authenticated `Models` APIs in `packages/ai` are already landed and receive no new work here. - - Integrate one stable `deferred_fetch` step per original deferred response and its later pending responses. Copy its total configuration and normalized retry policy from the original assistant generation step exactly once; use the exact source handle's provider/model for fetches and the copied active names for ready tool calls. Number poll attempts across the step, record exact source plus response/usage ids before each fetch, persist pending/ready/terminal/interrupted responses before usage and classification, advance equal-handle pending source entries, retain interrupted/unknown sources, reject unmarked complete-handle mismatch, and best-effort cancel the newest persisted handle. An unmarked `aborted` response re-parks on its source below the copied per-source cap and fails at that cap without retry lifecycle events. Suspended abort retains deferred entries and adds no assistant closure; an active marker-backed fetch settles or synthesizes only under its planned response id. - - `resume()` always calls `fetchDeferred` with `wait: 0`: one check, then re-park immediately when pending. Poll cadence belongs to the application and can use `pollAfterMs`. - - Acceptance: at most one fetch per resume; repeated pending polls with a completely equal handle create distinct durable messages and advance exact source lineage; every pending/ready/terminal/interrupted poll has its preplanned usage record; ready tool calls use copied active names despite later lane configuration changes; returned and rejected terminal errors never start replacement requests; unmarked interruptions retry only on later resumes under copied policy and never abort the operation; no deferred poll emits retry lifecycle events; cancellation targets the newest persisted handle and remains best effort. - -### Track C/N — structural operations - -These packages also own `agent-harness.ts` and merge after H8, in order C1 → C2 → C3 → N1. - -- [ ] **C1 — manual compaction operation.** Dependencies: H8. - - Add acceptance and hook decision; persist either one complete hook-sourced result on `step_started` or one generated structural source with a stable typed result id and captured config/policy. Add preplanned hook usage, numbered generated summary attempts/usage, `step_failed`, complete `retainedTail`, result entry, abort/failure, and structural resume. Generated compaction uses no prepared-result record. Abort before the result-entry commit finishes aborted with no assistant entry or `step_failed`; abort after that commit completes the compaction. - - Acceptance: exact manual-compaction traces and every crash boundary; all generated attempts share one result id, terminal failure writes no assistant entry, hook output and usage survive without rerunning the hook with `fromHook: true`, and internal streams emit no public assistant-message lifecycle. -- [ ] **C2 — threshold auto-compaction.** Dependencies: C1, H4. - - Run compaction inside the active run at checkpoints without a nested operation and continue the assistant loop. - - Acceptance: append-only context holds except at the compaction boundary; repeated compaction retains the previous checkpoint tail. -- [ ] **C3 — overflow recovery.** Dependencies: C2, H2. - - Extend post-persistence pure classification with explicit context-limit errors, reported input plus cache-read greater than the attempt's captured window, the existing provider-specific context-pressure signal, and recoverable `length` from the captured intended output limit. Link each overflow compaction to the exact superseded response and `triggerMessageId`, omit that response from preparation and retained tail, retry once for that trigger, and fail boundedly without deleting or replacing either response. Start no tool plan for overflow; keep genuine output-limit `length` in provider context. - - Acceptance: every provider shape and crash row from sections 6 and 19, exact-link validity and omission, hook decline, no overflow tool plan, genuine-length projection, and `length → length` bounded by one recovery for the same trigger. -- [ ] **N1 — move-first navigation.** Dependencies: C3. - - Add pre-acceptance validation, abandoned-branch preparation, and navigation custom-instruction delivery. Return `InvalidNavigation` without a hook or durable append when the target is the current leaf or when a label is supplied for the `null` root target. Persist a complete hook summary on `step_started`, or use a generated branch-summary start with one typed result id across numbered attempts and `step_failed`, then persist one complete `branch_summary_prepared` before the move. After move, append the exact durable payload; labeled completion uses one `[label fact, operation_finished]` append, publishes both only after success in logical order, and never reruns a hook or provider. Abort before the move finishes aborted with no assistant entry; abort after the move completes summary/fact writes and navigation outcome. - - Acceptance: both `InvalidNavigation` cases append and invoke nothing; `[label fact, operation_finished]` has consecutive sequence positions, no interleaving or internal crash prefix, and emits fact then operation-end events only after full success; every source/target/summary leaf-result row, exact generated preparation-before-move, no post-move regeneration or model requirement, no generic compaction prepared record, completion-winning label races without fact ids, hook usage and `fromHook: true`, generated `fromHook: false`, custom instructions, no structural assistant events, and non-null target existence validation. - -### Track O — observability and core completion - -These packages merge O1 → O2 → O3 → O4 after N1, with QA3 between O2 and O3. QA3 also requires J6. They may not modify `packages/coding-agent/**`. - -- [ ] **O1 — snapshots and event completeness.** Dependencies: N1, I2. - - Finish live lane/session snapshots, exact lane-versus-global event filtering, assistant drafts retained through the `message_end`-to-`entry_added` window, running-tool state, abort snapshots/results with no synthetic assistant requirement, `entry_added` for every committed entry, structural typed-entry events without internal summary-stream message events, and all section 10 event insertion points. - - Acceptance: event nesting/order tests cover passive post-hook values, direct/tool-result lifecycles, `message_end` without a commit guarantee, and `entry_added` confirmation; attach-mid-operation and attach-between-end-and-commit snapshot tests have no subscription gap. -- [ ] **O2 — runtime telemetry instrumentation.** Dependencies: O1, I0. - - Extend the landed harness schema only where the final execution model requires it: add stable `pi.step.id`, add `deferred_fetch` to the step kinds, reconcile the final step outcomes, add `multi` for one atomic append containing several logical mutations, and regenerate the schema reference. Do not otherwise redesign the landed schema. - - Insert operation/checkpoint/turn/attempt wrappers at their procedure scopes, effect and passive-handler spans at their owning boundaries with `startHarnessSpan()`, and logical model-request spans with `startAiSpan()`. Every provider-attempt span carries its durable `pi.step.id`, numbered attempt, and final kind including `deferred_fetch`; hook-sourced structural steps and post-move prepared-result completion emit no fabricated attempt span. Populate only schema-declared attributes, including parallel tool children, resumed operation correlation, marker-backed active-attempt abort, unmarked `aborted` interruption retry/failure, and abort-only recovery with no fabricated provider/step span; expected in-band failures set error status explicitly. - - Acceptance: the generated schema reference is current; captured telemetry has exact schema-conforming span trees for success, failure, suspend/resume, retry, compaction, and parallel tools; every emitted start/end/event bag conforms independently, callback spans settle exactly once, and no undeclared names, content, or secrets appear in defaults. -- [ ] **O3 — action-prefix and race audit.** Dependencies: O2, QA3. - - Complete Tier C for every race row, mechanically reopen every live action prefix and every prefix created by an individual recovery write, compare automatic/manual logs, and verify reducer/live-state fixed points. - - Acceptance: every race row has both orders, every durable/effect boundary has a reopen case, and no recovery write can occur without a close/reopen continuation test from its resulting prefix. -- [ ] **O4 — backend parity and final core audit.** Dependencies: J6, O3. - - Run the complete storage/recovery matrix across memory, JSONL, and SQLite, including one/many atomic append parity and expanded logs, exact batched entry reads, custom fact null/deletion, configured lane and labeled-finish arrays, coherent forks, JSONL object/array replay and close drain, and SQLite fenced lease release; remove dead agent/storage declarations and compatibility comments; verify exports/declarations and `./node`; update changelogs and core documentation. - - Acceptance: all non-e2e tests and `npm run check` pass, backend lifecycle/fact/fork conformance agrees, no active harness operation remains scaffolded, `packages/coding-agent/**` is unchanged, and the worktree is clean. - -### Dependency, priority, and merge summary - -The storage/reducer foundations join at D0: **R0 → J0 → J1 → J2 → J3**, **R0 → R1 → R2**, then **R2 + J3 → D0**. After D0, storage continues **D0 → J4 → J5 → J6** and restore continues **F0 + D0 → R3**. The loop lane is **I0 → L1 → L2 → L3**. The effects lane is **D0 → I3 → I4 → I5**, with I4 also requiring I0, I1, and L3. Before H0, the integration gate is **F0 + D0 + R3 + I2 + I5**. - -The runtime merge lane is strictly **H0 → H1 → H2 → H3 → H4 → H5 → H6 → H7 → H8 → C1 → C2 → C3 → N1 → O1 → O2 → QA3 → O3 → O4**. J6 may land independently at any time before QA3. This ordering prevents concurrent rewrites of `agent-harness.ts`, assigns every public method, and ensures every live path lands only after its reducer, telemetry, interception, and effect boundaries exist. - -## 21. Required reading - -For a fresh implementation session, in this order. This document wins over older harness designs. - -1. `packages/agent/docs/harness-v2.md` — this document. -2. `packages/agent/src/harness/session/types.ts` — v4 entries, records, storage, and repository contracts. -3. `packages/agent/src/harness/session/session.ts` — session validation and lane-bound views. -4. `packages/agent/src/harness/session/memory.ts` — reference backend. -5. `packages/session-backends/sqlite-node/src/sqlite/repo.ts` — v4 SQLite repository, leases, and forks. -6. `packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts` — branch cache queries. -7. `packages/agent/src/harness/agent-harness.ts` — public harness API and runtime. -8. `packages/telemetry/src/index.ts` — canonical telemetry contract, schema machinery, typed starter, and public exports. -9. `packages/telemetry/src/noop.ts`, `memory.ts`, and `testing/` — no-op/reference contexts and reusable conformance cases. -10. `packages/agent/src/harness/telemetry.ts` — AI-request and harness schemas, combined schema tuple, and typed helpers. -11. `packages/agent/src/agent-loop.ts` — agent-loop implementation and section 14 building blocks. -12. `packages/agent/src/agent.ts` — queues, continuation, abort, settlement to preserve in spirit. -13. `packages/agent/src/harness/messages.ts` — message conversion (`toProviderMessages` default). -14. `packages/agent/src/harness/compaction/compaction.ts` — preparation and split-turn summaries. -15. `packages/ai/src/utils/transform-messages.ts` — orphaned-tool-call healing. -16. `packages/coding-agent/src/core/agent-session.ts` — read-only behavioral reference; do not modify it. -17. `packages/coding-agent/src/core/extensions/runner.ts` — read-only error-isolation reference; do not modify it. -18. `packages/coding-agent/docs/session-format.md` — read-only v3 JSONL format reference. diff --git a/packages/agent/docs/harness-v3.md b/packages/agent/docs/harness.md similarity index 99% rename from packages/agent/docs/harness-v3.md rename to packages/agent/docs/harness.md index b169dd30c4d..5182c08a907 100644 --- a/packages/agent/docs/harness-v3.md +++ b/packages/agent/docs/harness.md @@ -1,6 +1,4 @@ -# AgentHarness v3 — implementation specification - -This document supersedes `agent-harness-spec.md` in full, which itself superseded `harness-v2.md`. +# AgentHarness — implementation specification # Part 0 — Orientation From 40a3d8556ab7fb4a6b4da20ffe1f5dfc08ec121d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 15:31:27 +0200 Subject: [PATCH 135/284] docs(agent): add table of contents --- packages/agent/docs/harness.md | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index 5182c08a907..9e38c1fab7e 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -1,5 +1,80 @@ # AgentHarness — implementation specification +- [Part 0 — Orientation](#part-0--orientation) + - [0.1 What this is](#01-what-this-is) + - [0.2 System model](#02-system-model) + - [0.3 The three stores](#03-the-three-stores) + - [0.4 Worked example — a Slack thread](#04-worked-example--a-slack-thread) + - [0.5 Worked example — a crash mid-tool](#05-worked-example--a-crash-mid-tool) + - [0.6 Non-goals](#06-non-goals) + - [0.7 Notation and source types](#07-notation-and-source-types) +- [Part 1 — Storage](#part-1--storage) + - [1.1 The model](#11-the-model) + - [1.2 Identity](#12-identity) + - [1.3 Register namespaces](#13-register-namespaces) + - [1.4 Transactions](#14-transactions) + - [1.5 Queries](#15-queries) + - [1.6 Usage ledger](#16-usage-ledger) + - [1.7 Backends](#17-backends) + - [1.8 Why write-once plus registers](#18-why-write-once-plus-registers) +- [Part 2 — The conversation tree](#part-2--the-conversation-tree) + - [2.1 Entries](#21-entries) + - [2.2 Placement](#22-placement) + - [2.3 Lanes](#23-lanes) + - [2.4 Facts](#24-facts) + - [2.5 Branch queries and context](#25-branch-queries-and-context) + - [2.6 The branch index](#26-the-branch-index) + - [2.7 Forks](#27-forks) + - [2.8 Session and repository boundary](#28-session-and-repository-boundary) + - [2.9 The precise rewrite](#29-the-precise-rewrite) +- [Part 3 — The operation state machine](#part-3--the-operation-state-machine) + - [3.1 Operations](#31-operations) + - [3.2 Operation state — the program counter](#32-operation-state--the-program-counter) + - [3.3 Lane state and current-state validity](#33-lane-state-and-current-state-validity) + - [3.4 The atomic transition rule](#34-the-atomic-transition-rule) + - [3.5 The graph](#35-the-graph) + - [3.6 Acceptance](#36-acceptance) + - [3.7 Assistant generation](#37-assistant-generation) + - [3.8 Tools](#38-tools) + - [3.9 Summary generation — compaction and navigation summaries](#39-summary-generation--compaction-and-navigation-summaries) + - [3.10 Navigation](#310-navigation) + - [3.11 Inbox, queues, deferred writes](#311-inbox-queues-deferred-writes) + - [3.12 The checkpoint procedure](#312-the-checkpoint-procedure) + - [3.13 Terminal transactions](#313-terminal-transactions) +- [Part 4 — Execution, recovery, abort, close](#part-4--execution-recovery-abort-close) + - [4.1 The interpreter](#41-the-interpreter) + - [4.2 The effects boundary](#42-the-effects-boundary) + - [4.3 The lane mutation line](#43-the-lane-mutation-line) + - [4.4 Restore](#44-restore) + - [4.5 Crash positions and recovery policy](#45-crash-positions-and-recovery-policy) + - [4.6 Abort](#46-abort) + - [4.7 Close — a controlled crash](#47-close--a-controlled-crash) + - [4.8 Faults](#48-faults) + - [4.9 External finalization](#49-external-finalization) +- [Part 5 — Public surface](#part-5--public-surface) + - [5.1 The lane surface](#51-the-lane-surface) + - [5.2 The harness](#52-the-harness) + - [5.3 SessionTree](#53-sessiontree) + - [5.4 Snapshots and subscription](#54-snapshots-and-subscription) + - [5.5 Events](#55-events) + - [5.6 Hooks](#56-hooks) + - [5.7 Agent-loop building blocks](#57-agent-loop-building-blocks) + - [5.8 Telemetry](#58-telemetry) +- [Part 6 — Future: partitioned retention (Postgres)](#part-6--future-partitioned-retention-postgres) +- [Part 7 — Schema evolution](#part-7--schema-evolution) + - [7.1 The problem](#71-the-problem) + - [7.2 Why this design shrinks the problem](#72-why-this-design-shrinks-the-problem) + - [7.3 The mechanism: storage version plus migrate-on-open](#73-the-mechanism-storage-version-plus-migrate-on-open) + - [7.4 Migrations are total](#74-migrations-are-total) + - [7.5 The three strata, restated as policy](#75-the-three-strata-restated-as-policy) +- [Part 8 — Build order](#part-8--build-order) +- [Part 9 — Invariants and tests](#part-9--invariants-and-tests) + - [9.1 Invariants](#91-invariants) + - [9.2 Race catalog](#92-race-catalog) + - [9.3 Test tiers](#93-test-tiers) +- [Appendix A — Glossary](#appendix-a--glossary) +- [Appendix B — Coding-agent v3-format compatibility](#appendix-b--coding-agent-v3-format-compatibility) +- [Appendix C — Open questions](#appendix-c--open-questions) # Part 0 — Orientation ## 0.1 What this is From 2a9b4ebc680053c64e31f635b0b22d5e22564001 Mon Sep 17 00:00:00 2001 From: Michael Renner Date: Tue, 11 Aug 2026 16:57:05 +0200 Subject: [PATCH 136/284] docs: document terminal-specific fullscreen mouse behavior (#7965) * docs(coding-agent): document iTerm2 fullscreen scroll workaround * docs(coding-agent): document Ghostty fullscreen link behavior --- packages/coding-agent/docs/keybindings.md | 2 +- packages/coding-agent/docs/terminal-setup.md | 25 ++++++++++++++++++-- packages/coding-agent/docs/usage.md | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index c693346a162..2f833dc513f 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -86,7 +86,7 @@ The dedicated history actions always change history entries, regardless of the c ### TUI Fullscreen Viewport -These actions apply when interactive mode uses `--tui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content. +These actions apply when interactive mode uses `--tui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content. See [Terminal setup](terminal-setup.md) for terminal-specific mouse and trackpad behavior. Fullscreen transcript bindings take precedence over editor bindings. The default unmodified navigation keys therefore control the transcript in fullscreen mode, while their `ctrl` variants continue to control the editor. Outside fullscreen mode, both variants control the editor. diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index d2a8fb92cca..08839256847 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -2,9 +2,26 @@ Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for reliable modifier key detection. Most modern terminals support this protocol, but some require configuration. -## Kitty, iTerm2 +## Kitty -Work out of the box. +Works out of the box. + +## iTerm2 + +### Regular TUI mode + +Works out of the box. + +### Fullscreen TUI mode + +Pi owns the viewport, so iTerm2 sends mouse-wheel reports instead of scrolling its native scrollback. With iTerm2's default fast-trackpad behavior, those reports can lose most of an accelerated wheel delta, making fullscreen scrolling much slower than regular scrolling. + +If fast mouse-wheel gestures move only about one line at a time in fullscreen mode: + +1. Open **iTerm2 → Settings → Advanced**. +2. Search for **Trackpad scrolls fast?** and set it to **No**. + +This is an iTerm2-wide workaround and may also change native trackpad scrolling. The underlying behavior is tracked in [iTerm2 issue 9619](https://gitlab.com/gnachman/iterm2/-/work_items/9619). ## Apple Terminal @@ -32,6 +49,10 @@ If Claude Code 2.x or newer is the only reason you added that mapping, you can r Pi binds `Ctrl+J` as a default newline alias, so `Shift+Enter` keeps working in tmux via that remap without extra pi configuration. +### Fullscreen TUI mode + +In fullscreen mode, links remain clickable, but Ghostty does not show its hover underline or lower-left URL preview while pi captures mouse input. Hold `Shift+Command` on macOS or `Shift+Ctrl` on Linux to use Ghostty's native link handling. + ## WezTerm WezTerm usually works out of the box for `Shift+Enter` via xterm modifyOtherKeys. To use the Kitty keyboard protocol explicitly, create `~/.wezterm.lua`: diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 5104b879044..397ab2812be 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -248,7 +248,7 @@ pi --no-extensions -e ./my-extension.ts | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | -In `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally. +In `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally. See [Terminal setup](terminal-setup.md) for terminal-specific settings and workarounds. Set **TUI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. **Fullscreen exit output** controls whether exiting fullscreen prints the final transcript or restores the previous screen and prints only the session resume hint. From 534bcbffb7e1e7551d9ee3572dfeb278e203e493 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 11 Aug 2026 23:19:24 +0200 Subject: [PATCH 137/284] fix(tui): handle LaTeX control spaces across line endings --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/latex.ts | 7 +++++++ packages/tui/test/latex.test.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5a23149f43f..9f414e921b2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -15,6 +15,7 @@ - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented SGR mouse input leaking into the search query. - Fixed required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). +- Fixed LaTeX control spaces split across line endings causing complete expressions to fall back to raw source. ## [0.84.1] - 2026-08-07 diff --git a/packages/tui/src/latex.ts b/packages/tui/src/latex.ts index 74cbd060acc..3170bf596a4 100644 --- a/packages/tui/src/latex.ts +++ b/packages/tui/src/latex.ts @@ -920,6 +920,13 @@ class LatexParser { let command = ""; const first = this.source[this.position] ?? ""; + if (first === "\n" || first === "\r") { + this.position++; + if (first === "\r" && this.source[this.position] === "\n") { + this.position++; + } + return " "; + } if (/[A-Za-z]/.test(first)) { const start = this.position; while (this.position < this.source.length && /[A-Za-z]/.test(this.source[this.position] ?? "")) { diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts index a9aca930b62..609bb0d64f8 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/tui/test/latex.test.ts @@ -415,6 +415,18 @@ R\left(\frac{\pi}{4}\right) assert.strictEqual(renderLatex(String.raw`\det(A)`), "det(A)"); }); + it("treats a backslash followed by a line ending as control space", () => { + const source = String.raw`\boxed{ +(1,1,1),\ (1,1,2),\ (1,2,5),\ (1,5,13),\ (2,5,29),\ +(1,13,34),\ (1,34,89) +}.`; + assert.strictEqual( + renderLatex(source, { display: true }), + "[(1,1,1), (1,1,2), (1,2,5), (1,5,13), (2,5,29), (1,13,34), (1,34,89)].", + ); + assert.strictEqual(renderLatex("a\\\r\nb"), "a b"); + }); + it("stacks operator limits in display mode", () => { assert.strictEqual(renderLatex(String.raw`\sum_{i=0}^n x_i`, { display: true }), " n\n ∑ xᵢ\ni=0"); assert.strictEqual(renderLatex(String.raw`\min_{x\in X} f(x)`, { display: true }), "min f(x)\nx∈X"); From 2e4d23959485279aa2da1a45103de2ea22d46395 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 12 Aug 2026 10:02:32 +0200 Subject: [PATCH 138/284] fix(tui): give focused fullscreen overlays wheel and viewport keys Skip TuiAltScreen viewport consume for wheel and alt-screen scroll keys when a visible overlay has focus. Transcript search still owns those events so the transcript can move while the find box is open. fixes #7894 --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui-alt-screen.ts | 6 ++ packages/tui/src/tui.ts | 7 ++ packages/tui/test/tui-alt-screen.test.ts | 93 ++++++++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9f414e921b2..fd1257cc9ea 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -16,6 +16,7 @@ - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented SGR mouse input leaking into the search query. - Fixed required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). - Fixed LaTeX control spaces split across line endings causing complete expressions to fall back to raw source. +- Fixed focused fullscreen overlays not receiving mouse wheel or viewport scroll keys such as PageUp and PageDown ([#7894](https://github.com/earendil-works/pi/issues/7894)). ## [0.84.1] - 2026-08-07 diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index f4d64e0ad2f..25efa42fa96 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -525,6 +525,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flashes.flash(message, durationMs); } + private shouldDeferViewportInputToOverlay(): boolean { + return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true; + } + private handleViewportInput(data: string): { consume?: boolean } | undefined { if (data === FOCUS_OUT) { const hadActiveSelection = this.selectionPressActive; @@ -549,6 +553,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const wheelEvent = this.parseWheelEvent(data); if (wheelEvent) { + if (this.shouldDeferViewportInputToOverlay()) return undefined; this.routeWheel(wheelEvent); return { consume: true }; } @@ -582,6 +587,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return { consume: true }; } } + if (this.shouldDeferViewportInputToOverlay()) return undefined; if (keybindings.matches(data, "tui.altScreen.pageUp")) { if (!isRelease) { this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5242fa72b68..5172a9a142c 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -662,6 +662,13 @@ export abstract class TuiBase extends Container implements TUI { return this.overlayStack.some((o) => this.isOverlayVisible(o)); } + /** Check if the focused component is a visible overlay */ + protected isOverlayFocused(): boolean { + return this.overlayStack.some( + (entry) => entry.component === this.focusedComponent && this.isOverlayVisible(entry), + ); + } + /** Check if an overlay entry is currently visible */ private isOverlayVisible(entry: OverlayStackEntry): boolean { if (entry.hidden) return false; diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 06b7dc0d412..6c0fe28d4ab 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -19,6 +19,21 @@ import { VirtualTerminal } from "./virtual-terminal.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; +class InputOverlay { + focused = false; + inputs: string[] = []; + + handleInput(data: string): void { + this.inputs.push(data); + } + + render(): string[] { + return ["overlay"]; + } + + invalidate(): void {} +} + class RecordingTerminal extends VirtualTerminal { readonly events: Array<{ type: "write"; data: string } | { type: "start" } | { type: "stop" }> = []; @@ -1264,4 +1279,82 @@ describe("TuiAltScreen", () => { assert.ok(restoreEvent.data.indexOf("first") < restoreEvent.data.indexOf("sixth")); } }); + + it("gives wheel and viewport keys to a focused overlay", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + const overlay = new InputOverlay(); + tui.start(); + await terminal.waitForRender(); + const topBefore = tui.viewportTop; + const handle = tui.showOverlay(overlay); + await terminal.waitForRender(); + assert.strictEqual(overlay.focused, true); + + const wheel = "\x1b[<64;10;3M"; + const keys = ["\x1b[5~", "\x1b[6~", "\x1bOH", "\x1bOF", wheel]; + for (const key of keys) terminal.sendInput(key); + await terminal.waitForRender(); + + assert.deepStrictEqual(overlay.inputs, keys); + assert.strictEqual(tui.viewportTop, topBefore); + + handle.hide(); + await terminal.waitForRender(); + terminal.sendInput("\x1b[5~"); + await terminal.waitForRender(); + assert.ok(tui.viewportTop < topBefore); + tui.stop(); + }); + + it("keeps viewport scrolling when an overlay is not focused", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + const editor = new InputOverlay(); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + const topBefore = tui.viewportTop; + + const hidden = tui.showOverlay(new InputOverlay()); + hidden.setHidden(true); + const nonCapturing = new InputOverlay(); + tui.showOverlay(nonCapturing, { nonCapturing: true }); + const unfocused = new InputOverlay(); + const unfocusedHandle = tui.showOverlay(unfocused); + unfocusedHandle.unfocus(); + await terminal.waitForRender(); + assert.strictEqual(nonCapturing.focused, false); + assert.strictEqual(unfocused.focused, false); + + terminal.sendInput("\x1b[5~"); + terminal.sendInput("\x1b[<64;10;3M"); + await terminal.waitForRender(); + assert.ok(tui.viewportTop < topBefore); + assert.deepStrictEqual(nonCapturing.inputs, []); + assert.deepStrictEqual(unfocused.inputs, []); + tui.stop(); + }); + + it("keeps viewport scrolling while transcript search is focused", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + const topBefore = tui.viewportTop; + + terminal.sendInput("\x1b[102;6u"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript"))); + + terminal.sendInput("\x1b[5~"); + terminal.sendInput("\x1b[<64;1;4M"); + await terminal.waitForRender(); + assert.ok(tui.viewportTop < topBefore); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript"))); + tui.stop(); + }); }); From 6d520c58d0b92cf420ab3b95d05ea75c59235cca Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 12 Aug 2026 14:56:26 +0200 Subject: [PATCH 139/284] chore(docs): fix ascii alignment in compaction docs --- packages/coding-agent/docs/compaction.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 4bf01c9d21a..9595ca54310 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -48,7 +48,7 @@ You can also trigger manually with `/compact [instructions]`, where optional ins Before compaction: entry: 0 1 2 3 4 5 6 7 8 9 - ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┬─────┐ + ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘ └────────┬───────┘ └──────────────┬──────────────┘ @@ -59,7 +59,7 @@ Before compaction: After compaction (new entry appended): entry: 0 1 2 3 4 5 6 7 8 9 10 - ┌─────┬─────┬─────┬─────┬──────┬─────┬──── ─┬──────┬─────┬─────┬─────┐ + ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┬─────┐ │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │ └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘ └──────────┬──────┘ └──────────────────────┬───────────────────┘ From 47b5119d09f00cb785409d53d74c4950acc7d803 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:16:05 +0200 Subject: [PATCH 140/284] fix: trigger turn false should not start turn (#8022) --- packages/coding-agent/src/core/agent-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 179a5521932..c4dd457e226 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1449,7 +1449,7 @@ export class AgentSession { } satisfies CustomMessage; if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); - } else if (this.isStreaming) { + } else if (this.isStreaming && options?.triggerTurn !== false) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { From 4d9aa837c2ec6e0ebc7599f7e724c7c19c06441e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 12 Aug 2026 15:26:42 +0200 Subject: [PATCH 141/284] feat(coding-agent): add configurable default tools --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 16 ++++ packages/coding-agent/src/core/sdk.ts | 16 ++-- .../coding-agent/src/core/settings-manager.ts | 6 ++ .../test/default-tools-setting.test.ts | 86 +++++++++++++++++++ .../test/settings-manager.test.ts | 17 ++++ 6 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/test/default-tools-setting.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 95010b1fa66..4a0ac089b30 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. - Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`. - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. +- Added the `defaultTools` setting for configuring the initial tool allowlist globally or per project. ### Changed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index ec8aa26cc35..aa777f81f2e 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -200,6 +200,22 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic `npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. User-scoped npm packages install under `~/.pi/agent/npm/`; project-scoped npm packages install under `.pi/npm/`. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers. +### Tools + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `defaultTools` | string[] | - | Initial tool allowlist. When omitted, Pi uses its standard defaults | + +`defaultTools` applies to built-in, extension, and custom tools, like the `--tools` CLI option: + +```json +{ + "defaultTools": ["bash", "edit", "write"] +} +``` + +An empty array starts with no tools. `--tools`, `--no-tools`, and `--no-builtin-tools` override this setting; `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. + ### Sessions | Setting | Type | Default | Description | diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7f662d330c7..5d962e00ab9 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -62,9 +62,10 @@ export interface CreateAgentSessionOptions { /** * Optional allowlist of tool names. * - * When omitted, pi enables the default built-in tools (read, bash, edit, write) - * and leaves extension/custom tools enabled unless `noTools` changes that default. - * When provided, only the listed tool names are enabled. + * When omitted, pi uses the `defaultTools` setting when configured. Otherwise it + * enables the default built-in tools (read, bash, edit, write) and leaves + * extension/custom tools enabled unless `noTools` changes that default. When + * provided, only the listed tool names are enabled. */ tools?: string[]; /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ @@ -243,11 +244,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; - const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); + const configuredDefaultToolNames = settingsManager.getDefaultTools(); + const allowedToolNames = + options.tools ?? + (options.noTools === "all" ? [] : options.noTools === undefined ? configuredDefaultToolNames : undefined); const excludedToolNames = options.excludeTools; const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; - const initialActiveToolNames: string[] = ( - options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames + const initialActiveToolNames = ( + options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? defaultActiveToolNames)) ).filter((name) => !excludedToolNameSet?.has(name)); let agent: Agent; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index d18916a5c6d..b1fa113045f 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -120,6 +120,7 @@ export interface Settings { terminal?: TerminalSettings; images?: ImageSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) + defaultTools?: string[]; // Initial tool allowlist (same format as --tools CLI flag) doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels @@ -1188,6 +1189,11 @@ export class SettingsManager { return this.settings.enabledModels; } + getDefaultTools(): string[] | undefined { + const tools = this.settings.defaultTools; + return tools ? [...tools] : undefined; + } + setEnabledModels(patterns: string[] | undefined): void { this.globalSettings.enabledModels = patterns; this.markModified("enabledModels"); diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts new file mode 100644 index 00000000000..f3130ec215f --- /dev/null +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -0,0 +1,86 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createAgentSessionFromServices, createAgentSessionServices } from "../src/core/agent-session-services.ts"; +import { DefaultResourceLoader } from "../src/core/resource-loader.ts"; +import { type CreateAgentSessionOptions, createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +type ToolOptions = Pick; + +describe("defaultTools setting", () => { + let tempDir: string; + let agentDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-default-tools-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + mkdirSync(agentDir, { recursive: true }); + }); + + afterEach(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + async function createSession(defaultTools: string[], options: ToolOptions = {}) { + const settingsManager = SettingsManager.inMemory({ defaultTools }); + const resourceLoader = new DefaultResourceLoader({ cwd: tempDir, agentDir, settingsManager }); + await resourceLoader.reload(); + + return ( + await createAgentSession({ + cwd: tempDir, + agentDir, + model: getModel("anthropic", "claude-sonnet-4-5")!, + settingsManager, + sessionManager: SessionManager.inMemory(tempDir), + resourceLoader, + ...options, + }) + ).session; + } + + it("uses the configured list as the initial tool allowlist", async () => { + const session = await createSession(["grep", "find"]); + + expect(session.getAllTools().map((tool) => tool.name)).toEqual(["grep", "find"]); + expect(session.getActiveToolNames()).toEqual(["grep", "find"]); + expect(session.systemPrompt).toContain("- grep:"); + expect(session.systemPrompt).not.toContain("- read:"); + session.dispose(); + }); + + it("preserves explicit tool option precedence", async () => { + const allowlistedSession = await createSession(["grep"], { tools: ["read"] }); + expect(allowlistedSession.getActiveToolNames()).toEqual(["read"]); + allowlistedSession.dispose(); + + const excludedSession = await createSession(["read", "grep"], { excludeTools: ["read"] }); + expect(excludedSession.getActiveToolNames()).toEqual(["grep"]); + excludedSession.dispose(); + + const toolLessSession = await createSession(["read"], { noTools: "all" }); + expect(toolLessSession.getAllTools()).toEqual([]); + expect(toolLessSession.getActiveToolNames()).toEqual([]); + toolLessSession.dispose(); + }); + + it("applies through service-based session creation", async () => { + const settingsManager = SettingsManager.inMemory({ defaultTools: ["ls"] }); + const services = await createAgentSessionServices({ cwd: tempDir, agentDir, settingsManager }); + const { session } = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.inMemory(tempDir), + model: getModel("anthropic", "claude-sonnet-4-5")!, + }); + + expect(session.getAllTools().map((tool) => tool.name)).toEqual(["ls"]); + expect(session.getActiveToolNames()).toEqual(["ls"]); + session.dispose(); + }); +}); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 6a223fd00dd..638a70f2329 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -526,6 +526,23 @@ describe("SettingsManager", () => { }); }); + describe("defaultTools", () => { + it("loads global defaults and lets project settings replace them", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultTools: ["read", "bash"] })); + + expect(SettingsManager.create(projectDir, agentDir).getDefaultTools()).toEqual(["read", "bash"]); + + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultTools: ["grep"] })); + + expect(SettingsManager.create(projectDir, agentDir).getDefaultTools()).toEqual(["grep"]); + }); + + it("preserves an empty tool list", () => { + expect(SettingsManager.inMemory({ defaultTools: [] }).getDefaultTools()).toEqual([]); + expect(SettingsManager.inMemory().getDefaultTools()).toBeUndefined(); + }); + }); + describe("getSessionDir", () => { it("should return undefined when not set", () => { writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark" })); From 541045ae0e30ac0375fde429f8a8baa057c009db Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 12 Aug 2026 15:42:55 +0200 Subject: [PATCH 142/284] fix(coding-agent): preserve extension tools with defaults --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/docs/settings.md | 6 +- packages/coding-agent/src/core/sdk.ts | 13 ++-- .../coding-agent/src/core/settings-manager.ts | 2 +- .../test/default-tools-setting.test.ts | 78 +++++++++++++++++-- 5 files changed, 82 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4a0ac089b30..27ec5cbd290 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,7 +7,7 @@ - Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. - Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`. - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. -- Added the `defaultTools` setting for configuring the initial tool allowlist globally or per project. +- Added the `defaultTools` setting for configuring the initial built-in tool selection globally or per project. ### Changed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index aa777f81f2e..cb48caee98a 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -204,9 +204,9 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `defaultTools` | string[] | - | Initial tool allowlist. When omitted, Pi uses its standard defaults | +| `defaultTools` | string[] | - | Built-in tools enabled initially. When omitted, Pi uses its standard defaults | -`defaultTools` applies to built-in, extension, and custom tools, like the `--tools` CLI option: +`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled: ```json { @@ -214,7 +214,7 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic } ``` -An empty array starts with no tools. `--tools`, `--no-tools`, and `--no-builtin-tools` override this setting; `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. +An empty array starts with no built-in tools while preserving extension and SDK custom tools. `--tools` replaces this behavior with a strict allowlist for all tools, `--no-tools` disables all tools, and `--no-builtin-tools` disables the built-in defaults. `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. ### Sessions diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 5d962e00ab9..a9a26641868 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -62,10 +62,11 @@ export interface CreateAgentSessionOptions { /** * Optional allowlist of tool names. * - * When omitted, pi uses the `defaultTools` setting when configured. Otherwise it - * enables the default built-in tools (read, bash, edit, write) and leaves - * extension/custom tools enabled unless `noTools` changes that default. When - * provided, only the listed tool names are enabled. + * When omitted, pi uses the `defaultTools` setting for the initial built-in + * selection when configured. Otherwise it enables the default built-in tools + * (read, bash, edit, write). Extension/custom tools remain enabled unless + * `noTools` changes that default. When provided, only the listed tool names are + * enabled. */ tools?: string[]; /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ @@ -245,9 +246,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const configuredDefaultToolNames = settingsManager.getDefaultTools(); - const allowedToolNames = - options.tools ?? - (options.noTools === "all" ? [] : options.noTools === undefined ? configuredDefaultToolNames : undefined); + const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); const excludedToolNames = options.excludeTools; const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; const initialActiveToolNames = ( diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index b1fa113045f..9a744f0270b 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -120,7 +120,7 @@ export interface Settings { terminal?: TerminalSettings; images?: ImageSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) - defaultTools?: string[]; // Initial tool allowlist (same format as --tools CLI flag) + defaultTools?: string[]; // Initial built-in tool selection doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index f3130ec215f..69adbceb572 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -2,14 +2,15 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai/compat"; +import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createAgentSessionFromServices, createAgentSessionServices } from "../src/core/agent-session-services.ts"; import { DefaultResourceLoader } from "../src/core/resource-loader.ts"; -import { type CreateAgentSessionOptions, createAgentSession } from "../src/core/sdk.ts"; +import { type CreateAgentSessionOptions, createAgentSession, type InlineExtension } from "../src/core/sdk.ts"; import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; -type ToolOptions = Pick; +type ToolOptions = Pick; describe("defaultTools setting", () => { let tempDir: string; @@ -27,9 +28,18 @@ describe("defaultTools setting", () => { } }); - async function createSession(defaultTools: string[], options: ToolOptions = {}) { + async function createSession( + defaultTools: string[], + options: ToolOptions = {}, + extensionFactories: InlineExtension[] = [], + ) { const settingsManager = SettingsManager.inMemory({ defaultTools }); - const resourceLoader = new DefaultResourceLoader({ cwd: tempDir, agentDir, settingsManager }); + const resourceLoader = new DefaultResourceLoader({ + cwd: tempDir, + agentDir, + settingsManager, + extensionFactories, + }); await resourceLoader.reload(); return ( @@ -45,16 +55,65 @@ describe("defaultTools setting", () => { ).session; } - it("uses the configured list as the initial tool allowlist", async () => { + it("uses the configured list as the initial built-in selection", async () => { const session = await createSession(["grep", "find"]); - expect(session.getAllTools().map((tool) => tool.name)).toEqual(["grep", "find"]); + expect( + session + .getAllTools() + .map((tool) => tool.name) + .sort(), + ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["grep", "find"]); expect(session.systemPrompt).toContain("- grep:"); expect(session.systemPrompt).not.toContain("- read:"); session.dispose(); }); + it("keeps extension and SDK custom tools enabled", async () => { + const session = await createSession( + ["grep"], + { + customTools: [ + { + name: "sdk_tool", + label: "SDK Tool", + description: "SDK custom tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }, + ], + }, + [ + (pi) => { + pi.registerTool({ + name: "static_tool", + label: "Static Tool", + description: "Statically registered extension tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + pi.on("session_start", () => { + pi.registerTool({ + name: "dynamic_tool", + label: "Dynamic Tool", + description: "Dynamically registered extension tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + }); + }, + ], + ); + await session.bindExtensions({}); + + expect(session.getActiveToolNames().sort()).toEqual(["dynamic_tool", "grep", "sdk_tool", "static_tool"]); + expect(session.getAllTools().map((tool) => tool.name)).toEqual( + expect.arrayContaining(["read", "dynamic_tool", "sdk_tool", "static_tool"]), + ); + session.dispose(); + }); + it("preserves explicit tool option precedence", async () => { const allowlistedSession = await createSession(["grep"], { tools: ["read"] }); expect(allowlistedSession.getActiveToolNames()).toEqual(["read"]); @@ -79,7 +138,12 @@ describe("defaultTools setting", () => { model: getModel("anthropic", "claude-sonnet-4-5")!, }); - expect(session.getAllTools().map((tool) => tool.name)).toEqual(["ls"]); + expect( + session + .getAllTools() + .map((tool) => tool.name) + .sort(), + ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["ls"]); session.dispose(); }); From 9795d602306ef68a97585909e8e79f92a389057b Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Wed, 12 Aug 2026 16:25:38 +0200 Subject: [PATCH 143/284] feat(coding-agent): add per-run theme selection (#7722) - add `--use-theme` for fixed and terminal-aware theme pairs - keep invocation state non-persistent until an explicit selection - preserve active themes across previews, reloads, and HTML exports --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 1 + packages/coding-agent/docs/themes.md | 17 +++ packages/coding-agent/docs/usage.md | 1 + packages/coding-agent/src/cli/args.ts | 10 ++ .../coding-agent/src/core/agent-session.ts | 8 +- packages/coding-agent/src/main.ts | 5 + .../src/modes/interactive/interactive-mode.ts | 25 ++-- .../interactive/theme/theme-controller.ts | 49 +++++-- packages/coding-agent/test/args.test.ts | 14 ++ .../test/theme-controller.test.ts | 125 ++++++++++++++++++ 11 files changed, 232 insertions(+), 24 deletions(-) create mode 100644 packages/coding-agent/test/theme-controller.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 27ec5cbd290..3da3c603085 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`. - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. - Added the `defaultTools` setting for configuring the initial built-in tool selection globally or per project. +- Added `--use-theme ` to choose an initial per-run interactive theme without changing saved settings ([#7722](https://github.com/earendil-works/pi/pull/7722) by [@rwachtler](https://github.com/rwachtler)). ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e4e76e29626..e6e43c2eb8b 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -609,6 +609,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set | `--system-prompt ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | Append to system prompt | | `--tui-mode ` | TUI mode: `regular` (default) or experimental `fullscreen` | +| `--use-theme ` | Set the initial interactive theme for this run without changing settings | | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index f9855f87fae..99a2b949663 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -39,6 +39,23 @@ Select a theme via `/settings` or in `settings.json`: On first run, pi detects your terminal background and defaults to `dark` or `light`. +### Initial Theme + +Start an interactive run with a theme without changing the saved setting: + +```bash +pi --use-theme light +``` + +To follow terminal appearance, use `lightTheme/darkTheme` syntax: + +```bash +pi --use-theme light/dark +``` + +The CLI value is the initial theme for that run. Choosing another theme later in `/settings` applies it immediately +and saves it normally. + ## Creating a Custom Theme 1. Create a theme file: diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 397ab2812be..41a5c37d9b0 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -242,6 +242,7 @@ pi --no-extensions -e ./my-extension.ts | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | Append to system prompt | | `--tui-mode ` | TUI mode: `regular` (default) or experimental `fullscreen` | +| `--use-theme ` | Set the initial interactive theme for this run without changing settings | | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 5ea1ee8fbf6..3c0e01b3c0d 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -42,6 +42,7 @@ export interface Args { promptTemplates?: string[]; noPromptTemplates?: boolean; themes?: string[]; + useTheme?: string; noThemes?: boolean; noContextFiles?: boolean; listModels?: string | true; @@ -162,6 +163,14 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--theme" && i + 1 < args.length) { result.themes = result.themes ?? []; result.themes.push(args[++i]); + } else if (arg === "--use-theme") { + const themeName = args[i + 1]; + if (themeName === undefined || themeName.startsWith("-")) { + result.diagnostics.push({ type: "error", message: "--use-theme requires a theme name" }); + } else { + result.useTheme = themeName; + i++; + } } else if (arg === "--no-skills" || arg === "-ns") { result.noSkills = true; } else if (arg === "--no-prompt-templates" || arg === "-np") { @@ -283,6 +292,7 @@ ${chalk.bold("Options:")} --prompt-template Load a prompt template file or directory (can be used multiple times) --no-prompt-templates, -np Disable prompt template discovery and loading --theme Load a theme file or directory (can be used multiple times) + --use-theme Set the initial interactive theme for this run --no-themes Disable theme discovery and loading --no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading --export Export session file to HTML and exit diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c4dd457e226..1a1228d006d 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3220,11 +3220,13 @@ export class AgentSession { /** * Export session to HTML. * @param outputPath Optional output path (defaults to session directory) + * @param options Optional export presentation settings * @returns Path to exported file */ - async exportToHtml(outputPath?: string): Promise { - const configuredThemeName = this.settingsManager.getTheme(); - const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined; + async exportToHtml(outputPath?: string, options: { themeName?: string } = {}): Promise { + const themeName = [options.themeName, this.settingsManager.getTheme()].find( + (candidate) => candidate !== undefined && getThemeByName(candidate) !== undefined, + ); // Create tool renderer if we have an extension runner (for custom tool HTML rendering) const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({ diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 8db96a6706f..5ca03f3d8e0 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -665,6 +665,10 @@ export async function main(args: string[], options?: MainOptions) { time("firstTimeSetup"); } + if (appMode === "interactive" && parsed.useTheme !== undefined) { + startupSettingsManager.applyOverrides({ theme: parsed.useTheme }); + } + // Decide the final runtime cwd before creating cwd-bound runtime services. // --session and --resume may select a session from another project, so project-local // settings, resources, provider registrations, and models must be resolved only after @@ -933,6 +937,7 @@ export async function main(args: string[], options?: MainOptions) { initialMessages: parsed.messages, verbose: parsed.verbose, tuiMode: parsed.tuiMode, + initialThemeSetting: parsed.useTheme, }); if (startupBenchmark) { await interactiveMode.init(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 03d015a5af8..d5a1b915972 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -329,6 +329,8 @@ export interface InteractiveModeOptions { verbose?: boolean; /** TUI layout mode. */ tuiMode?: TuiMode; + /** Initial interactive theme setting for this invocation. */ + initialThemeSetting?: string; } interface InteractiveTuiOptions { @@ -538,6 +540,7 @@ export class InteractiveMode { }); this.runtimeHost.setRebindSession(async () => { await this.rebindCurrentSession({ renderBeforeBind: true }); + await this.themeController.applyFromSettings(); }); this.version = VERSION; this.renderer = createInteractiveTui({ @@ -582,12 +585,12 @@ export class InteractiveMode { // Register themes from resource loader and initialize setRegisteredThemes(this.session.resourceLoader.getThemes().themes); - this.themeController = new InteractiveThemeController( - this.ui, - this.settingsManager, - (message) => this.showError(message), - () => this.updateEditorBorderColor(), - ); + this.themeController = new InteractiveThemeController(this.ui, { + getSettingsManager: () => this.settingsManager, + showError: (message) => this.showError(message), + onChanged: () => this.updateEditorBorderColor(), + initialThemeSetting: options.initialThemeSetting, + }); } private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { @@ -4393,7 +4396,7 @@ export class InteractiveMode { httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), - currentTheme: this.settingsManager.getThemeSetting() || "dark", + currentTheme: this.themeController.getThemeSelection() || "dark", terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, @@ -4469,7 +4472,7 @@ export class InteractiveMode { }, onThemeChange: (themeSetting) => { this.settingsManager.setTheme(themeSetting); - void this.themeController.applyFromSettings(); + void this.themeController.setThemeSetting(themeSetting); }, onThemePreview: (themeName) => this.themeController.preview(themeName), onHideThinkingBlockChange: (hidden) => { @@ -5778,7 +5781,9 @@ export class InteractiveMode { const filePath = this.session.exportToJsonl(outputPath); this.showStatus(`Session exported to: ${filePath}`); } else { - const filePath = await this.session.exportToHtml(outputPath); + const filePath = await this.session.exportToHtml(outputPath, { + themeName: theme.name, + }); this.showStatus(`Session exported to: ${filePath}`); } } catch (error: unknown) { @@ -5875,7 +5880,7 @@ export class InteractiveMode { // Export to a temp file const tmpFile = path.join(os.tmpdir(), "session.html"); try { - await this.session.exportToHtml(tmpFile); + await this.session.exportToHtml(tmpFile, { themeName: theme.name }); } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts index 6d99b0ded12..0684ecaee50 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -17,20 +17,33 @@ type ThemeResult = { success: boolean; error?: string }; export class InteractiveThemeController { private readonly ui: TUI; - private readonly settingsManager: SettingsManager; + private readonly getSettingsManager: () => SettingsManager; private readonly showError: (message: string) => void; private readonly onChanged: () => void; + private currentThemeSetting: string | undefined; private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme; private activeThemeName: string | undefined; private autoSyncEnabled = false; private terminalColorSchemeUnsubscribe: (() => void) | undefined; - constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) { + constructor( + ui: TUI, + options: { + getSettingsManager: () => SettingsManager; + showError: (message: string) => void; + onChanged: () => void; + initialThemeSetting?: string; + }, + ) { this.ui = ui; - this.settingsManager = settingsManager; - this.showError = showError; - this.onChanged = onChanged; - this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme); + this.getSettingsManager = options.getSettingsManager; + this.showError = options.showError; + this.onChanged = options.onChanged; + this.currentThemeSetting = options.initialThemeSetting; + this.activeThemeName = resolveThemeSetting( + this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting(), + this.terminalTheme, + ); initTheme(this.activeThemeName, true); this.bindTerminalColorSchemeListener(); } @@ -42,7 +55,8 @@ export class InteractiveThemeController { } async applyFromSettings(): Promise { - const themeSetting = this.settingsManager.getThemeSetting(); + const settingsManager = this.getSettingsManager(); + const themeSetting = this.currentThemeSetting ?? settingsManager.getThemeSetting(); const autoTheme = parseAutoThemeSetting(themeSetting); if (autoTheme) { this.terminalTheme = await detectTerminalThemeForAuto({ ui: this.ui, timeoutMs: 100 }); @@ -61,14 +75,27 @@ export class InteractiveThemeController { this.terminalTheme = detection.theme; if (!this.applyThemeName(detection.theme).success) return; if (detection.confidence === "high") { - this.settingsManager.setTheme(detection.theme); - await this.settingsManager.flush(); + settingsManager.setTheme(detection.theme); + await settingsManager.flush(); } } + getThemeSelection(): string | undefined { + return this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting() ?? this.activeThemeName; + } + setThemeName(themeName: string, showError = false): ThemeResult { this.setAutoSync(false); - return this.applyThemeName(themeName, showError); + const result = this.applyThemeName(themeName, showError); + if (result.success) { + this.currentThemeSetting = themeName; + } + return result; + } + + async setThemeSetting(themeSetting: string): Promise { + this.currentThemeSetting = themeSetting; + await this.applyFromSettings(); } setThemeInstance(themeInstance: Theme): ThemeResult { @@ -126,7 +153,7 @@ export class InteractiveThemeController { private applyTerminalTheme(terminalTheme: TerminalTheme): void { if (!this.autoSyncEnabled) return; this.terminalTheme = terminalTheme; - const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting()); + const autoTheme = parseAutoThemeSetting(this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting()); if (!autoTheme) { this.setAutoSync(false); return; diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 575d3666e34..a8be4f8eb4d 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -260,6 +260,20 @@ describe("parseArgs", () => { }); }); + describe("--use-theme flag", () => { + test("parses --use-theme", () => { + const result = parseArgs(["--use-theme", "light"]); + expect(result.useTheme).toBe("light"); + }); + + test("reports when the theme name value is missing", () => { + const result = parseArgs(["--use-theme", "--print"]); + expect(result.useTheme).toBeUndefined(); + expect(result.print).toBe(true); + expect(result.diagnostics).toEqual([{ type: "error", message: "--use-theme requires a theme name" }]); + }); + }); + describe("--no-skills flag", () => { test("parses --no-skills flag", () => { const result = parseArgs(["--no-skills"]); diff --git a/packages/coding-agent/test/theme-controller.test.ts b/packages/coding-agent/test/theme-controller.test.ts new file mode 100644 index 00000000000..a20009d579f --- /dev/null +++ b/packages/coding-agent/test/theme-controller.test.ts @@ -0,0 +1,125 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { initTheme, type TerminalTheme, theme } from "../src/modes/interactive/theme/theme.ts"; +import { InteractiveThemeController } from "../src/modes/interactive/theme/theme-controller.ts"; + +function createUi() { + const queryTerminalBackgroundColor = vi.fn(); + const queryTerminalColorScheme = vi.fn(); + const setTerminalColorSchemeNotifications = vi.fn(); + let terminalColorSchemeListener: ((terminalTheme: TerminalTheme) => void) | undefined; + const ui = { + invalidate: vi.fn(), + requestRender: vi.fn(), + setTerminalColorSchemeNotifications, + onTerminalColorSchemeChange: vi.fn((listener: (terminalTheme: TerminalTheme) => void) => { + terminalColorSchemeListener = listener; + return vi.fn(); + }), + queryTerminalBackgroundColor, + queryTerminalColorScheme, + } as unknown as TUI; + return { + ui, + queryTerminalBackgroundColor, + queryTerminalColorScheme, + setTerminalColorSchemeNotifications, + emitTerminalColorScheme: (terminalTheme: TerminalTheme) => terminalColorSchemeListener?.(terminalTheme), + }; +} + +function createController(ui: TUI, getSettingsManager: () => SettingsManager, initialThemeSetting?: string) { + return new InteractiveThemeController(ui, { + getSettingsManager, + showError: vi.fn(), + onChanged: vi.fn(), + initialThemeSetting, + }); +} + +afterEach(() => { + initTheme("dark"); + vi.unstubAllEnvs(); +}); + +describe("InteractiveThemeController", () => { + it("uses the initial theme without persisting it", async () => { + const { ui, queryTerminalBackgroundColor } = createUi(); + const manager = SettingsManager.inMemory({ theme: "dark" }); + const setTheme = vi.spyOn(manager, "setTheme"); + const flush = vi.spyOn(manager, "flush"); + const controller = createController(ui, () => manager, "light"); + + expect(theme.name).toBe("light"); + expect(controller.getThemeSelection()).toBe("light"); + await controller.applyFromSettings(); + + expect(queryTerminalBackgroundColor).not.toHaveBeenCalled(); + expect(setTheme).not.toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); + }); + + it("resolves a theme pair and follows terminal appearance changes", async () => { + vi.stubEnv("COLORFGBG", "15;0"); + const { ui, queryTerminalColorScheme, setTerminalColorSchemeNotifications, emitTerminalColorScheme } = createUi(); + queryTerminalColorScheme.mockResolvedValue("light"); + const manager = SettingsManager.inMemory({ theme: "dark/light" }); + const controller = createController(ui, () => manager, "light/dark"); + + expect(theme.name).toBe("dark"); + await controller.applyFromSettings(); + expect(theme.name).toBe("light"); + expect(setTerminalColorSchemeNotifications).toHaveBeenCalledWith(true); + + emitTerminalColorScheme("dark"); + expect(theme.name).toBe("dark"); + }); + + it("detects the current terminal appearance when selecting a theme pair", async () => { + vi.stubEnv("COLORFGBG", ""); + const { ui, queryTerminalColorScheme } = createUi(); + queryTerminalColorScheme.mockResolvedValue("light"); + const manager = SettingsManager.inMemory({ theme: "dark" }); + const controller = createController(ui, () => manager); + + expect(theme.name).toBe("dark"); + await controller.setThemeSetting("light/dark"); + expect(theme.name).toBe("light"); + expect(queryTerminalColorScheme).toHaveBeenCalledOnce(); + }); + + it("lets an explicit selection replace the initial theme", async () => { + const { ui } = createUi(); + const firstManager = SettingsManager.inMemory({ theme: "dark" }); + const secondManager = SettingsManager.inMemory({ theme: "light" }); + let manager = firstManager; + const controller = createController(ui, () => manager, "light"); + await controller.applyFromSettings(); + + expect(controller.setThemeName("dark")).toEqual({ success: true }); + manager = secondManager; + await controller.applyFromSettings(); + + expect(controller.getThemeSelection()).toBe("dark"); + expect(theme.name).toBe("dark"); + }); + + it("reloads theme settings when no initial theme was supplied", async () => { + const { ui } = createUi(); + const firstManager = SettingsManager.inMemory({ theme: "dark" }); + const secondManager = SettingsManager.inMemory({ theme: "light" }); + let manager = firstManager; + const controller = createController(ui, () => manager); + await controller.applyFromSettings(); + + firstManager.applyOverrides({ theme: "light" }); + await controller.applyFromSettings(); + expect(theme.name).toBe("light"); + + secondManager.applyOverrides({ theme: "dark" }); + manager = secondManager; + await controller.applyFromSettings(); + expect(theme.name).toBe("dark"); + }); +}); From c93ea6ccf0a398c293641e8001db06b8f7997c79 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Thu, 13 Aug 2026 00:32:26 +0300 Subject: [PATCH 144/284] fix(coding-agent): preserve usage in streaming events (#7982) Fixes #7911 --- packages/coding-agent/docs/json.md | 10 +++--- packages/coding-agent/docs/rpc.md | 19 ++++++++--- packages/coding-agent/src/modes/json-event.ts | 12 +++++-- .../7911-json-stream-usage.test.ts | 32 +++++++++++++++++++ 4 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7911-json-stream-usage.test.ts diff --git a/packages/coding-agent/docs/json.md b/packages/coding-agent/docs/json.md index f497f340640..ddb9cb3054d 100644 --- a/packages/coding-agent/docs/json.md +++ b/packages/coding-agent/docs/json.md @@ -19,6 +19,7 @@ type JsonAgentSessionEvent = | Exclude | { type: "message_update"; + usage: Usage; assistantMessageEvent: WithoutPartial; }; ``` @@ -73,16 +74,17 @@ Followed by events as they occur: {"type":"agent_start"} {"type":"turn_start"} {"type":"message_start","message":{"role":"assistant","content":[],...}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} {"type":"message_end","message":{...}} {"type":"turn_end","message":{...},"toolResults":[]} {"type":"agent_end","messages":[...]} ``` `message_update` records are delta-only. They omit both the cumulative `message` field and -`assistantMessageEvent.partial` to keep stream size linear. Use `contentIndex` and `delta` -to assemble live text, thinking, or tool-call arguments if needed. `message_end` contains -the final authoritative message. +`assistantMessageEvent.partial` to keep stream size linear. The top-level `usage` field contains +the latest cumulative provider-reported usage and may remain zero when a provider only reports +usage at completion. Use `contentIndex` and `delta` to assemble live text, thinking, or tool-call +arguments if needed. `message_end` contains the final authoritative message. ## Example diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 499ca673b76..fc4c69867e5 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -919,6 +919,14 @@ Emitted during streaming of assistant messages. Contains a delta event without a ```json { "type": "message_update", + "usage": { + "input": 100, + "output": 1, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 101, + "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0} + }, "assistantMessageEvent": { "type": "text_delta", "contentIndex": 0, @@ -943,12 +951,15 @@ The `assistantMessageEvent` field contains one of these delta types: Example streaming a text response: ```json -{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}} -{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_start","contentIndex":0}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}} ``` +The top-level `usage` field contains the latest cumulative provider-reported usage. It may remain +zero until completion when a provider does not report usage during streaming. + `message_update` intentionally omits the former cumulative `message` field and `assistantMessageEvent.partial`. Clients that need a live partial message must assemble it from `message_start` and subsequent events using `contentIndex`. Treat `message_end.message` diff --git a/packages/coding-agent/src/modes/json-event.ts b/packages/coding-agent/src/modes/json-event.ts index 6cff8ebf27f..f300b17a91c 100644 --- a/packages/coding-agent/src/modes/json-event.ts +++ b/packages/coding-agent/src/modes/json-event.ts @@ -1,3 +1,4 @@ +import type { Usage } from "@earendil-works/pi-ai"; import type { AgentSessionEvent } from "../core/agent-session.ts"; type WithoutPartial = T extends { partial: unknown } ? Omit : T; @@ -8,6 +9,7 @@ type ToJsonEvent = T extends { } ? { type: "message_update"; + usage: Usage; assistantMessageEvent: WithoutPartial; } : T; @@ -21,7 +23,8 @@ type JsonMessageUpdateEvent = Extract { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + }); + + it("includes cumulative usage without cumulative message snapshots", async () => { + harness = await createHarness(); + harness.setResponses([fauxAssistantMessage("hello")]); + + await harness.session.prompt("respond"); + + // #7290's delta-only wire projection dropped this fixed-size metadata with the snapshots. + const update = harness + .eventsOfType("message_update") + .find((event) => event.message.role === "assistant" && event.message.usage.totalTokens > 0); + if (!update || update.message.role !== "assistant") { + throw new Error("Expected an assistant update with populated usage"); + } + + const wireUpdate = toJsonEvent(update); + expect(wireUpdate.usage).toEqual(update.message.usage); + expect(wireUpdate).not.toHaveProperty("message"); + expect(wireUpdate.assistantMessageEvent).not.toHaveProperty("partial"); + }); +}); From 581d75a89cea21e50d6a26df840352f94427f633 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 13 Aug 2026 00:53:22 +0200 Subject: [PATCH 145/284] docs(coding-agent): document model catalog refresh --- packages/coding-agent/docs/sdk.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index edced918629..a4e4f2276e5 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -372,6 +372,13 @@ import { ModelRuntime } from "@earendil-works/pi-coding-agent"; const modelRuntime = await ModelRuntime.create(); +// create() restores cached catalogs but does not refresh them from pi.dev by default. +// Opt in to a create-time network refresh and bound how long it may take: +const refreshedRuntime = await ModelRuntime.create({ + allowModelNetwork: true, + modelRefreshTimeoutMs: 15_000, +}); + // Find specific built-in model (doesn't check if API key exists) const opus = getModel("anthropic", "claude-opus-4-5"); if (!opus) throw new Error("Model not found"); @@ -402,6 +409,8 @@ If no model is provided: 2. Uses default from settings 3. Falls back to first available model +Remote catalogs are persisted locally so later runtimes can restore them without a network request. The default file is `~/.pi/agent/models-store.json`; set `modelsStorePath` to choose another location, or inject `modelsStore` to control persistence. Network refreshes are throttled to once per provider every four hours unless forced. To force an immediate refresh, call `await modelRuntime.refresh({ allowNetwork: true, force: true, signal })`. Setting `PI_OFFLINE` disables model network access. + To match CLI model parsing, use the exported resolver helpers: ```typescript From 7d8c11d37f8d6687cc6688998fcb0d3db45e6151 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 13 Aug 2026 09:43:36 +0200 Subject: [PATCH 146/284] fix(coding-agent): share concurrent model catalog refreshes --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/model-selector.ts | 3 +- .../src/modes/interactive/interactive-mode.ts | 9 +-- .../interactive/model-catalog-refresh.ts | 51 ++++++++++++ .../test/model-catalog-refresh.test.ts | 81 +++++++++++++++++++ .../7153-scoped-models-refresh.test.ts | 2 +- 6 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/model-catalog-refresh.ts create mode 100644 packages/coding-agent/test/model-catalog-refresh.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3da3c603085..a008fe69317 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ ### Fixed +- Fixed opening a model selector immediately after startup cancelling and restarting the in-progress model catalog refresh. - Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. - Fixed inherited required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 6f2668a491b..1d5e58b12a2 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -11,6 +11,7 @@ import { } from "@earendil-works/pi-tui"; import type { ModelRuntime } from "../../../core/model-runtime.ts"; import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { refreshModelCatalogs } from "../model-catalog-refresh.ts"; import { getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; @@ -167,7 +168,7 @@ export class ModelSelectorComponent extends Container implements Focusable { this.refreshAbortController.abort(); }, timeoutMs); try { - const result = await this.modelRuntime.refresh({ signal: this.refreshAbortController.signal }); + const result = await refreshModelCatalogs(this.modelRuntime, this.refreshAbortController.signal); if (this.closed) return; this.refreshStatusMessage = ""; if (result.aborted && timedOut) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d5a1b915972..5b5b3d86aaa 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -152,6 +152,7 @@ import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { editInExternalEditor } from "./external-editor.ts"; +import { refreshModelCatalogs } from "./model-catalog-refresh.ts"; import { getModelSearchText } from "./model-search.ts"; import { getAvailableThemes, @@ -1018,8 +1019,7 @@ export class InteractiveMode { if (!process.env.PI_OFFLINE) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); - void this.session.modelRuntime - .refresh({ signal: controller.signal }) + void refreshModelCatalogs(this.session.modelRuntime, controller.signal) .then(() => this.updateAvailableProviderCount()) .catch(() => {}) .finally(() => clearTimeout(timeout)); @@ -4632,7 +4632,7 @@ export class InteractiveMode { controller.abort(); }, 15_000); try { - const result = await this.session.modelRuntime.refresh({ signal: controller.signal }); + const result = await refreshModelCatalogs(this.session.modelRuntime, controller.signal); if (result.aborted && timedOut) { this.showWarning("Model refresh timed out; searching cached models."); } else if (result.errors.size > 0) { @@ -4848,8 +4848,7 @@ export class InteractiveMode { }, }, ); - void this.session.modelRuntime - .refresh({ signal: controller.signal }) + void refreshModelCatalogs(this.session.modelRuntime, controller.signal) .then((result) => { if (disposed) return; availableModels = [...this.session.modelRuntime.getAvailableSnapshot()]; diff --git a/packages/coding-agent/src/modes/interactive/model-catalog-refresh.ts b/packages/coding-agent/src/modes/interactive/model-catalog-refresh.ts new file mode 100644 index 00000000000..e5915ff49d5 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/model-catalog-refresh.ts @@ -0,0 +1,51 @@ +import type { ModelsRefreshResult } from "@earendil-works/pi-ai"; +import type { ModelRuntime } from "../../core/model-runtime.ts"; +import { raceWithAbortSignal } from "../../utils/abort.ts"; + +type ModelCatalogRuntime = Pick; + +interface ActiveModelCatalogRefresh { + controller: AbortController; + promise: Promise; + waiters: number; +} + +class ModelCatalogRefreshCoordinator { + private readonly activeByRuntime = new WeakMap(); + + refresh(modelRuntime: ModelCatalogRuntime, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let active = this.activeByRuntime.get(modelRuntime); + if (!active) { + const controller = new AbortController(); + let created!: ActiveModelCatalogRefresh; + const operation = modelRuntime.refresh({ signal: controller.signal }); + const promise = raceWithAbortSignal(operation, controller.signal).finally(() => { + if (this.activeByRuntime.get(modelRuntime) === created) { + this.activeByRuntime.delete(modelRuntime); + } + }); + created = { controller, promise, waiters: 0 }; + active = created; + this.activeByRuntime.set(modelRuntime, active); + } + + active.waiters++; + return raceWithAbortSignal(active.promise, signal).finally(() => { + active.waiters--; + if (active.waiters === 0 && this.activeByRuntime.get(modelRuntime) === active) { + active.controller.abort(); + } + }); + } +} + +const modelCatalogRefreshCoordinator = new ModelCatalogRefreshCoordinator(); + +/** Share concurrent interactive all-catalog refreshes while keeping each caller's cancellation independent. */ +export function refreshModelCatalogs( + modelRuntime: ModelCatalogRuntime, + signal: AbortSignal, +): Promise { + return modelCatalogRefreshCoordinator.refresh(modelRuntime, signal); +} diff --git a/packages/coding-agent/test/model-catalog-refresh.test.ts b/packages/coding-agent/test/model-catalog-refresh.test.ts new file mode 100644 index 00000000000..0250026f934 --- /dev/null +++ b/packages/coding-agent/test/model-catalog-refresh.test.ts @@ -0,0 +1,81 @@ +import type { ModelsRefreshOptions, ModelsRefreshResult } from "@earendil-works/pi-ai"; +import { describe, expect, it, vi } from "vitest"; +import { refreshModelCatalogs } from "../src/modes/interactive/model-catalog-refresh.ts"; + +interface Deferred { + promise: Promise; + resolve(value: T): void; +} + +function createDeferred(): Deferred { + let resolvePromise!: (value: T) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +} + +function successfulRefresh(): ModelsRefreshResult { + return { aborted: false, errors: new Map() }; +} + +describe("interactive model catalog refresh", () => { + it("shares one runtime refresh between concurrent callers", async () => { + const deferred = createDeferred(); + const runtime = { refresh: vi.fn((_options?: ModelsRefreshOptions) => deferred.promise) }; + const firstController = new AbortController(); + const secondController = new AbortController(); + + const first = refreshModelCatalogs(runtime, firstController.signal); + const second = refreshModelCatalogs(runtime, secondController.signal); + + expect(runtime.refresh).toHaveBeenCalledOnce(); + deferred.resolve(successfulRefresh()); + await expect(first).resolves.toEqual(successfulRefresh()); + await expect(second).resolves.toEqual(successfulRefresh()); + }); + + it("keeps the shared refresh alive when one caller stops waiting", async () => { + const deferred = createDeferred(); + let refreshSignal: AbortSignal | undefined; + const runtime = { + refresh: vi.fn((options?: ModelsRefreshOptions) => { + refreshSignal = options?.signal; + return deferred.promise; + }), + }; + const firstController = new AbortController(); + const secondController = new AbortController(); + const first = refreshModelCatalogs(runtime, firstController.signal); + const second = refreshModelCatalogs(runtime, secondController.signal); + + firstController.abort(); + await expect(first).rejects.toMatchObject({ name: "AbortError" }); + expect(refreshSignal?.aborted).toBe(false); + + deferred.resolve(successfulRefresh()); + await expect(second).resolves.toEqual(successfulRefresh()); + }); + + it("aborts an abandoned refresh and allows a later refresh to start", async () => { + const refreshSignals: AbortSignal[] = []; + const runtime = { + refresh: vi.fn((options?: ModelsRefreshOptions) => { + if (options?.signal) refreshSignals.push(options.signal); + return new Promise(() => {}); + }), + }; + const firstController = new AbortController(); + const first = refreshModelCatalogs(runtime, firstController.signal); + + firstController.abort(); + await expect(first).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(refreshSignals[0]?.aborted).toBe(true)); + + const secondController = new AbortController(); + const second = refreshModelCatalogs(runtime, secondController.signal); + expect(runtime.refresh).toHaveBeenCalledTimes(2); + secondController.abort(); + await expect(second).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/7153-scoped-models-refresh.test.ts b/packages/coding-agent/test/suite/regressions/7153-scoped-models-refresh.test.ts index 0fb55a30627..dccdc9d86b5 100644 --- a/packages/coding-agent/test/suite/regressions/7153-scoped-models-refresh.test.ts +++ b/packages/coding-agent/test/suite/regressions/7153-scoped-models-refresh.test.ts @@ -101,7 +101,7 @@ describe("issue #7153 scoped models refresh", () => { expect(refresh.refreshSignal).toBeDefined(); refresh.selector.handleInput("\x1b"); - expect(refresh.refreshSignal?.aborted).toBe(true); + await vi.waitFor(() => expect(refresh.refreshSignal?.aborted).toBe(true)); expect(refresh.done).toHaveBeenCalledOnce(); }); }); From 46bb9a2c3bdb296b0d2179f7309ec6b79a7f3106 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 13 Aug 2026 09:54:58 +0200 Subject: [PATCH 147/284] docs(coding-agent): clarify Windows paths in settings --- packages/coding-agent/docs/settings.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index cb48caee98a..585160806b2 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -192,6 +192,20 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic | `shellCommandPrefix` | string | - | Prefix for every bash command (e.g., `"shopt -s expand_aliases"`) | | `npmCommand` | string[] | - | Command argv used for npm package lookup/install operations (e.g., `["mise", "exec", "node@20", "--", "npm"]`) | +Windows paths in JSON must use forward slashes or escaped backslashes: + +```json +{ + "shellPath": "C:/Program Files/Git/bin/bash.exe" +} +``` + +```json +{ + "shellPath": "C:\\Program Files\\Git\\bin\\bash.exe" +} +``` + ```json { "npmCommand": ["mise", "exec", "node@20", "--", "npm"] From 6f707eb36064e82af9c1320a7634f4dfad21049b Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 13 Aug 2026 12:15:49 +0200 Subject: [PATCH 148/284] fix(coding-agent): show managed-tool startup status in TUI --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/tools/find.ts | 2 +- packages/coding-agent/src/core/tools/grep.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 48 +++++++++++++++---- .../coding-agent/src/utils/tools-manager.ts | 41 ++++++++-------- .../interactive-mode-startup-input.test.ts | 18 +++++++ .../test/interactive-mode-status.test.ts | 24 ++++++++++ .../coding-agent/test/tools-manager.test.ts | 47 ++++++++++++++++++ 8 files changed, 153 insertions(+), 30 deletions(-) create mode 100644 packages/coding-agent/test/tools-manager.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a008fe69317..00419380e44 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ ### Fixed +- Fixed managed-tool downloads delaying TUI startup and hiding diagnostics in fullscreen mode by mounting the TUI first and showing download progress and warnings inside it. - Fixed opening a model selector immediately after startup cancelling and restarting the in-progress model catalog refresh. - Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index a14f327e840..f3228ef77e7 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -222,7 +222,7 @@ export function createFindToolDefinition( } // Default implementation uses fd. - const fdPath = await ensureTool("fd", true); + const fdPath = await ensureTool("fd"); if (signal?.aborted) { settle(() => reject(new Error("Operation aborted"))); return; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 2274e706449..a65cab9dafe 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -174,7 +174,7 @@ export function createGrepToolDefinition( (async () => { try { - const rgPath = await ensureTool("rg", true); + const rgPath = await ensureTool("rg"); if (!rgPath) { settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded"))); return; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5b5b3d86aaa..4e64341b2d4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -107,7 +107,7 @@ import { openBrowser } from "../../utils/open-browser.ts"; import { getCwdRelativePath } from "../../utils/paths.ts"; import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; -import { ensureTool } from "../../utils/tools-manager.ts"; +import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts"; import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts"; import { ArminComponent } from "./components/armin.ts"; import { AssistantMessageComponent } from "./components/assistant-message.ts"; @@ -436,6 +436,7 @@ export class InteractiveMode { // Status line tracking (for mutating immediately-sequential status updates) private lastStatusSpacer: Spacer | undefined = undefined; private lastStatusText: Text | undefined = undefined; + private managedToolStatusStarted = false; // Streaming message tracking private streamingComponent: AssistantMessageComponent | undefined = undefined; @@ -851,11 +852,6 @@ export class InteractiveMode { // Load changelog (only show new entries, skip for resumed sessions) this.changelogMarkdown = this.getChangelogForDisplay(); - // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) - // Both are needed: fd for autocomplete, rg for grep tool and bash commands - const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); - this.fdPath = fdPath; - if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) { const modelList = this.session.scopedModels .map((sm) => { @@ -901,11 +897,12 @@ export class InteractiveMode { this.widgetContainerBelow, this.footerContainer, ]); + // Accept text while startup completes, but only enable interrupt, exit, and submission feedback. + this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); + this.defaultEditor.onCtrlD = () => this.handleCtrlD(); + this.defaultEditor.onSubmit = (text) => this.handleStartupSubmit(text); this.ui.setFocus(this.editor); - this.setupKeyHandlers(); - this.setupEditorSubmitHandler(); - // Start the UI before initializing extensions so session_start handlers can use interactive dialogs this.ui.start(); this.isInitialized = true; @@ -974,6 +971,20 @@ export class InteractiveMode { } this.ui.requestRender(); + // Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir) + // so slow downloads do not make startup appear frozen. + // Both are needed: fd for autocomplete, rg for grep tool and bash commands. + const [fdPath] = await Promise.all([ + ensureTool("fd", (status) => this.showManagedToolStatus(status)), + ensureTool("rg", (status) => this.showManagedToolStatus(status)), + ]); + this.fdPath = fdPath; + + // Enable the remaining input handlers only after managed-tool setup completes. + this.setupKeyHandlers(); + this.setupEditorSubmitHandler(); + this.ui.requestRender(); + // Initialize extensions first so resources are shown before messages await this.rebindCurrentSession(); @@ -2873,6 +2884,11 @@ export class InteractiveMode { } } + private handleStartupSubmit(text: string): void { + this.editor.setText(text); + this.showStatus("Startup is still in progress"); + } + private setupEditorSubmitHandler(): void { this.defaultEditor.onSubmit = async (text: string) => { text = text.trim(); @@ -3408,6 +3424,20 @@ export class InteractiveMode { return textBlocks.map((c) => (c as { text: string }).text).join(""); } + /** Show a managed-tool status update in the chat. */ + private showManagedToolStatus(status: ToolStatus): void { + if (!this.managedToolStatusStarted) { + this.chatContainer.addChild(new Spacer(1)); + this.managedToolStatusStarted = true; + } + const message = status.type === "warning" ? `Warning: ${status.message}` : status.message; + const color = status.type === "warning" ? "warning" : "dim"; + this.chatContainer.addChild(new Text(theme.fg(color, message), 1, 0)); + this.lastStatusSpacer = undefined; + this.lastStatusText = undefined; + this.ui.requestRender(); + } + /** * Show a status message in the chat. * diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index a177b4f64ce..23a71b5a171 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,4 +1,3 @@ -import chalk from "chalk"; import { type SpawnSyncReturns, spawnSync } from "child_process"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; import { arch, platform } from "os"; @@ -323,9 +322,20 @@ const TERMUX_PACKAGES: Record = { rg: "ripgrep", }; -// Ensure a tool is available, downloading if necessary -// Returns the path to the tool, or null if unavailable -export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { +export interface ToolStatus { + type: "info" | "warning"; + message: string; +} + +/** + * Ensure a tool is available, downloading if necessary. + * Reports progress through `onStatus`; status messages are otherwise silent. + * Returns the tool path, or undefined if unavailable. + */ +export async function ensureTool( + tool: "fd" | "rg", + onStatus?: (status: ToolStatus) => void, +): Promise { const existingPath = getToolPath(tool); if (existingPath) { return existingPath; @@ -335,9 +345,7 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr if (!config) return undefined; if (isOfflineModeEnabled()) { - if (!silent) { - console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`)); - } + onStatus?.({ type: "warning", message: `${config.name} not found. Offline mode enabled, skipping download.` }); return undefined; } @@ -345,27 +353,22 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr // Users must install via pkg. if (platform() === "android") { const pkgName = TERMUX_PACKAGES[tool] ?? tool; - if (!silent) { - console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`)); - } + onStatus?.({ type: "warning", message: `${config.name} not found. Install with: pkg install ${pkgName}` }); return undefined; } // Tool not found - download it - if (!silent) { - console.log(chalk.dim(`${config.name} not found. Downloading...`)); - } + onStatus?.({ type: "info", message: `${config.name} not found. Downloading...` }); try { const path = await downloadTool(tool); - if (!silent) { - console.log(chalk.dim(`${config.name} installed to ${path}`)); - } + onStatus?.({ type: "info", message: `${config.name} installed to ${path}` }); return path; } catch (e) { - if (!silent) { - console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`)); - } + onStatus?.({ + type: "warning", + message: `Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`, + }); return undefined; } } diff --git a/packages/coding-agent/test/interactive-mode-startup-input.test.ts b/packages/coding-agent/test/interactive-mode-startup-input.test.ts index b784f3769ec..28a0f48b80c 100644 --- a/packages/coding-agent/test/interactive-mode-startup-input.test.ts +++ b/packages/coding-agent/test/interactive-mode-startup-input.test.ts @@ -23,7 +23,13 @@ type InputContext = { pendingUserInputs: string[]; }; +type StartupSubmitContext = { + editor: { setText: (text: string) => void }; + showStatus: (message: string) => void; +}; + type InteractiveModePrivate = { + handleStartupSubmit(this: StartupSubmitContext, text: string): void; setupEditorSubmitHandler(this: SubmitContext): void; getUserInput(this: InputContext): Promise; }; @@ -49,6 +55,18 @@ function createSubmitContext(): SubmitContext { } describe("InteractiveMode startup input", () => { + it("restores a prompt submitted while managed-tool setup is running", () => { + const context: StartupSubmitContext = { + editor: { setText: vi.fn() }, + showStatus: vi.fn(), + }; + + interactiveModePrototype.handleStartupSubmit.call(context, "early prompt"); + + expect(context.editor.setText).toHaveBeenCalledWith("early prompt"); + expect(context.showStatus).toHaveBeenCalledWith("Startup is still in progress"); + }); + it("queues a normal prompt submitted before the input callback is installed", async () => { const context = createSubmitContext(); interactiveModePrototype.setupEditorSubmitHandler.call(context); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d422d8f3480..a4aaa48c02a 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -118,6 +118,30 @@ describe("InteractiveMode.showStatus", () => { }); }); +describe("InteractiveMode.showManagedToolStatus", () => { + beforeAll(() => initTheme("dark")); + + test("renders tool updates as one contiguous group", () => { + const fakeThis: any = { + chatContainer: new Container(), + ui: { requestRender: vi.fn() }, + managedToolStatusStarted: false, + lastStatusSpacer: undefined, + lastStatusText: undefined, + }; + const showManagedToolStatus = (InteractiveMode as any).prototype.showManagedToolStatus; + + showManagedToolStatus.call(fakeThis, { type: "info", message: "fd downloading" }); + showManagedToolStatus.call(fakeThis, { type: "info", message: "rg downloading" }); + showManagedToolStatus.call(fakeThis, { type: "warning", message: "rg failed" }); + + expect(fakeThis.chatContainer.children).toHaveLength(4); + expect(normalizeRenderedOutput(fakeThis.chatContainer)).toBe( + "fd downloading\n rg downloading\n Warning: rg failed", + ); + }); +}); + describe("InteractiveMode.setToolsExpanded", () => { test("applies expansion state to the active header and chat entries", () => { const header = { setExpanded: vi.fn() }; diff --git a/packages/coding-agent/test/tools-manager.test.ts b/packages/coding-agent/test/tools-manager.test.ts new file mode 100644 index 00000000000..fb53baad4fd --- /dev/null +++ b/packages/coding-agent/test/tools-manager.test.ts @@ -0,0 +1,47 @@ +import type * as ChildProcess from "node:child_process"; +import type * as Fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ensureTool, type ToolStatus } from "../src/utils/tools-manager.ts"; + +const originalOffline = process.env.PI_OFFLINE; + +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + }; +}); + +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnSync: vi.fn(() => ({ error: new Error("not found") })), + }; +}); + +afterEach(() => { + if (originalOffline === undefined) delete process.env.PI_OFFLINE; + else process.env.PI_OFFLINE = originalOffline; +}); + +describe("ensureTool", () => { + it("reports status through a callback without writing to the console", async () => { + process.env.PI_OFFLINE = "1"; + const statuses: ToolStatus[] = []; + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + + const result = await ensureTool("fd", (status) => statuses.push(status)); + + expect(result).toBeUndefined(); + expect(statuses).toEqual([ + { + type: "warning", + message: "fd not found. Offline mode enabled, skipping download.", + }, + ]); + expect(consoleLog).not.toHaveBeenCalled(); + consoleLog.mockRestore(); + }); +}); From 9d2ec7ffabe927bfad2214c1cee25b6632a78dcf Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 13 Aug 2026 22:26:38 +0200 Subject: [PATCH 149/284] fix(ai): use pi user agent for Kimi Coding requests --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 5 ---- packages/ai/src/api/anthropic-messages.ts | 24 +++++++++++++-- packages/ai/src/api/openai-codex-responses.ts | 19 ++---------- packages/ai/src/utils/pi-user-agent.ts | 19 ++++++++++++ packages/ai/test/anthropic-auth-token.test.ts | 29 +++++++++++++++++++ packages/ai/test/openai-codex-stream.test.ts | 3 +- packages/coding-agent/CHANGELOG.md | 1 + 8 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 packages/ai/src/utils/pi-user-agent.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2a884888afb..87ab7b68588 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed +- Changed Kimi Coding requests to use pi's runtime `User-Agent` header. - Automatically converted supported strict tool schemas to provider-compatible closed objects with required nullable optional fields while preserving original tool definitions, and treated `null` values for optional non-nullable tool arguments as omitted. - Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). - Replaced the Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d44c75e97af..e36d5551be5 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -148,10 +148,6 @@ const COPILOT_STATIC_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; -const KIMI_STATIC_HEADERS = { - "User-Agent": "KimiCLI/1.5", -} as const; - const TOGETHER_BASE_URL = "https://api.together.ai/v1"; const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { supportsStore: false, @@ -2057,7 +2053,6 @@ async function loadModelsDevData(): Promise[]> { provider: "kimi-coding", // Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages baseUrl: "https://api.kimi.com/coding", - headers: { ...KIMI_STATIC_HEADERS }, compat: { ...(allowEmptySignature ? { allowEmptySignature: true } : {}), forceAdaptiveThinking: true, diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 10f91f2a3cf..b9586120dd0 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -33,6 +33,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -271,6 +272,20 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid return merged; } +function mergeClientHeaders( + model: Model<"anthropic-messages">, + ...headerSources: (ProviderHeaders | undefined)[] +): ProviderHeaders { + const merged = mergeHeaders(...headerSources); + if (model.provider === "kimi-coding") { + for (const name of Object.keys(merged)) { + if (name.toLowerCase() === "user-agent") delete merged[name]; + } + merged["User-Agent"] = getPiUserAgent(); + } + return merged; +} + function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean { if (!headers) return false; const expected = name.toLowerCase(); @@ -872,7 +887,8 @@ function createClient( baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, - defaultHeaders: mergeHeaders( + defaultHeaders: mergeClientHeaders( + model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -895,7 +911,8 @@ function createClient( baseURL: model.baseUrl, dangerouslyAllowBrowser: true, fetch, - defaultHeaders: mergeHeaders( + defaultHeaders: mergeClientHeaders( + model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -914,7 +931,8 @@ function createClient( // API key or header-owned auth. const sessionAffinityHeaders: ProviderHeaders = sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {}; - const defaultHeaders = mergeHeaders( + const defaultHeaders = mergeClientHeaders( + model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 863e2ef46d0..71e0ee9bc98 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -1,4 +1,3 @@ -import type * as NodeOs from "node:os"; import type * as NodeZlib from "node:zlib"; import type { Tool as OpenAITool, @@ -7,20 +6,6 @@ import type { ResponseStreamEvent, } from "openai/resources/responses/responses.js"; -type ProcessWithOsBuiltinModule = typeof process & { - getBuiltinModule?: (id: "node:os") => typeof NodeOs; -}; - -function loadNodeOs(): typeof NodeOs | null { - if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) { - return null; - } - return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null; -} - -// NEVER convert to top-level runtime imports - breaks browser/Vite builds -const _os: typeof NodeOs | null = loadNodeOs(); - import { clampThinkingLevel } from "../models.ts"; import { registerSessionResourceCleanup } from "../session-resources.ts"; import type { @@ -46,6 +31,7 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { uuidv7 } from "../utils/uuid.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; @@ -1618,8 +1604,7 @@ function buildBaseCodexHeaders( headers.set("Authorization", `Bearer ${token}`); headers.set("chatgpt-account-id", accountId); headers.set("originator", "pi"); - const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : "pi (browser)"; - headers.set("User-Agent", userAgent); + headers.set("User-Agent", getPiUserAgent()); return headers; } diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts new file mode 100644 index 00000000000..93b23dd3511 --- /dev/null +++ b/packages/ai/src/utils/pi-user-agent.ts @@ -0,0 +1,19 @@ +import type * as NodeOs from "node:os"; + +type ProcessWithOsBuiltinModule = typeof process & { + getBuiltinModule?: (id: "node:os") => typeof NodeOs; +}; + +function loadNodeOs(): typeof NodeOs | null { + if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) { + return null; + } + return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null; +} + +// Keep runtime OS loading browser-safe. A top-level runtime import of node:os breaks browser/Vite builds. +const nodeOs = loadNodeOs(); + +export function getPiUserAgent(): string { + return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; +} diff --git a/packages/ai/test/anthropic-auth-token.test.ts b/packages/ai/test/anthropic-auth-token.test.ts index e2780a1750f..a80a021dbeb 100644 --- a/packages/ai/test/anthropic-auth-token.test.ts +++ b/packages/ai/test/anthropic-auth-token.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts"; import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts"; @@ -71,6 +72,14 @@ const anthropicModel: Model<"anthropic-messages"> = { maxTokens: 4096, }; +const kimiCodingModel: Model<"anthropic-messages"> = { + ...anthropicModel, + id: "kimi-for-coding", + name: "Kimi For Coding", + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", +}; + afterEach(() => { mockState.constructorOpts = undefined; mockState.createParams = undefined; @@ -185,3 +194,23 @@ describe("Anthropic auth token env", () => { expect(headers.Authorization).toBe("Bearer explicit-token"); }); }); + +describe("Anthropic-compatible user agents", () => { + it("enforces the pi runtime user agent for Kimi Coding", async () => { + await streamAnthropic(kimiCodingModel, context, { + apiKey: "kimi-key", + headers: { "user-agent": "custom-client" }, + }).result(); + + const headers = mockState.constructorOpts?.defaultHeaders as Record; + const userAgentHeaders = Object.entries(headers).filter(([name]) => name.toLowerCase() === "user-agent"); + expect(userAgentHeaders).toEqual([["User-Agent", `pi (${platform()} ${release()}; ${arch()})`]]); + }); + + it("does not apply the pi runtime user agent to Anthropic", async () => { + await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result(); + + const headers = mockState.constructorOpts?.defaultHeaders as Record; + expect(Object.keys(headers).some((name) => name.toLowerCase() === "user-agent")).toBe(false); + }); +}); diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index ccd7f51c62c..1ef93f3be50 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { arch, platform, release, tmpdir } from "node:os"; import { join } from "node:path"; import { zstdDecompressSync } from "node:zlib"; import { Type } from "typebox"; @@ -160,6 +160,7 @@ describe("openai-codex streaming", () => { expect(headers?.get("chatgpt-account-id")).toBe("acc_test"); expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental"); expect(headers?.get("originator")).toBe("pi"); + expect(headers?.get("User-Agent")).toBe(`pi (${platform()} ${release()}; ${arch()})`); expect(headers?.get("accept")).toBe("text/event-stream"); expect(headers?.has("x-api-key")).toBe(false); return new Response(stream, { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 00419380e44..5d16436f47d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Changed +- Changed inherited Kimi Coding requests to use pi's runtime `User-Agent` header. - Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. - Documented the generic `AI_AGENT=pi` process marker and how it differs from `PI_CODING_AGENT=true` ([#7747](https://github.com/earendil-works/pi/issues/7747)). From 5f7195c51eac43cdf329f813a7ef020d7bd74527 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 14 Aug 2026 09:32:24 +0200 Subject: [PATCH 150/284] fix(coding-agent): update Cloudflare compat test model --- .../coding-agent/test/model-runtime-cloudflare-compat.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts b/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts index 8a0afcabf7a..6e9c4518511 100644 --- a/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts +++ b/packages/coding-agent/test/model-runtime-cloudflare-compat.test.ts @@ -59,7 +59,7 @@ async function createCloudflareRuntime(): Promise<{ modelRuntime: ModelRuntime; describe("ModelRegistry Cloudflare compat streaming", () => { it("materializes the Cloudflare endpoint through ModelRuntime streaming", async () => { const { modelRuntime } = await createCloudflareRuntime(); - const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5"); + const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); expect(model).toBeDefined(); resetApiProviders(); @@ -75,7 +75,7 @@ describe("ModelRegistry Cloudflare compat streaming", () => { it("materializes the Cloudflare endpoint after extension-style auth resolution", async () => { const { modelRegistry } = await createCloudflareRuntime(); - const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5"); + const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); expect(model).toBeDefined(); resetApiProviders(); From d268454e9c347c115ac334b4abd719216e34046b Mon Sep 17 00:00:00 2001 From: alexsavio Date: Fri, 14 Aug 2026 09:59:42 +0200 Subject: [PATCH 151/284] fix(examples): accept array-form `tools` in the subagent example (#7598) `parseFrontmatter` runs a real YAML parser, so `tools: [read, bash]` arrives as an array while `tools: read, bash` arrives as a string. The loader only handled the string form and called `.split(",")`, which throws on the array form. The throw escapes `loadAgentsFromDir` -- only `readdirSync` and `readFileSync` are guarded -- so one agent file using the array form broke discovery for every agent in that scope, not just itself. Normalize both spellings in one helper, and type the frontmatter fields as `unknown` so the string assumptions are explicit. The previous `parseFrontmatter>` annotation asserted a shape the YAML parser never guarantees, which is what hid the mismatch from tsc while `AgentConfig.tools` was already typed `string[]`. --- .../examples/extensions/subagent/agents.ts | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/examples/extensions/subagent/agents.ts b/packages/coding-agent/examples/extensions/subagent/agents.ts index c41ef579c5d..b8a36598c61 100644 --- a/packages/coding-agent/examples/extensions/subagent/agents.ts +++ b/packages/coding-agent/examples/extensions/subagent/agents.ts @@ -23,6 +23,42 @@ export interface AgentDiscoveryResult { projectAgentsDir: string | null; } +/** + * Raw agent frontmatter. Values are `unknown` because `parseFrontmatter` runs a + * real YAML parser, so any scalar or collection can appear here. + * + * A type alias rather than an interface: `parseFrontmatter` constrains its + * parameter to `Record`, and only an alias picks up the + * implicit index signature that satisfies it. + */ +type AgentFrontmatter = { + name?: unknown; + description?: unknown; + tools?: unknown; + model?: unknown; +}; + +/** + * Normalize a frontmatter `tools` value to a list of tool names. + * + * Both spellings are valid YAML and both are in use: + * + * tools: read, bash # string + * tools: [read, bash] # array + * + * so accept either. Anything else (a number, a map, a nested list) yields no + * tools rather than throwing: this runs inside agent discovery, where a single + * bad file must not take down every other agent in the same directory. + */ +function parseToolList(value: unknown): string[] | undefined { + const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; + const tools = raw + .filter((t): t is string => typeof t === "string") + .map((t) => t.trim()) + .filter(Boolean); + return tools.length > 0 ? tools : undefined; +} + function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] { const agents: AgentConfig[] = []; @@ -49,22 +85,17 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig continue; } - const { frontmatter, body } = parseFrontmatter>(content); + const { frontmatter, body } = parseFrontmatter(content); - if (!frontmatter.name || !frontmatter.description) { + if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") { continue; } - const tools = frontmatter.tools - ?.split(",") - .map((t: string) => t.trim()) - .filter(Boolean); - agents.push({ name: frontmatter.name, description: frontmatter.description, - tools: tools && tools.length > 0 ? tools : undefined, - model: frontmatter.model, + tools: parseToolList(frontmatter.tools), + model: typeof frontmatter.model === "string" ? frontmatter.model : undefined, systemPrompt: body, source, filePath, From 721e2768ee3b0850723a1f6d9ba54d62bff3ebac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 08:03:33 +0000 Subject: [PATCH 152/284] chore: approve contributors from issue #7973 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 15211d52141..ebb093c6e13 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -363,3 +363,5 @@ pablasso pr bilby91 pr giannisCKS pr + +Panoplos pr From 6633618350a9d9ea91fdc11668442e771869a56f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 14 Aug 2026 10:09:35 +0200 Subject: [PATCH 153/284] fix(coding-agent): update vulnerable nanoid dependency --- package-lock.json | 6 +++--- packages/coding-agent/CHANGELOG.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff7ce754b0d..fb467b63808 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4015,9 +4015,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d16436f47d..1d84fc75ce9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -23,6 +23,7 @@ - Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. - Fixed inherited required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). +- Updated the transitive `nanoid` development dependency to address a denial-of-service vulnerability. ## [0.84.1] - 2026-08-07 From 83aed2ba5829654694ac972ba40b8e89e83de089 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 14 Aug 2026 10:11:15 +0200 Subject: [PATCH 154/284] fix(tui): handle generic SGR mouse releases closes #7963 --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui-alt-screen.ts | 3 ++- packages/tui/test/tui-alt-screen.test.ts | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index fd1257cc9ea..3be21b9ab76 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed fullscreen mouse drag selection and OSC 8 link activation in terminals that report generic SGR mouse release button codes ([#7963](https://github.com/earendil-works/pi/issues/7963)). - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented SGR mouse input leaking into the search query. - Fixed required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). - Fixed LaTeX control spaces split across line endings causing complete expressions to fall back to raw source. diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 25efa42fa96..0dfc43c7f8c 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -942,7 +942,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private handleSelectionMouseEvent(event: SgrMouseEvent): void { - if ((event.button & 3) !== 0) return; + const button = event.button & 3; + if (button !== 0 && !(event.release && button === 3)) return; const anchorScrollView = this.selectionAnchor?.scrollView; const point = this.getSelectionPoint(event, anchorScrollView); if (event.release) { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 6c0fe28d4ab..1e0d94c2287 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -890,7 +890,7 @@ describe("TuiAltScreen", () => { } }); - it("opens an OSC 8 hyperlink on click but not on drag", async () => { + it("opens an OSC 8 hyperlink with specific or generic release codes, but not on drag", async () => { const terminal = new RecordingTerminal(20, 3); const openedUrls: string[] = []; const tui = new TuiAltScreen(terminal, undefined, undefined, { @@ -910,7 +910,7 @@ describe("TuiAltScreen", () => { await terminal.waitForRender(); terminal.sendInput("\x1b[<0;2;1M"); - terminal.sendInput("\x1b[<0;2;1m"); + terminal.sendInput("\x1b[<3;2;1m"); await terminal.waitForRender(); assert.deepStrictEqual(openedUrls, [url]); @@ -933,7 +933,7 @@ describe("TuiAltScreen", () => { tui.stop(); }); - it("selects visible text with the mouse and copies it with OSC 52", async () => { + it("selects visible text with the mouse and copies it with OSC 52 after a generic release", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); tui.addChild(new Text("\x1b[1mal\x1b[0mpha\nbeta\ngamma\ndelta", 0, 0)); @@ -942,7 +942,7 @@ describe("TuiAltScreen", () => { terminal.sendInput("\x1b[<0;1;1M"); terminal.sendInput("\x1b[<32;4;2M"); - terminal.sendInput("\x1b[<0;4;2m"); + terminal.sendInput("\x1b[<3;4;2m"); await terminal.waitForRender(); const expectedClipboardSequence = `\x1b]52;c;${Buffer.from("alpha\nbeta").toString("base64")}\x07`; From e14afc648e10fb6c527ea88fa627091ada764306 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 14 Aug 2026 10:22:36 +0200 Subject: [PATCH 155/284] fix(coding-agent): collapse fallback tool output closes #7979 --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/tool-execution.ts | 13 +++++++++++- .../test/tool-execution-component.test.ts | 20 ++++++++++++++----- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1d84fc75ce9..e0651a7718a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -24,6 +24,7 @@ - Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query. - Fixed inherited required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). - Updated the transitive `nanoid` development dependency to address a denial-of-service vulnerability. +- Fixed fallback rendering for extension tool results to collapse long output and honor tool expansion ([#7979](https://github.com/earendil-works/pi/issues/7979)). ## [0.84.1] - 2026-08-07 diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index ad84f441334..b1420b36c94 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -4,6 +4,9 @@ import { createAllToolDefinitions, type ToolName } from "../../../core/tools/ind import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts"; import { convertToPng } from "../../../utils/image-convert.ts"; import { theme } from "../theme/theme.ts"; +import { keyHint } from "./keybinding-hints.ts"; + +const FALLBACK_PREVIEW_LINES = 10; export interface ToolExecutionOptions { showImages?: boolean; @@ -141,7 +144,15 @@ export class ToolExecutionComponent extends Container { if (!output) { return undefined; } - return new Text(theme.fg("toolOutput", output), 0, 0); + + const lines = output.split("\n"); + const displayLines = this.expanded ? lines : lines.slice(0, FALLBACK_PREVIEW_LINES); + const remaining = lines.length - displayLines.length; + let text = displayLines.map((line) => theme.fg("toolOutput", line)).join("\n"); + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + return new Text(text, 0, 0); } updateArgs(args: any): void { diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 49b3754ae70..14d3b3f04e1 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -344,7 +344,7 @@ describe("ToolExecutionComponent parity", () => { expect(rendered).toContain("arg:bar"); }); - test("falls back when custom renderers are absent", () => { + test("collapses fallback results until expanded", () => { const toolDefinition: ToolDefinition = { ...createBaseToolDefinition(), }; @@ -358,10 +358,20 @@ describe("ToolExecutionComponent parity", () => { createFakeTui(), process.cwd(), ); - component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); - const rendered = stripAnsi(component.render(120).join("\n")); - expect(rendered).toContain("custom_tool"); - expect(rendered).toContain("done"); + const output = Array.from({ length: 15 }, (_, index) => `line-${index + 1}`).join("\n"); + component.updateResult({ content: [{ type: "text", text: output }], details: {}, isError: false }, false); + + const collapsed = stripAnsi(component.render(120).join("\n")); + expect(collapsed).toContain("custom_tool"); + expect(collapsed).toContain("line-10"); + expect(collapsed).not.toContain("line-11"); + expect(collapsed).toContain("5 more lines"); + expect(collapsed).toContain("to expand"); + + component.setExpanded(true); + const expanded = stripAnsi(component.render(120).join("\n")); + expect(expanded).toContain("line-15"); + expect(expanded).not.toContain("more lines"); }); test("trims trailing blank display lines from write previews", () => { From 0589435e7d4c56152ff850c9648ffcfb54a90959 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 08:49:18 +0000 Subject: [PATCH 156/284] chore: approve contributors from issue #8007 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index ebb093c6e13..62bac4e7716 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -365,3 +365,5 @@ bilby91 pr giannisCKS pr Panoplos pr + +haoyongchun1125-maker pr From f3c406a9b2d886ae6f4222c13f8ee7e26b87a0cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 08:51:32 +0000 Subject: [PATCH 157/284] chore: approve contributors from issue #8018 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 62bac4e7716..f735831af1a 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -367,3 +367,5 @@ giannisCKS pr Panoplos pr haoyongchun1125-maker pr + +gwokhou pr From c5b7cd2a33e3a412eaf35badc6bc641be201abdf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 09:20:04 +0000 Subject: [PATCH 158/284] chore: approve contributors from issue #8063 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index f735831af1a..d1ad0f3f89c 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -369,3 +369,5 @@ Panoplos pr haoyongchun1125-maker pr gwokhou pr + +gaoyk19 pr From 5093641a562cf9c78c0a8cf9af5d341418b7a112 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 14 Aug 2026 11:25:40 +0200 Subject: [PATCH 159/284] fix(ai): preserve Google length stops with tool calls closes #8059 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/google-generative-ai.ts | 2 +- packages/ai/src/api/google-vertex.ts | 2 +- .../ai/test/google-raw-stop-reason.test.ts | 56 +++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 87ab7b68588..e0e15deea95 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -15,6 +15,7 @@ - Fixed upstream request buffer limit failures to trigger automatic assistant retries. - Fixed OpenAI Responses function and custom tool calls to preserve namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). - Fixed built-in and custom DeepSeek API models to send output limits through the supported `max_tokens` field. +- Fixed Google Generative AI and Vertex AI responses with tool calls incorrectly treating output-limit or provider-error stops as normal tool use ([#8059](https://github.com/earendil-works/pi/issues/8059)). ## [0.84.1] - 2026-08-07 diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 30da47cd833..b0c5a09ed03 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -215,7 +215,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = ( if (candidate?.finishReason) { output.rawStopReason = candidate.finishReason; output.stopReason = mapStopReason(candidate.finishReason); - if (output.content.some((b) => b.type === "toolCall")) { + if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { output.stopReason = "toolUse"; } } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 5bb90663e8f..17ea6c03c2b 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -232,7 +232,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = ( if (candidate?.finishReason) { output.rawStopReason = candidate.finishReason; output.stopReason = mapStopReason(candidate.finishReason); - if (output.content.some((b) => b.type === "toolCall")) { + if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { output.stopReason = "toolUse"; } } diff --git a/packages/ai/test/google-raw-stop-reason.test.ts b/packages/ai/test/google-raw-stop-reason.test.ts index b485088ea40..ccd85b0a429 100644 --- a/packages/ai/test/google-raw-stop-reason.test.ts +++ b/packages/ai/test/google-raw-stop-reason.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; const googleGenAiMock = vi.hoisted(() => ({ finishReason: "MALFORMED_FUNCTION_CALL", + includeFunctionCall: false, })); vi.mock("@google/genai", () => { @@ -13,6 +14,19 @@ vi.mock("@google/genai", () => { candidates: [ { finishReason: googleGenAiMock.finishReason, + ...(googleGenAiMock.includeFunctionCall && { + content: { + parts: [ + { + functionCall: { + id: "call-1", + name: "echo", + args: { value: "truncated" }, + }, + }, + ], + }, + }), }, ], usageMetadata: { @@ -77,6 +91,7 @@ const context: Context = { describe("Google raw stop reasons", () => { it("preserves raw Gemini finish reasons for Google Generative AI errors", async () => { googleGenAiMock.finishReason = "MALFORMED_FUNCTION_CALL"; + googleGenAiMock.includeFunctionCall = false; const stream = streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, { apiKey: "test-api-key", @@ -91,6 +106,7 @@ describe("Google raw stop reasons", () => { it("preserves raw Gemini finish reasons for Google Vertex errors", async () => { googleGenAiMock.finishReason = "SAFETY"; + googleGenAiMock.includeFunctionCall = false; const stream = streamGoogleVertex(getModel("google-vertex", "gemini-3-flash-preview"), context, { project: "test-project", @@ -103,4 +119,44 @@ describe("Google raw stop reasons", () => { expect(message.rawStopReason).toBe("SAFETY"); expect(message.errorMessage).toBe("Provider stopped with: SAFETY"); }); + + const adapters = [ + { + name: "Google Generative AI", + createStream: () => + streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, { + apiKey: "test-api-key", + }), + }, + { + name: "Google Vertex", + createStream: () => + streamGoogleVertex(getModel("google-vertex", "gemini-3-flash-preview"), context, { + project: "test-project", + location: "us-central1", + }), + }, + ]; + + it.each(adapters)("preserves MAX_TOKENS with a tool call as length for $name", async ({ createStream }) => { + googleGenAiMock.finishReason = "MAX_TOKENS"; + googleGenAiMock.includeFunctionCall = true; + + const message = await createStream().result(); + + expect(message.stopReason).toBe("length"); + expect(message.rawStopReason).toBe("MAX_TOKENS"); + expect(message.content.some((block) => block.type === "toolCall")).toBe(true); + }); + + it.each(adapters)("maps STOP with a tool call to toolUse for $name", async ({ createStream }) => { + googleGenAiMock.finishReason = "STOP"; + googleGenAiMock.includeFunctionCall = true; + + const message = await createStream().result(); + + expect(message.stopReason).toBe("toolUse"); + expect(message.rawStopReason).toBe("STOP"); + expect(message.content.some((block) => block.type === "toolCall")).toBe(true); + }); }); From ab0dc51fcfd1e4c0f8c04ab685812b748f3b8a70 Mon Sep 17 00:00:00 2001 From: Anders Bech Mellson Date: Fri, 14 Aug 2026 11:26:20 +0200 Subject: [PATCH 160/284] fix(coding-agent): use APP_NAME in user-facing messages (#8067) APP_NAME is derived from piConfig.name so a rebranded distribution shows its own name, and roughly 90 call sites already use it. Twelve user-facing strings hardcoded "pi" instead, so those messages named the upstream binary regardless of piConfig.name: - the trust project folder prompt - "Restart pi for this to take effect." after a saved trust decision - the "Start without extensions" hint on an extension load failure - the invalid session file error - the executable location in the self-update-unavailable notice - the three auth command usage strings and the unknown auth command error, which are printed next to `${APP_NAME} --help` in main.ts and so contradicted it - "is configured outside pi." in the provider setup dialog - the uncaughtException line Output is unchanged for pi itself: piConfig has no name, so APP_NAME falls back to "pi". --- packages/coding-agent/src/cli/auth-command.ts | 9 +++++---- packages/coding-agent/src/core/project-trust.ts | 4 ++-- packages/coding-agent/src/core/session-manager.ts | 4 ++-- packages/coding-agent/src/main.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 10 +++++++--- packages/coding-agent/src/package-manager-cli.ts | 2 +- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts index a0e58d14260..9ee80460279 100644 --- a/packages/coding-agent/src/cli/auth-command.ts +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -1,4 +1,5 @@ import type { AuthResult } from "@earendil-works/pi-ai"; +import { APP_NAME } from "../config.ts"; import type { Args } from "./args.ts"; export type AuthCommandKind = "check" | "api_key" | "bearer_token"; @@ -15,9 +16,9 @@ export interface AuthCommand { export class AuthCommandError extends Error {} const AUTH_COMMAND_USAGE: Record = { - check: "pi auth check --provider [--json] [--credentials] [--no-refresh]", - api_key: "pi auth print-api-key --provider [--model ]", - bearer_token: "pi auth print-bearer-token --provider [--model ] [--min-expiry ]", + check: `${APP_NAME} auth check --provider [--json] [--credentials] [--no-refresh]`, + api_key: `${APP_NAME} auth print-api-key --provider [--model ]`, + bearer_token: `${APP_NAME} auth print-bearer-token --provider [--model ] [--min-expiry ]`, }; export function getAuthCommandName(kind: AuthCommandKind): string { @@ -57,7 +58,7 @@ export function parseAuthCommand(args: string[]): AuthCommand | undefined { : undefined; if (!kind) { throw new AuthCommandError( - `Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key", "pi auth print-bearer-token", or "pi auth check".`, + `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, ); } diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts index 2521ad5dbc5..b9ece17e49e 100644 --- a/packages/coding-agent/src/core/project-trust.ts +++ b/packages/coding-agent/src/core/project-trust.ts @@ -1,4 +1,4 @@ -import { CONFIG_DIR_NAME } from "../config.ts"; +import { APP_NAME, CONFIG_DIR_NAME } from "../config.ts"; import { emitProjectTrustEvent } from "./extensions/runner.ts"; import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts"; import type { DefaultProjectTrust } from "./settings-manager.ts"; @@ -22,7 +22,7 @@ export interface ResolveProjectTrustedOptions { } function formatProjectTrustPrompt(cwd: string): string { - return `Trust project folder?\n${cwd}\n\nThis allows pi to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`; + return `Trust project folder?\n${cwd}\n\nThis allows ${APP_NAME} to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`; } async function selectProjectTrustOption( diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 70d39216465..cd9d2f437ae 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -17,7 +17,7 @@ import { readdir, stat } from "fs/promises"; import { join, resolve } from "path"; import { createInterface } from "readline"; import { StringDecoder } from "string_decoder"; -import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; +import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; import { type BashExecutionMessage, @@ -902,7 +902,7 @@ export class SessionManager { if (this.fileEntries.length === 0) { const explicitPath = this.sessionFile; if (statSync(explicitPath).size > 0) { - throw new Error(`Session file is not a valid pi session: ${explicitPath}`); + throw new Error(`Session file is not a valid ${APP_NAME} session: ${explicitPath}`); } this.newSession(); this.sessionFile = explicitPath; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5ca03f3d8e0..c6a66251455 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -67,7 +67,7 @@ import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; -const EXTENSION_LOAD_FAILURE_HINT = 'Hint: Start without extensions using "pi -ne".'; +const EXTENSION_LOAD_FAILURE_HINT = `Hint: Start without extensions using "${APP_NAME} -ne".`; /** * Read all content from piped stdin. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4e64341b2d4..07fc5ea1922 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3883,7 +3883,7 @@ export class InteractiveMode { try { this.ui.stop(); } catch {} - console.error("pi exiting due to uncaughtException:"); + console.error(`${APP_NAME} exiting due to uncaughtException:`); console.error(error); process.exit(1); } @@ -4759,7 +4759,7 @@ export class InteractiveMode { trustStore.setMany(selection.updates); done(); this.showStatus( - `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, + `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart ${APP_NAME} for this to take effect.`, ); }, onCancel: () => { @@ -5537,7 +5537,11 @@ export class InteractiveMode { providerOption.name, `${providerOption.name} setup`, ); - dialog.showInfo(`${providerOption.method?.name ?? "Authentication"} is configured outside pi.`, [], true); + dialog.showInfo( + `${providerOption.method?.name ?? "Authentication"} is configured outside ${APP_NAME}.`, + [], + true, + ); this.editorContainer.clear(); this.editorContainer.addChild(dialog); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index aedfdf0b9b4..0a05f315936 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -432,7 +432,7 @@ function printSelfUpdateUnavailable( const entrypoint = process.argv[1]; if (entrypoint) { console.error(""); - console.error(`Location of pi executable: ${entrypoint}`); + console.error(`Location of ${APP_NAME} executable: ${entrypoint}`); } } From 48bd3f424d4f26bc59d130ebc9589e838f252c06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 09:41:22 +0000 Subject: [PATCH 161/284] chore: approve contributors from issue #8092 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index d1ad0f3f89c..4ddc80f1433 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -371,3 +371,5 @@ haoyongchun1125-maker pr gwokhou pr gaoyk19 pr + +cad0p pr From 4caa3c440baaf94c9fe9ec054e04b9528b0a07ef Mon Sep 17 00:00:00 2001 From: Panoplos Date: Fri, 14 Aug 2026 11:46:46 +0200 Subject: [PATCH 162/284] fix(tui): route selection copy through the host clipboard (#8110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TuiAltScreen.copySelectionToClipboard() wrote a bare OSC 52 sequence and flashed "Copied!" unconditionally. Terminals that ignore OSC 52 clipboard writes (macOS Terminal.app, VTE terminals, tmux without OSC 52 passthrough) left the system clipboard untouched while the toast reported success. Add an injectable copySelection option to TuiAltScreenOptions (an async handler returning whether the copy succeeded) and use it when provided, falling back to the existing OSC 52 write otherwise. The coding agent now injects its native clipboard implementation (clipboard addon + pbcopy / wl-copy / xclip / xsel), so selection copy reaches the real clipboard and the toast only fires on verified success, with "Copy failed" otherwise. Closes #7761 Co-authored-by: 富永 誠 --- .../src/modes/interactive/interactive-mode.ts | 8 +++ packages/tui/src/tui-alt-screen.ts | 20 +++++++- packages/tui/test/tui-alt-screen.test.ts | 51 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 07fc5ea1922..6d5e6b884fc 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -352,6 +352,14 @@ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScr searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))), openUrl: openBrowser, onRightClickPaste: options.onRightClickPaste, + copySelection: async (text) => { + try { + await copyToClipboard(text); + return true; + } catch { + return false; + } + }, }); } return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory); diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 0dfc43c7f8c..783a92ab8f0 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -153,6 +153,11 @@ export interface TuiAltScreenOptions { openUrl?: (url: string) => void; /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ onRightClickPaste?: () => void; + /** + * Copy selected text to the system clipboard. Return `true` on success; the caller flashes + * an error otherwise. When omitted, the selection is copied via an OSC 52 write. + */ + copySelection?: (text: string) => Promise; } /** Alternate-screen TUI with a scrollable, application-owned viewport. */ @@ -192,6 +197,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private readonly searchCurrentMatchStyle: (text: string) => string; private readonly openUrl?: (url: string) => void; private readonly onRightClickPaste?: () => void; + private readonly copySelection?: (text: string) => Promise; constructor( terminal: Terminal, @@ -214,6 +220,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); this.openUrl = options.openUrl; this.onRightClickPaste = options.onRightClickPaste; + this.copySelection = options.copySelection; this.addInputListener((data) => this.handleViewportInput(data)); } @@ -971,7 +978,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.requestRender(); return; } - this.copySelectionToClipboard(); + void this.copySelectionToClipboard(); this.requestRender(); return; } @@ -1047,7 +1054,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) }; } - private copySelectionToClipboard(): void { + private async copySelectionToClipboard(): Promise { const selection = this.getSelectionBounds(); if (!selection) return; let sourceLines: readonly string[] = this.previousScreen; @@ -1069,6 +1076,15 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } const text = lines.join("\n"); if (text.length === 0) return; + // Prefer an injected clipboard implementation (native clipboard + platform tools with a + // verified success path) when the host app provides one. A bare OSC 52 write can show + // "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux + // without OSC 52 clipboard passthrough), so only report success when it actually copies. + if (this.copySelection) { + const ok = await this.copySelection(text); + this.flash(ok ? "Copied!" : "Copy failed"); + return; + } this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); this.flash("Copied!"); } diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 1e0d94c2287..2166debe774 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -963,6 +963,57 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("uses an injected copySelection handler instead of OSC 52 and reports success", async () => { + const terminal = new RecordingTerminal(20, 4); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + + assert.deepStrictEqual(copied, ["alpha\nbeta"]); + assert.ok( + terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")), + "must not emit OSC 52 when a copySelection handler is provided", + ); + assert.ok(terminal.getViewport().some((line) => line.includes("Copied!"))); + + tui.stop(); + }); + + it("flashes an error when the injected copySelection handler fails", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async () => false, + }); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + + assert.ok(terminal.getViewport().some((line) => line.includes("Copy failed"))); + assert.ok( + terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")), + "must not emit OSC 52 when a copySelection handler is provided", + ); + + tui.stop(); + }); + it("does not append whitespace to double-click word highlighting", async () => { const terminal = new RecordingTerminal(20, 1); const tui = new TuiAltScreen(terminal); From d10c974b940d7ce3c8439fcdf02d140f3a206458 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 14 Aug 2026 09:46:51 +0000 Subject: [PATCH 163/284] docs: audit changelogs since v0.84.1 --- packages/ai/CHANGELOG.md | 7 +++++++ packages/coding-agent/CHANGELOG.md | 29 +++++++++++++++++++++++++++++ packages/tui/CHANGELOG.md | 4 +++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e0e15deea95..efd76da7b4e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Added `createGatewayBindingFetch()` for routing Cloudflare AI Gateway requests through a Workers AI binding without an API token ([#7901](https://github.com/earendil-works/pi/pull/7901) by [@Maximo-Guk](https://github.com/Maximo-Guk)). +- Added `AssistantMessage.endTurn` to preserve OpenAI Codex's terminal `end_turn` signal for diagnostics ([#7766](https://github.com/earendil-works/pi/pull/7766)). + ### Changed - Changed Kimi Coding requests to use pi's runtime `User-Agent` header. @@ -16,6 +21,8 @@ - Fixed OpenAI Responses function and custom tool calls to preserve namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). - Fixed built-in and custom DeepSeek API models to send output limits through the supported `max_tokens` field. - Fixed Google Generative AI and Vertex AI responses with tool calls incorrectly treating output-limit or provider-error stops as normal tool use ([#8059](https://github.com/earendil-works/pi/issues/8059)). +- Fixed Amazon Bedrock replay rejecting tool arguments that contain empty object keys while preserving all valid nested values ([#7882](https://github.com/earendil-works/pi/pull/7882) by [@muyiyr](https://github.com/muyiyr)). +- Fixed DeepSeek compatibility detection for base URLs whose hostname contains uppercase letters ([#7933](https://github.com/earendil-works/pi/pull/7933) by [@yearth](https://github.com/yearth)). ## [0.84.1] - 2026-08-07 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e0651a7718a..6439c6b5fd2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### New Features + +- **Fullscreen transcript search** — Search and navigate matches in fullscreen mode. See [TUI Fullscreen Viewport](docs/keybindings.md#tui-fullscreen-viewport). +- **Configurable default tools** — Choose startup built-in tools globally or per project. See [Tools](docs/settings.md#tools). +- **Configurable fullscreen exit output** — Print the transcript or only a resume hint on exit. See [Interactive Mode](docs/usage.md#interactive-mode). + ### Added - Added fullscreen transcript search with `Ctrl+Shift+F`, incremental match highlighting, configurable search match theme colors, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. @@ -9,12 +15,18 @@ - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. - Added the `defaultTools` setting for configuring the initial built-in tool selection globally or per project. - Added `--use-theme ` to choose an initial per-run interactive theme without changing saved settings ([#7722](https://github.com/earendil-works/pi/pull/7722) by [@rwachtler](https://github.com/rwachtler)). +- Added `expandPromptTemplates` to extension `pi.sendUserMessage()` options for explicitly dispatching commands and expanding skills and prompt templates. See [`pi.sendUserMessage()`](docs/extensions.md#pisendusermessagecontent-options) ([#7857](https://github.com/earendil-works/pi/pull/7857) by [@mrexodia](https://github.com/mrexodia)). +- Added inherited `createGatewayBindingFetch()` for routing Cloudflare AI Gateway requests through a Workers AI binding without an API token ([#7901](https://github.com/earendil-works/pi/pull/7901) by [@Maximo-Guk](https://github.com/Maximo-Guk)). +- Added inherited `AssistantMessage.endTurn` to preserve OpenAI Codex's terminal `end_turn` signal for diagnostics ([#7766](https://github.com/earendil-works/pi/pull/7766)). +- Added inherited unbound single-line transcript scrolling actions for fullscreen mode. See [TUI Fullscreen Viewport](docs/keybindings.md#tui-fullscreen-viewport) ([#7903](https://github.com/earendil-works/pi/pull/7903) by [@midastruth](https://github.com/midastruth)). ### Changed - Changed inherited Kimi Coding requests to use pi's runtime `User-Agent` header. - Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead. - Documented the generic `AI_AGENT=pi` process marker and how it differs from `PI_CODING_AGENT=true` ([#7747](https://github.com/earendil-works/pi/issues/7747)). +- Changed inherited OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). +- Reduced inherited fullscreen rendering allocation churn by painting full-width layout rows directly instead of recompositing them on every frame. ### Fixed @@ -25,6 +37,23 @@ - Fixed inherited required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). - Updated the transitive `nanoid` development dependency to address a denial-of-service vulnerability. - Fixed fallback rendering for extension tool results to collapse long output and honor tool expansion ([#7979](https://github.com/earendil-works/pi/issues/7979)). +- Fixed JSON and RPC `message_update` events dropping cumulative usage during streaming. See [JSON Event Mode](docs/json.md) and [RPC `message_update`](docs/rpc.md#message_update-streaming) ([#7982](https://github.com/earendil-works/pi/pull/7982) by [@christianklotz](https://github.com/christianklotz)). +- Fixed `pi.sendMessage(..., { triggerTurn: false })` steering an active run instead of only recording the custom message ([#8022](https://github.com/earendil-works/pi/pull/8022) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Fixed the `defaultTools` setting dropping extension and SDK custom tools when selecting built-in defaults. +- Fixed the subagent example rejecting YAML array syntax for the `tools` frontmatter field ([#7598](https://github.com/earendil-works/pi/pull/7598) by [@alexsavio](https://github.com/alexsavio)). +- Fixed the subagent example dropping parent session model, thinking, and tool configuration ([#7897](https://github.com/earendil-works/pi/pull/7897) by [@virtuald](https://github.com/virtuald)). +- Fixed custom system prompts concatenating the current working directory with later appended prompt content ([#7887](https://github.com/earendil-works/pi/pull/7887) by [@distributedlock](https://github.com/distributedlock)). +- Fixed inherited OpenAI Responses function and custom tool calls losing namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). +- Fixed inherited upstream request buffer failures not triggering automatic assistant retries. +- Fixed inherited built-in and custom DeepSeek API models sending output limits through an unsupported field. +- Fixed inherited Amazon Bedrock replay rejecting tool arguments that contain empty object keys while preserving all valid nested values ([#7882](https://github.com/earendil-works/pi/pull/7882) by [@muyiyr](https://github.com/muyiyr)). +- Fixed inherited DeepSeek compatibility detection for base URLs whose hostname contains uppercase letters ([#7933](https://github.com/earendil-works/pi/pull/7933) by [@yearth](https://github.com/yearth)). +- Fixed inherited Google Generative AI and Vertex AI responses with tool calls incorrectly treating output-limit or provider-error stops as normal tool use ([#8059](https://github.com/earendil-works/pi/issues/8059)). +- Fixed inherited fullscreen mouse drag selection and OSC 8 link activation in terminals that report generic SGR mouse release button codes ([#7963](https://github.com/earendil-works/pi/issues/7963)). +- Fixed inherited focused fullscreen overlays not receiving mouse wheel or viewport scroll keys such as PageUp and PageDown ([#7894](https://github.com/earendil-works/pi/issues/7894)). +- Fixed inherited LaTeX control spaces split across line endings causing complete expressions to fall back to raw source. +- Fixed split `Alt+Enter` input over SSH being misread as Escape, added `PI_TUI_ESC_TIMEOUT` for high-latency terminals, and limited that timeout to lone Escape input ([#7899](https://github.com/earendil-works/pi/pull/7899) by [@powerfooI](https://github.com/powerfooI)). +- Fixed inherited idle fullscreen sessions repainting and clearing text selection when the terminal loses focus ([#7892](https://github.com/earendil-works/pi/pull/7892) by [@terrorobe](https://github.com/terrorobe)). ## [0.84.1] - 2026-08-07 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3be21b9ab76..f653d35f9d1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added unbound single-line transcript scrolling actions, `tui.altScreen.lineUp` and `tui.altScreen.lineDown`, for fullscreen TUI keybindings ([#7830](https://github.com/earendil-works/pi/issues/7830)). +- Added unbound single-line transcript scrolling actions, `tui.altScreen.lineUp` and `tui.altScreen.lineDown`, for fullscreen TUI keybindings ([#7903](https://github.com/earendil-works/pi/pull/7903) by [@midastruth](https://github.com/midastruth)). - Added incremental primary-scroll-view search to the fullscreen TUI with configurable match styles, `Ctrl+Shift+F`, and next/previous navigation with `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G`. ### Changed @@ -18,6 +18,8 @@ - Fixed required LaTeX arguments starting on a new line being parsed as empty ([#7760](https://github.com/earendil-works/pi/issues/7760)). - Fixed LaTeX control spaces split across line endings causing complete expressions to fall back to raw source. - Fixed focused fullscreen overlays not receiving mouse wheel or viewport scroll keys such as PageUp and PageDown ([#7894](https://github.com/earendil-works/pi/issues/7894)). +- Fixed split `Alt+Enter` input over SSH being misread as Escape, added `PI_TUI_ESC_TIMEOUT` for high-latency terminals, and limited that timeout to lone Escape input ([#7899](https://github.com/earendil-works/pi/pull/7899) by [@powerfooI](https://github.com/powerfooI)). +- Fixed idle fullscreen sessions repainting and clearing text selection when the terminal loses focus ([#7892](https://github.com/earendil-works/pi/pull/7892) by [@terrorobe](https://github.com/terrorobe)). ## [0.84.1] - 2026-08-07 From 9b4adc823fa05f883565fa6b7a2ea58de3e39254 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 14 Aug 2026 09:50:22 +0000 Subject: [PATCH 164/284] docs: audit changelogs since v0.84.1 --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6439c6b5fd2..19b52173a40 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -54,6 +54,7 @@ - Fixed inherited LaTeX control spaces split across line endings causing complete expressions to fall back to raw source. - Fixed split `Alt+Enter` input over SSH being misread as Escape, added `PI_TUI_ESC_TIMEOUT` for high-latency terminals, and limited that timeout to lone Escape input ([#7899](https://github.com/earendil-works/pi/pull/7899) by [@powerfooI](https://github.com/powerfooI)). - Fixed inherited idle fullscreen sessions repainting and clearing text selection when the terminal loses focus ([#7892](https://github.com/earendil-works/pi/pull/7892) by [@terrorobe](https://github.com/terrorobe)). +- Fixed fullscreen selection copy to use the host clipboard and report failure instead of claiming success when OSC 52 is unsupported ([#8110](https://github.com/earendil-works/pi/pull/8110) by [@Panoplos](https://github.com/Panoplos)). ## [0.84.1] - 2026-08-07 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index f653d35f9d1..6225889de06 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -20,6 +20,7 @@ - Fixed focused fullscreen overlays not receiving mouse wheel or viewport scroll keys such as PageUp and PageDown ([#7894](https://github.com/earendil-works/pi/issues/7894)). - Fixed split `Alt+Enter` input over SSH being misread as Escape, added `PI_TUI_ESC_TIMEOUT` for high-latency terminals, and limited that timeout to lone Escape input ([#7899](https://github.com/earendil-works/pi/pull/7899) by [@powerfooI](https://github.com/powerfooI)). - Fixed idle fullscreen sessions repainting and clearing text selection when the terminal loses focus ([#7892](https://github.com/earendil-works/pi/pull/7892) by [@terrorobe](https://github.com/terrorobe)). +- Fixed fullscreen selection copy falsely reporting success when OSC 52 is unsupported by allowing host clipboard integration and reporting verified failures ([#8110](https://github.com/earendil-works/pi/pull/8110) by [@Panoplos](https://github.com/Panoplos)). ## [0.84.1] - 2026-08-07 From 914cf1472e715297caa30db4b9535d534a9eb718 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 14 Aug 2026 12:00:00 +0200 Subject: [PATCH 165/284] Release v0.84.2 --- package-lock.json | 60 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 6 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 4 +- packages/ai/src/image-models.generated.ts | 45 ++++++++++++++ packages/client/CHANGELOG.md | 2 +- packages/client/package.json | 4 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/gondolin/package-lock.json | 4 +- .../examples/extensions/gondolin/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +- .../extensions/with-deps/package.json | 2 +- .../install-lock/package-lock.json | 52 ++++++++-------- .../coding-agent/install-lock/package.json | 4 +- packages/coding-agent/npm-shrinkwrap.json | 46 +++++++------- packages/coding-agent/package.json | 12 ++-- packages/evals/package.json | 6 +- packages/protocol/CHANGELOG.md | 2 +- packages/protocol/package.json | 2 +- packages/server/CHANGELOG.md | 2 +- packages/server/package.json | 6 +- .../session-backends/sqlite-node/CHANGELOG.md | 2 +- .../session-backends/sqlite-node/package.json | 6 +- packages/telemetry/CHANGELOG.md | 2 +- packages/telemetry/package.json | 2 +- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 33 files changed, 173 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb467b63808..1f77aa69a44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5388,11 +5388,11 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-telemetry": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.2", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -5434,12 +5434,12 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", @@ -5480,10 +5480,10 @@ }, "packages/client": { "name": "@earendil-works/pi-client", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.1" + "@earendil-works/pi-protocol": "^0.84.2" }, "devDependencies": { "shx": "0.4.0", @@ -5495,14 +5495,14 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.1", - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-client": "^0.84.1", - "@earendil-works/pi-protocol": "^0.84.1", - "@earendil-works/pi-tui": "^0.84.1", + "@earendil-works/pi-agent-core": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-client": "^0.84.2", + "@earendil-works/pi-protocol": "^0.84.2", + "@earendil-works/pi-tui": "^0.84.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5544,32 +5544,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.84.1", + "version": "0.84.2", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.84.1" + "version": "0.84.2" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.84.1", + "version": "0.84.2", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.14.1", + "version": "1.14.2", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.84.1", + "version": "0.84.2", "dependencies": { "ms": "2.1.3" }, @@ -5640,10 +5640,10 @@ }, "packages/evals": { "name": "@earendil-works/pi-evals", - "version": "0.84.1", + "version": "0.84.2", "devDependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-coding-agent": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-coding-agent": "^0.84.2", "@types/node": "24.12.4", "shx": "0.4.0", "typescript": "5.9.3", @@ -5670,7 +5670,7 @@ }, "packages/protocol": { "name": "@earendil-works/pi-protocol", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -5685,11 +5685,11 @@ }, "packages/server": { "name": "@earendil-works/pi-server", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-protocol": "^0.84.1" + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-protocol": "^0.84.2" }, "devDependencies": { "shx": "0.4.0", @@ -5701,11 +5701,11 @@ }, "packages/session-backends/sqlite-node": { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.1", - "@earendil-works/pi-ai": "^0.84.1" + "@earendil-works/pi-agent-core": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.2" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", @@ -5717,7 +5717,7 @@ }, "packages/telemetry": { "name": "@earendil-works/pi-telemetry", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "devDependencies": { "@types/node": "24.12.4", @@ -5746,7 +5746,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 81e894d8c8b..6566d349718 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index 4c011eaf789..f204dcfe704 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.84.1", + "version": "0.84.2", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -35,8 +35,8 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-telemetry": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.2", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index efd76da7b4e..77d791780f7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ### Added diff --git a/packages/ai/package.json b/packages/ai/package.json index 552876250f8..fe1f336d89b 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.84.1", + "version": "0.84.2", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -62,7 +62,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index 2679897286f..80bb8f77dee 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -80,6 +80,36 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "bytedance-seed/seedream-5-0-lite": { + id: "bytedance-seed/seedream-5-0-lite", + name: "ByteDance Seed: Seedream 5.0 Lite", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "bytedance-seed/seedream-5-0-pro": { + id: "bytedance-seed/seedream-5-0-pro", + name: "ByteDance Seed: Seedream 5.0 Pro", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "google/gemini-2.5-flash-image": { id: "google/gemini-2.5-flash-image", name: "Google: Nano Banana (Gemini 2.5 Flash Image)", @@ -620,6 +650,21 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "x-ai/grok-imagine-image-2.0": { + id: "x-ai/grok-imagine-image-2.0", + name: "xAI: Grok Imagine Image 2.0", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "x-ai/grok-imagine-image-quality": { id: "x-ai/grok-imagine-image-quality", name: "SpaceXAI: Grok Imagine Image Quality", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 396ee220bea..0b71c98cfce 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/client/package.json b/packages/client/package.json index 81efdead8a1..45d5a103f4b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-client", - "version": "0.84.1", + "version": "0.84.2", "description": "Transport-neutral client for remote pi sessions over framed CBOR bytes", "type": "module", "main": "./dist/index.js", @@ -47,7 +47,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-protocol": "^0.84.1" + "@earendil-works/pi-protocol": "^0.84.2" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 19b52173a40..dc2c6e4a815 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ### New Features diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 40ac536edcf..272f0fcb735 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.84.1", + "version": "0.84.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.84.1", + "version": "0.84.2", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index e4760cc1112..7c8231631ea 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.84.1", + "version": "0.84.2", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index a13a3a8cb70..9a8e14ff5be 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.84.1", + "version": "0.84.2", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index 5ba0c2d840b..df80c87fd8e 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.84.1", + "version": "0.84.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.84.1", + "version": "0.84.2", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 83019c1d39d..450c74f69d4 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.84.1", + "version": "0.84.2", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 450c0460dc5..22d48511490 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.14.1", + "version": "1.14.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.14.1", + "version": "1.14.2", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 7e028f2848c..57ba99af509 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.14.1", + "version": "1.14.2", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index 963fb84a8ca..fc39ce641c6 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.84.1", + "version": "0.84.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.84.1", + "version": "0.84.2", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 08a30f3300d..907d96bc1a3 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.84.1", + "version": "0.84.2", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index d55a533d66c..5f43256d30e 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.1", + "version": "0.84.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.1", + "version": "0.84.2", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.1" + "@earendil-works/pi-coding-agent": "0.84.2" }, "engines": { "node": ">=22.19.0" @@ -450,12 +450,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-telemetry": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.2", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -466,13 +466,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", @@ -490,26 +490,26 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.1" + "@earendil-works/pi-protocol": "^0.84.2" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.2.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.1", - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-client": "^0.84.1", - "@earendil-works/pi-protocol": "^0.84.1", - "@earendil-works/pi-tui": "^0.84.1", + "@earendil-works/pi-agent-core": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-client": "^0.84.2", + "@earendil-works/pi-protocol": "^0.84.2", + "@earendil-works/pi-tui": "^0.84.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -538,8 +538,8 @@ } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -549,16 +549,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index a761b0bdcab..4c35a51ec80 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.1", + "version": "0.84.2", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.1" + "@earendil-works/pi-coding-agent": "0.84.2" }, "overrides": { "protobufjs": "7.6.5", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index bb64c682ddf..2f2c309b350 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,19 +1,19 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.1", + "version": "0.84.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.1", + "version": "0.84.2", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.1", - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-client": "^0.84.1", - "@earendil-works/pi-protocol": "^0.84.1", - "@earendil-works/pi-tui": "^0.84.1", + "@earendil-works/pi-agent-core": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-client": "^0.84.2", + "@earendil-works/pi-protocol": "^0.84.2", + "@earendil-works/pi-tui": "^0.84.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -477,12 +477,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-telemetry": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.2", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -493,13 +493,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", @@ -517,19 +517,19 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.1" + "@earendil-works/pi-protocol": "^0.84.2" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -539,16 +539,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", + "version": "0.84.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index f6fc21a3d58..58d9dd64d57 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.1", + "version": "0.84.2", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -43,11 +43,11 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.1", - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-client": "^0.84.1", - "@earendil-works/pi-protocol": "^0.84.1", - "@earendil-works/pi-tui": "^0.84.1", + "@earendil-works/pi-agent-core": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-client": "^0.84.2", + "@earendil-works/pi-protocol": "^0.84.2", + "@earendil-works/pi-tui": "^0.84.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/evals/package.json b/packages/evals/package.json index 087ad17bf33..3acd48e241e 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-evals", - "version": "0.84.1", + "version": "0.84.2", "private": true, "type": "module", "scripts": { @@ -9,8 +9,8 @@ "test": "vitest run --config vitest.test.config.ts" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-coding-agent": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-coding-agent": "^0.84.2", "@types/node": "24.12.4", "shx": "0.4.0", "typescript": "5.9.3", diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index 3e32c6a5726..7693020f725 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 6f9186fc979..1c943ec27c8 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-protocol", - "version": "0.84.1", + "version": "0.84.2", "description": "Transport-neutral CBOR protocol for remote pi sessions", "type": "module", "main": "./dist/index.js", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 7dcb6e9e1f9..677c7241235 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/server/package.json b/packages/server/package.json index 5cf0bd71874..cdbbf338a25 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-server", - "version": "0.84.1", + "version": "0.84.2", "description": "experimental server package for pi", "type": "module", "main": "./dist/index.js", @@ -47,8 +47,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-protocol": "^0.84.1" + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-protocol": "^0.84.2" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index ba9a214ff4a..7d7fbd53438 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/session-backends/sqlite-node/package.json b/packages/session-backends/sqlite-node/package.json index e54f41a08c8..145fffeac68 100644 --- a/packages/session-backends/sqlite-node/package.json +++ b/packages/session-backends/sqlite-node/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.1", + "version": "0.84.2", "description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions", "type": "module", "main": "./dist/index.js", @@ -34,8 +34,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.1", - "@earendil-works/pi-agent-core": "^0.84.1" + "@earendil-works/pi-ai": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.2" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 9dc96f4f819..68b56597800 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index a06674100e0..37e1153223e 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-telemetry", - "version": "0.84.1", + "version": "0.84.2", "description": "Vendor-neutral telemetry contracts and typed schema utilities for pi", "type": "module", "main": "./dist/index.js", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6225889de06..6029ceea870 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.2] - 2026-08-14 ### Added diff --git a/packages/tui/package.json b/packages/tui/package.json index 52cabe21296..034d351ab48 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.84.1", + "version": "0.84.2", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 0e0021fbbe83d67338b910aca8820bd99126793d Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 14 Aug 2026 12:00:04 +0200 Subject: [PATCH 166/284] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/client/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/protocol/CHANGELOG.md | 2 ++ packages/server/CHANGELOG.md | 2 ++ packages/session-backends/sqlite-node/CHANGELOG.md | 2 ++ packages/telemetry/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 9 files changed, 18 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 6566d349718..679110f18d1 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 77d791780f7..182e1d8a389 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ### Added diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 0b71c98cfce..49ecd548fc2 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dc2c6e4a815..0b7de08477f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ### New Features diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index 7693020f725..d4328d9eae4 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 677c7241235..1725d40d7cd 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index 7d7fbd53438..30064cb53c3 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 68b56597800..e9de35b6460 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ## [0.84.1] - 2026-08-07 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6029ceea870..ae7c200284c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.2] - 2026-08-14 ### Added From c36552212e898221cc9435cc78f4e1c443ecbbca Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 14 Aug 2026 12:45:26 +0200 Subject: [PATCH 167/284] agents: Adjust rules for changelog entries --- .pi/prompts/wr.md | 1 + AGENTS.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.pi/prompts/wr.md b/.pi/prompts/wr.md index 8f05bd8fba8..5d9b11bbc33 100644 --- a/.pi/prompts/wr.md +++ b/.pi/prompts/wr.md @@ -38,3 +38,4 @@ Constraints: - Do not open a PR unless I explicitly ask. - If this is not GitHub issue or PR work, do not post a GitHub comment. - If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask. +- When working against a branch other than `main`, skip the changelog. diff --git a/AGENTS.md b/AGENTS.md index 09eb963f0de..765f376b87a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,7 @@ Rules: - All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them. - Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them. +- Do not create changelog entries when working on a branch other than `main` or pull request Attribution: From e429d90b800f9a37c8a5812f4c9c10a8cdcc85a7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 14 Aug 2026 13:06:21 +0200 Subject: [PATCH 168/284] fix(coding-agent): update Z.AI Coding Plan defaults closes #8096 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/core/model-resolver.ts | 4 ++-- packages/coding-agent/test/model-resolver.test.ts | 14 +++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0b7de08477f..934b09c734f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). + ## [0.84.2] - 2026-08-14 ### New Features diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index eb70a573b13..cfc36ec1623 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -35,8 +35,8 @@ export const defaultModelPerProvider: Record = { xai: "grok-4.5", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.7", - zai: "glm-5.1", - "zai-coding-cn": "glm-5.1", + zai: "glm-5.3", + "zai-coding-cn": "glm-5.3", mistral: "devstral-medium-latest", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index f756172d4af..ddf83c5691b 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,4 +1,5 @@ import type { Model } from "@earendil-works/pi-ai"; +import { getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all"; import { describe, expect, test, vi } from "vitest"; import { defaultModelPerProvider, @@ -700,13 +701,24 @@ describe("default model selection", () => { }); test("zai, minimax, cerebras, and ant-ling defaults track current models", () => { - expect(defaultModelPerProvider.zai).toBe("glm-5.1"); + expect(defaultModelPerProvider.zai).toBe("glm-5.3"); + expect(defaultModelPerProvider["zai-coding-cn"]).toBe("glm-5.3"); expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7"); expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T"); }); + test("built-in defaults exist in generated provider catalogs", () => { + for (const provider of getBuiltinProviders()) { + const defaultId = defaultModelPerProvider[provider]; + expect( + getBuiltinModels(provider).some((model) => model.id === defaultId), + `${provider} default ${defaultId} should exist in its generated catalog`, + ).toBe(true); + } + }); + test("ai-gateway default tracks current model", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("zai/glm-5.1"); }); From b1efcf7d7c5d7394fbb12ede0174e04d39ee7004 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 13:46:34 +0000 Subject: [PATCH 169/284] chore: approve contributors from issue #8124 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 4ddc80f1433..fb8384f185b 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -373,3 +373,5 @@ gwokhou pr gaoyk19 pr cad0p pr + +Jaaneek pr From d5278eaac3446a65e41d5f1392b2c79571858393 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Sat, 15 Aug 2026 07:24:34 +0200 Subject: [PATCH 170/284] fix(ai): enable Copilot model policies sequentially during login Batching policy POSTs at concurrency 4 still bursts 30 requests during login, which trips the Copilot API rate limiter and makes the following GET /models fail with 429, aborting login (#8121). Send the policy updates one at a time instead. --- packages/ai/src/auth/oauth/github-copilot.ts | 10 ++-------- packages/ai/test/github-copilot-oauth.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index 1c9aabc3c50..a5aa15ebe85 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -16,7 +16,6 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; const COPILOT_API_VERSION = "2026-06-01"; -const COPILOT_POLICY_CONCURRENCY = 4; type DeviceCodeResponse = { device_code: string; @@ -344,13 +343,8 @@ async function enableAllGitHubCopilotModels( enterpriseDomain: string | undefined, signal: AbortSignal, ): Promise { - const models = Object.values(GITHUB_COPILOT_MODELS); - for (let index = 0; index < models.length; index += COPILOT_POLICY_CONCURRENCY) { - await Promise.all( - models.slice(index, index + COPILOT_POLICY_CONCURRENCY).map(async (model) => { - await enableGitHubCopilotModel(token, model.id, enterpriseDomain, signal); - }), - ); + for (const model of Object.values(GITHUB_COPILOT_MODELS)) { + await enableGitHubCopilotModel(token, model.id, enterpriseDomain, signal); } } diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index ae8ae32568b..4094291a805 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -239,7 +239,7 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); - it("limits concurrent model policy updates during login", async () => { + it("enables model policies sequentially during login", async () => { vi.useFakeTimers(); let activePolicyRequests = 0; @@ -297,8 +297,8 @@ describe("GitHub Copilot OAuth device flow", () => { await vi.advanceTimersByTimeAsync(1000); await loginPromise; - expect(policyRequestCount).toBeGreaterThan(4); - expect(maxActivePolicyRequests).toBe(4); + expect(policyRequestCount).toBeGreaterThan(1); + expect(maxActivePolicyRequests).toBe(1); }); it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { From 086c32e74530564922d011ade23ff582c9d63116 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Sat, 15 Aug 2026 07:33:05 +0200 Subject: [PATCH 171/284] fix(ai): retry Copilot GET /models once on 429 during login The login-time policy updates can drain the Copilot API rate-limit bucket, in which case the follow-up GET /models is rejected with 429 (observed response: retry-after: 2, body 'too many requests') and login aborts. Honor Retry-After and retry once instead of failing (#8121). --- packages/ai/src/auth/oauth/device-code.ts | 2 +- packages/ai/src/auth/oauth/github-copilot.ts | 42 +++++++++---- packages/ai/test/github-copilot-oauth.test.ts | 59 +++++++++++++++++++ 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/auth/oauth/device-code.ts b/packages/ai/src/auth/oauth/device-code.ts index f87c5e3b541..a078155c89a 100644 --- a/packages/ai/src/auth/oauth/device-code.ts +++ b/packages/ai/src/auth/oauth/device-code.ts @@ -23,7 +23,7 @@ export type OAuthDeviceCodePollOptions = { signal: AbortSignal; }; -function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise { +export function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new Error(cancelMessage)); diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index a5aa15ebe85..8bd6342692c 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -4,7 +4,7 @@ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; -import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { abortableSleep, pollOAuthDeviceCodeFlow } from "./device-code.ts"; const decode = (s: string) => atob(s); const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg="); @@ -16,6 +16,8 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; const COPILOT_API_VERSION = "2026-06-01"; +const MAX_RETRY_AFTER_MS = 10_000; +const DEFAULT_RETRY_AFTER_MS = 1_000; type DeviceCodeResponse = { device_code: string; @@ -121,16 +123,34 @@ async function fetchAvailableGitHubCopilotModelIds( // Some Individual accounts return false for every picker flag despite explicit enabled policies. // Limit the fallback to that endpoint so other account types keep strict picker semantics. const allowPolicyFallback = baseUrl === "https://api.individual.githubcopilot.com"; - const raw = await fetchJson(`${baseUrl}/models`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${copilotToken}`, - ...COPILOT_HEADERS, - "X-GitHub-Api-Version": COPILOT_API_VERSION, - }, - signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), - }); - return parseAvailableCopilotModelIds(raw, allowPolicyFallback); + const request = () => + fetch(`${baseUrl}/models`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${copilotToken}`, + ...COPILOT_HEADERS, + "X-GitHub-Api-Version": COPILOT_API_VERSION, + }, + signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), + }); + + // The login-time policy updates can drain the Copilot API rate-limit bucket, in which case + // this request is rejected with 429. Honor Retry-After and retry once instead of failing. + let response = await request(); + if (response.status === 429) { + const retryAfterSeconds = Number(response.headers.get("retry-after")); + const waitMs = + Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? Math.min(retryAfterSeconds * 1000, MAX_RETRY_AFTER_MS) + : DEFAULT_RETRY_AFTER_MS; + await abortableSleep(waitMs, signal, "Login cancelled"); + response = await request(); + } + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status} ${response.statusText}: ${text}`); + } + return parseAvailableCopilotModelIds(await response.json(), allowPolicyFallback); } async function fetchJson(url: string, init: RequestInit): Promise { diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 4094291a805..61ceea3e570 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -301,6 +301,65 @@ describe("GitHub Copilot OAuth device flow", () => { expect(maxActivePolicyRequests).toBe(1); }); + it("retries GET /models once after a 429, honoring Retry-After", async () => { + vi.useFakeTimers(); + + let modelsRequestCount = 0; + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 1, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.endsWith("/models")) { + modelsRequestCount += 1; + if (modelsRequestCount === 1) { + return new Response("too many requests", { status: 429, headers: { "retry-after": "1" } }); + } + return jsonResponse({ data: [{ id: "gpt-5.4", model_picker_enabled: true }] }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const loginPromise = loginGitHubCopilotForTest({ + onDeviceCode: () => {}, + onPrompt: async () => "", + }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); // device poll + await vi.advanceTimersByTimeAsync(1000); // Retry-After wait + const credentials = await loginPromise; + + expect(modelsRequestCount).toBe(2); + expect(credentials.availableModelIds).toEqual(["gpt-5.4"]); + }); + it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { // A malicious enterprise OAuth server could return a verification_uri that // the browser launcher would otherwise hand to the OS. Ensure such values From 70e878d4cf8f643067c8fc73ea27b74133dca0ab Mon Sep 17 00:00:00 2001 From: Milosz Jankiewicz <25470423+Jaaneek@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:47:38 +0100 Subject: [PATCH 172/284] feat(ai): route xAI models through Responses and default to Grok 4.6 (#8124) * feat(ai): route xAI models through Responses and default to Grok 4.6 Send built-in xAI catalog models through the Responses API with store: false and include reasoning.encrypted_content so encrypted reasoning is requested and replayed, including future models.dev additions. Thinking levels come from models.dev reasoning_options (grok-4.6 exposes xhigh); models without verified options (grok-build-0.1) keep reasoning always on and never send unsupported "none"/"minimal" efforts. Narrow the xAI provider to the Responses API, send pi's runtime User-Agent on xAI requests for provider-side attribution, and make Grok 4.6 the default xAI model. * fix(ai): drop duplicate xAI encrypted-reasoning tests Azure already covers the shared replay path; keep the xAI-specific include-without-effort case next to the existing request tests. --------- Co-authored-by: Jaaneek --- packages/ai/README.md | 2 +- packages/ai/scripts/generate-models.ts | 20 +-- packages/ai/src/api/openai-completions.ts | 5 + packages/ai/src/api/openai-responses.ts | 5 + packages/ai/src/providers/xai.ts | 8 +- packages/ai/src/utils/pi-user-agent.ts | 8 + packages/ai/test/model-catalog-types.test.ts | 4 +- packages/ai/test/stream.test.ts | 2 +- packages/ai/test/supports-xhigh.test.ts | 6 + packages/ai/test/xai-responses.test.ts | 144 +++++++++++++++++- .../coding-agent/src/core/model-resolver.ts | 2 +- .../coding-agent/test/model-resolver.test.ts | 4 + 12 files changed, 185 insertions(+), 25 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index 16b3be096c3..e010fdf2589 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -794,7 +794,7 @@ Many models support thinking/reasoning capabilities where they can show their in const model = models.getModel('anthropic', 'claude-sonnet-4-5')!; // or models.getModel('openai', 'gpt-5-mini'); // or models.getModel('google', 'gemini-2.5-flash'); -// or models.getModel('xai', 'grok-4.5'); +// or models.getModel('xai', 'grok-4.6'); // Check if model supports reasoning if (model.reasoning) { diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e36d5551be5..1f3a49bcbf4 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -410,7 +410,6 @@ const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.6-terra", "gpt-5.6-luna", ]); -const XAI_RESPONSES_MODEL_ID = "grok-4.5"; const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ "grok-3", "grok-3-fast", @@ -418,10 +417,6 @@ const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ "grok-4.20-0309-reasoning", "grok-code-fast-1", ]); -const XAI_RESPONSES_EFFORT_LEVEL_MAP = { - off: null, - minimal: null, -} as const; const XAI_RESPONSES_COMPAT: OpenAIResponsesCompat = { supportsLongCacheRetention: false, }; @@ -823,8 +818,10 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { off: "none" }); } - if (model.provider === "xai" && model.api === "openai-responses" && model.id === XAI_RESPONSES_MODEL_ID) { - mergeThinkingLevelMap(model, XAI_RESPONSES_EFFORT_LEVEL_MAP); + // xAI models without verified effort options (e.g. grok-build-0.1) must not + // send the undocumented "none"/"minimal" efforts. + if (model.provider === "xai" && model.api === "openai-responses" && model.thinkingLevelMap === undefined) { + mergeThinkingLevelMap(model, { off: null, minimal: null }); } if (supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); @@ -1632,15 +1629,14 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data.xai.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; - const useResponsesApi = modelId === XAI_RESPONSES_MODEL_ID; models.push({ id: modelId, name: m.name || modelId, - api: useResponsesApi ? "openai-responses" : "openai-completions", + api: "openai-responses", provider: "xai", baseUrl: "https://api.x.ai/v1", - ...(useResponsesApi ? { compat: { ...XAI_RESPONSES_COMPAT } } : {}), + compat: { ...XAI_RESPONSES_COMPAT }, reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { @@ -1946,10 +1942,10 @@ async function loadModelsDevData(): Promise[]> { // Claude 4.x and 5.x models route to Anthropic Messages API const isCopilotClaude = /^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/.test(modelId); - // Grok 4.5, gpt-5, oswe, and MAI-Code models are only served through + // Grok, gpt-5, oswe, and MAI-Code models are only served through // the Copilot /responses endpoint. const needsResponsesApi = - modelId === "grok-4.5" || + modelId.startsWith("grok-") || modelId.startsWith("gpt-5") || modelId.startsWith("oswe") || modelId.startsWith("mai-"); diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index c62ef9f874e..268e4f4f8c3 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -39,6 +39,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -670,6 +671,10 @@ function createClient( Object.assign(headers, optionsHeaders); } + if (model.provider === "xai") { + forcePiUserAgent(headers); + } + return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 578398e8eb8..58d179c7ccf 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -19,6 +19,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -246,6 +247,10 @@ function createClient( Object.assign(headers, optionsHeaders); } + if (model.provider === "xai") { + forcePiUserAgent(headers); + } + return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index c9fe6c349e9..8e2b179c28a 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,11 +1,10 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { loadXaiOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import { XAI_MODELS } from "./xai.models.ts"; -export function xaiProvider(): Provider<"openai-completions" | "openai-responses"> { +export function xaiProvider(): Provider<"openai-responses"> { return createProvider({ id: "xai", name: "xAI", @@ -20,9 +19,6 @@ export function xaiProvider(): Provider<"openai-completions" | "openai-responses }), }, models: Object.values(XAI_MODELS), - api: { - "openai-completions": openAICompletionsApi(), - "openai-responses": openAIResponsesApi(), - }, + api: openAIResponsesApi(), }); } diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts index 93b23dd3511..c36d5ffbc89 100644 --- a/packages/ai/src/utils/pi-user-agent.ts +++ b/packages/ai/src/utils/pi-user-agent.ts @@ -1,4 +1,5 @@ import type * as NodeOs from "node:os"; +import type { ProviderHeaders } from "../types.ts"; type ProcessWithOsBuiltinModule = typeof process & { getBuiltinModule?: (id: "node:os") => typeof NodeOs; @@ -17,3 +18,10 @@ const nodeOs = loadNodeOs(); export function getPiUserAgent(): string { return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; } + +export function forcePiUserAgent(headers: ProviderHeaders): void { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === "user-agent") delete headers[name]; + } + headers["User-Agent"] = getPiUserAgent(); +} diff --git a/packages/ai/test/model-catalog-types.test.ts b/packages/ai/test/model-catalog-types.test.ts index 0facda3dbc8..0918228fe8c 100644 --- a/packages/ai/test/model-catalog-types.test.ts +++ b/packages/ai/test/model-catalog-types.test.ts @@ -6,7 +6,9 @@ it("derives model API, ID, and provider literals from grouped model data", () => expectTypeOf(XAI_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">(); expectTypeOf(XAI_MODELS["grok-4.5"].id).toEqualTypeOf<"grok-4.5">(); expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">(); - expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-completions">(); + expectTypeOf(XAI_MODELS["grok-4.6"].api).toEqualTypeOf<"openai-responses">(); + expectTypeOf(XAI_MODELS["grok-4.6"].id).toEqualTypeOf<"grok-4.6">(); + expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-responses">(); }); it("routes GitHub Copilot Grok 4.5 through the Responses API", () => { diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index d3e7a02119b..bf68823d296 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -542,7 +542,7 @@ describe("Generate E2E Tests", () => { }); }); - describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Completions)", () => { + describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Responses)", () => { const llm = getModel("xai", "grok-4.3"); it("should complete basic text generation", { retry: 3 }, async () => { diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index a1fd7ff8f06..8d97b760712 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -147,6 +147,12 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("max"); }); + it("includes xhigh but not off or max for xAI Grok 4.6", () => { + const model = getModel("xai", "grok-4.6"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["low", "medium", "high", "xhigh"]); + }); + it("includes xhigh and max but not off for Bedrock Claude Fable 5", () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); expect(model).toBeDefined(); diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index 92e46420379..00e89081523 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -1,10 +1,15 @@ +import { arch, platform, release } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts"; +import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts"; import { getSupportedThinkingLevels } from "../src/models.ts"; import { XAI_MODELS } from "../src/providers/xai.models.ts"; import { xaiProvider } from "../src/providers/xai.ts"; import type { Context, Model } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + type CapturedRequest = { url: string; headers: Headers; @@ -72,10 +77,14 @@ describe("xAI Responses provider", () => { } }); - it("uses Responses with low/medium/high efforts only for Grok 4.5", () => { - expect(XAI_MODELS["grok-4.5"].api).toBe("openai-responses"); + it("routes every built-in xAI model through Responses", () => { + for (const model of Object.values(XAI_MODELS)) { + expect(model.api, model.id).toBe("openai-responses"); + } expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]); - expect(XAI_MODELS["grok-4.3"].api).toBe("openai-completions"); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.6"])).toEqual(["low", "medium", "high", "xhigh"]); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.3"])).toEqual(["off", "low", "medium", "high"]); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-build-0.1"])).toEqual(["low", "medium", "high"]); }); it("uses /responses with bearer auth and xAI-compatible request fields", async () => { @@ -95,6 +104,7 @@ describe("xAI Responses provider", () => { expect(captured.url).toBe("https://api.x.ai/v1/responses"); expect(captured.headers.get("authorization")).toBe("Bearer xai-test-token"); + expect(captured.headers.get("user-agent")).toBe(PI_USER_AGENT); expect(captured.headers.get("session_id")).toBe("pi-session-123"); expect(captured.body).toMatchObject({ model: "grok-4.5", @@ -114,4 +124,132 @@ describe("xAI Responses provider", () => { ]), ); }); + + it("requests encrypted reasoning without an effort override", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.5"], + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token" }, + ); + + expect(captured.body).toMatchObject({ + model: "grok-4.5", + store: false, + include: ["reasoning.encrypted_content"], + }); + expect(captured.body).not.toHaveProperty("reasoning"); + }); + + it("uses /responses for Grok 4.6 with xhigh effort and encrypted reasoning", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.6"], + { + systemPrompt: "You are a careful coding assistant.", + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + reasoningEffort: "xhigh", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.body).toMatchObject({ + model: "grok-4.6", + store: false, + stream: true, + reasoning: { effort: "xhigh" }, + include: ["reasoning.encrypted_content"], + }); + }); + + it("uses /responses for Grok 4.3", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.3"], + { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + reasoningEffort: "low", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.body).toMatchObject({ + model: "grok-4.3", + store: false, + include: ["reasoning.encrypted_content"], + reasoning: { effort: "low" }, + }); + }); + + it("keeps the SDK User-Agent for non-xAI Responses requests", async () => { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + return completedResponse(); + }); + + const openaiModel: Model<"openai-responses"> = { + ...XAI_MODELS["grok-4.5"], + provider: "openai", + baseUrl: "https://api.openai.com/v1", + }; + const result = await streamOpenAIResponses( + openaiModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "test-token" }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(userAgent).not.toBeNull(); + expect(userAgent).not.toBe(PI_USER_AGENT); + }); + + it("forces pi's User-Agent on custom xAI Completions models over caller headers", async () => { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + const chunks = [ + { id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] }, + { + id: "chatcmpl-ua", + choices: [{ delta: {}, finish_reason: "stop", index: 0 }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + const customModel: Model<"openai-completions"> = { + id: "grok-custom", + name: "Grok Custom", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }; + const result = await streamOpenAICompletions( + customModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(userAgent).toBe(PI_USER_AGENT); + }); }); diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index cfc36ec1623..aea6717e117 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -32,7 +32,7 @@ export const defaultModelPerProvider: Record = { "github-copilot": "gpt-5.4", openrouter: "moonshotai/kimi-k2.6", "vercel-ai-gateway": "zai/glm-5.1", - xai: "grok-4.5", + xai: "grok-4.6", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.7", zai: "glm-5.3", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index ddf83c5691b..c38b59f04a5 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -723,6 +723,10 @@ describe("default model selection", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("zai/glm-5.1"); }); + test("xai default tracks current model", () => { + expect(defaultModelPerProvider.xai).toBe("grok-4.6"); + }); + test("qwen token plan individual default tracks current model", () => { expect(defaultModelPerProvider["qwen-token-plan-individual"]).toBe("qwen3.8-max"); }); From d3ab2af969d64997338253c9151190aa1bc33580 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:48:06 +0200 Subject: [PATCH 173/284] fix: track kimi cached tokens (#8119) * fix: track kimi cached tokens * changelog --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/api/openai-completions.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 182e1d8a389..bfe0c60cf4f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). + ## [0.84.2] - 2026-08-14 ### Added diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 268e4f4f8c3..fd58a2284c8 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1381,6 +1381,7 @@ function parseChunkUsage( rawUsage: { prompt_tokens?: number; completion_tokens?: number; + cached_tokens?: number; prompt_cache_hit_tokens?: number; prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; completion_tokens_details?: { reasoning_tokens?: number }; @@ -1388,11 +1389,15 @@ function parseChunkUsage( model: Model<"openai-completions">, ): AssistantMessage["usage"] { const promptTokens = rawUsage.prompt_tokens || 0; - const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0; + const cacheReadTokens = + rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? rawUsage.cached_tokens ?? 0; const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0; // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read - // tokens (hits). OpenAI does not document or emit cache_write_tokens, but + // tokens (hits). Providers disagree on placement: OpenAI/OpenRouter use + // prompt_tokens_details.cached_tokens, DeepSeek uses prompt_cache_hit_tokens, + // and Kimi documents top-level usage.cached_tokens on the final usage chunk. + // OpenAI does not document or emit cache_write_tokens, but // OpenRouter-compatible providers can include it as a separate write count. // OpenRouter's own provider/tests affirm the separate mapping: // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409 From 47bf47f11f1379b142574dfeacf762981989add4 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 07:10:50 +0200 Subject: [PATCH 174/284] docs(coding-agent): clarify compaction paths --- .../coding-agent/src/core/agent-session.ts | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1a1228d006d..77d8b876436 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1784,7 +1784,17 @@ export class AgentSession { /** * Manually compact the session context. - * Aborts current agent operation first. + * + * This is the manual entry point used by `/compact`, RPC, and extensions. It is + * separate from automatic threshold/overflow compaction, which enters through + * `_checkCompaction()` and `_runAutoCompaction()`. After preparation and the + * `session_before_compact` hook, both paths call the lower-level `compact()` + * function imported from `./compaction/index.ts`, unless the hook cancels or + * supplies a custom result. + * + * Aborts the current agent operation first. Manual compaction never retries or + * continues the interrupted agent turn. + * * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { @@ -1850,7 +1860,7 @@ export class AgentSession { usage = extensionCompaction.usage; details = extensionCompaction.details; } else { - // Generate compaction result + // Shared default summary generator, also used by automatic compaction. const result = await compact( preparation, requestModel, @@ -1948,16 +1958,25 @@ export class AgentSession { } /** - * Check if compaction is needed and run it. - * Called after agent_end and before prompt submission. + * Dispatch automatic compaction after `agent_end` or before prompt submission. + * Manual compaction does not call this method; it enters through `compact()`. * - * Two cases: - * 1. Recoverable failure: LLM returned context overflow or stopped below its desired output limit; - * remove the assistant message, compact, and auto-retry once - * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually) + * Automatic cases: + * 1. Overflow with retry: a context-overflow error or recoverable length stop; + * remove the failed assistant message, compact, and retry the turn once. + * 2. Overflow without retry: a successful response exceeded the configured + * context window; compact but preserve the completed response. + * 3. Threshold without retry: valid or estimated context usage crossed the + * configured threshold; compact without retrying the completed response. + * + * Each case calls `_runAutoCompaction()`. After preparation and the + * `session_before_compact` hook, that method calls the lower-level `compact()` + * function imported from `./compaction/index.ts`, unless the hook cancels or + * supplies a custom result. * * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true + * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages */ private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { const settings = this.settingsManager.getCompactionSettings(); @@ -1985,15 +2004,15 @@ export class AgentSession { return false; } - // Case 1: Recoverable failure. Explicit/silent context overflow still uses context metadata. + // Automatic cases 1 and 2: context overflow. // A length stop is recoverable when output ended below the model's original desired limit, // independent of the configured context size or any context-clamped provider request limit. - // A successful response over the configured window should compact but must not retry: the - // assistant answer already completed and agent.continue() cannot continue from an assistant. const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0); if (sameModel && (isContextOverflow(assistantMessage, contextWindow) || recoverableLength)) { const willRetry = assistantMessage.stopReason !== "stop"; + // Case 2: the response completed successfully. Compact, but do not retry because + // agent.continue() cannot continue from a completed assistant response. if (!willRetry) { return await this._runAutoCompaction("overflow", false); } @@ -2011,9 +2030,9 @@ export class AgentSession { return false; } + // Case 1: remove the failed or truncated message from agent state, compact, and + // retry once. The message remains in session history but is excluded from retry context. this._overflowRecoveryAttempted = true; - // Remove the failed or truncated message from agent state. It remains in session history, - // but must not be included in the compact-and-retry context. const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.state.messages = messages.slice(0, -1); @@ -2021,7 +2040,7 @@ export class AgentSession { return await this._runAutoCompaction("overflow", willRetry); } - // Case 2: Threshold - context is getting large + // Case 3: threshold compaction without retry. // For error messages or all-zero usage messages, estimate from the last valid response. // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage // responses can still compact and do not reset context accounting. @@ -2053,7 +2072,14 @@ export class AgentSession { } /** - * Internal: Run auto-compaction with events. + * Execute threshold or overflow compaction. Manual compaction uses + * `AgentSession.compact()` instead. Both paths call the lower-level `compact()` + * function imported from `./compaction/index.ts` after preparation and extension + * interception. + * + * @param reason Automatic trigger selected by `_checkCompaction()` + * @param willRetry Whether to continue the interrupted turn after overflow compaction + * @returns Whether the post-run loop should call `agent.continue()` */ private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); @@ -2122,7 +2148,7 @@ export class AgentSession { usage = extensionCompaction.usage; details = extensionCompaction.details; } else { - // Generate compaction result + // Shared default summary generator, also used by manual compaction. const compactResult = await compact( preparation, requestModel, From 58302d34e703e0453ea13bdd10c7e423589ce177 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 07:34:52 +0200 Subject: [PATCH 175/284] feat(coding-agent): support compaction routing sessions --- .../coding-agent/src/core/agent-session.ts | 2 ++ .../src/core/compaction/compaction.ts | 30 +++++++++++++++---- .../test/compaction-summary-reasoning.test.ts | 14 +++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 77d8b876436..f0dae74f14a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1873,6 +1873,7 @@ export class AgentSession { env, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }), + undefined, // sessionId ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -2161,6 +2162,7 @@ export class AgentSession { env, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), + undefined, // sessionId ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 3f9b90ca424..ee121052e96 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -544,8 +544,9 @@ function createSummarizationOptions( env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, + sessionId: string | undefined, ): SimpleStreamOptions { - const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env }; + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } @@ -567,11 +568,12 @@ export async function completeSummarization( retry?: RetryPolicy, callbacks?: RetryCallbacks, ): Promise { - // Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused. + // Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available; + // callers without a session ID, including branch summaries, receive a fresh routing ID. const requestOptions: SimpleStreamOptions = { ...options, cacheRetention: "none", - sessionId: uuidv7(), + sessionId: options.sessionId ?? uuidv7(), }; const produce = async (): Promise => streamFn @@ -598,6 +600,7 @@ export async function generateSummary( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise { return ( await generateSummaryWithUsage( @@ -614,6 +617,7 @@ export async function generateSummary( env, retry, callbacks, + sessionId, ) ).text; } @@ -633,6 +637,7 @@ export async function generateSummaryWithUsage( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), @@ -665,7 +670,16 @@ export async function generateSummaryWithUsage( }, ]; - const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel); + const completionOptions = createSummarizationOptions( + model, + maxTokens, + apiKey, + headers, + env, + signal, + thinkingLevel, + sessionId, + ); const response = await completeSummarization( model, @@ -813,6 +827,7 @@ Be concise. Focus on what's needed to understand the kept suffix.`; * * @param preparation - Pre-calculated preparation from prepareCompaction() * @param customInstructions - Optional custom focus for the summary + * @param sessionId - Optional routing session ID forwarded without enabling prompt caching */ export async function compact( preparation: CompactionPreparation, @@ -826,6 +841,7 @@ export async function compact( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise { const { firstKeptEntryId, @@ -860,6 +876,7 @@ export async function compact( env, retry, callbacks, + sessionId, ); historyText = historyResult.text; historyUsage = historyResult.usage; @@ -876,6 +893,7 @@ export async function compact( streamFn, retry, callbacks, + sessionId, ); // Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; @@ -896,6 +914,7 @@ export async function compact( env, retry, callbacks, + sessionId, ); summary = result.text; summaryUsage = result.usage; @@ -933,6 +952,7 @@ async function generateTurnPrefixSummary( streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), @@ -952,7 +972,7 @@ async function generateTurnPrefixSummary( const response = await completeSummarization( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, - createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId), streamFn, retry, callbacks, diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index e79a55047e6..7259edf9f80 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { type CompactionPreparation, compact, + completeSummarization, generateSummary, generateSummaryWithUsage, } from "../src/core/compaction/index.ts"; @@ -102,6 +103,19 @@ describe("generateSummary reasoning options", () => { expect(sessionIds[0]).not.toBe(sessionIds[1]); }); + it("honors a caller-supplied routing session without prompt caching", async () => { + await completeSummarization( + createModel(false), + { systemPrompt: "Summarize", messages: [] }, + { sessionId: "current-routing-session", cacheRetention: "long" }, + ); + + expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ + sessionId: "current-routing-session", + cacheRetention: "none", + }); + }); + it("does not set reasoning when thinking is off", async () => { await generateSummary( messages, From ca21c1686157bf29696c9252f623fc094447ddf2 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:36:17 +0200 Subject: [PATCH 176/284] fix: single edit input (#8011) * fix: single edit input * changelog go brrrr --- packages/agent/src/harness/tools/edit.ts | 15 ++++++++++++- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/tools/edit.ts | 22 ++++++++++++++++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/harness/tools/edit.ts b/packages/agent/src/harness/tools/edit.ts index c8210ad7018..5473c48b8a0 100644 --- a/packages/agent/src/harness/tools/edit.ts +++ b/packages/agent/src/harness/tools/edit.ts @@ -38,6 +38,13 @@ const editSchema = Type.Object( export type EditToolInput = Static; type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown }; +type SingleEditInput = { oldText: string; newText: string }; + +function isSingleEditInput(value: unknown): value is SingleEditInput { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const edit = value as Record; + return typeof edit.oldText === "string" && typeof edit.newText === "string"; +} export interface EditToolDetails { diff: string; @@ -51,8 +58,14 @@ function prepareEditArguments(input: unknown): EditToolInput { if (typeof args.edits === "string") { try { const parsed: unknown = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; + if (Array.isArray(parsed)) { + args.edits = parsed; + } else if (isSingleEditInput(parsed)) { + args.edits = [parsed]; + } } catch {} + } else if (isSingleEditInput(args.edits)) { + args.edits = [args.edits]; } const legacy = args as LegacyEditToolInput; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 934b09c734f..d35b19c5ab1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,6 +36,7 @@ ### Fixed +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays in both coding-agent and harness edit tools ([#7835](https://github.com/earendil-works/pi/issues/7835)). - Fixed managed-tool downloads delaying TUI startup and hiding diagnostics in fullscreen mode by mounting the TUI first and showing download progress and warnings inside it. - Fixed opening a model selector immediately after startup cancelling and restarting the in-progress model catalog refresh. - Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 45281c254ce..149a2e15767 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -69,6 +69,17 @@ type LegacyEditToolInput = EditToolInput & { newText?: unknown; }; +type SingleEditInput = { oldText: string; newText: string }; + +function isSingleEditInput(value: unknown): value is SingleEditInput { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const edit = value as Record; + return typeof edit.oldText === "string" && typeof edit.newText === "string"; +} + export interface EditToolDetails { /** Display-oriented diff of the changes made */ diff: string; @@ -109,12 +120,19 @@ function prepareEditArguments(input: unknown): EditToolInput { const args = input as Record; - // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array + // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array. + // Others send a single edit object instead of a one-element edits array. if (typeof args.edits === "string") { try { const parsed = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; + if (Array.isArray(parsed)) { + args.edits = parsed; + } else if (isSingleEditInput(parsed)) { + args.edits = [parsed]; + } } catch {} + } else if (isSingleEditInput(args.edits)) { + args.edits = [args.edits]; } const legacy = args as LegacyEditToolInput; From 8c2529daebe0eac5aecb54424b607b4c88d55e15 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:37:37 +0200 Subject: [PATCH 177/284] fix: dont load root mds as skills in settings (#8012) * fix: dont load root mds as skills in settings * changelogololol --------- Co-authored-by: Mario Zechner --- packages/agent/src/harness/skills.ts | 17 +++- packages/agent/test/harness/skills.test.ts | 19 +++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/skills.md | 5 +- packages/coding-agent/src/core/skills.ts | 92 +++++++++++++--------- 5 files changed, 93 insertions(+), 41 deletions(-) diff --git a/packages/agent/src/harness/skills.ts b/packages/agent/src/harness/skills.ts index 0c0b54330d3..44fd828ed7b 100644 --- a/packages/agent/src/harness/skills.ts +++ b/packages/agent/src/harness/skills.ts @@ -43,8 +43,9 @@ export function formatSkillInvocation(skill: Skill, additionalInstructions?: str /** * Load skills from one or more directories. * - * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files as skills, honors ignore files, - * and returns diagnostics for invalid skill files. Missing input directories are skipped. + * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files with skill + * frontmatter, honors ignore files, and returns diagnostics for invalid declared skill files. Missing input + * directories are skipped. */ export async function loadSkills( env: ExecutionEnv, @@ -246,6 +247,11 @@ async function loadSkillFromFile( parentDirName: string, ): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { const diagnostics: SkillDiagnostic[] = []; + const isDeclaredSkill = + filePath + .replace(/[\\/]+$/, "") + .split(/[\\/]/) + .pop() === "SKILL.md"; const rawContent = await env.readTextFile(filePath); if (!rawContent.ok) { diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath }); @@ -254,12 +260,17 @@ async function loadSkillFromFile( const parsed = parseFrontmatter(rawContent.value); if (!parsed.ok) { - diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + if (isDeclaredSkill) { + diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + } return { skill: null, diagnostics }; } const { frontmatter, body } = parsed.value; const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined; + if (!isDeclaredSkill && (!description || description.trim() === "")) { + return { skill: null, diagnostics }; + } for (const error of validateDescription(description)) { diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); diff --git a/packages/agent/test/harness/skills.test.ts b/packages/agent/test/harness/skills.test.ts index bd769ea9168..88c1559480f 100644 --- a/packages/agent/test/harness/skills.test.ts +++ b/packages/agent/test/harness/skills.test.ts @@ -113,4 +113,23 @@ Use this skill. expect(skills.map((skill) => skill.name)).toEqual(["skills"]); expect(skills[0]?.content).toBe("Root content"); }); + + it("ignores root markdown docs that do not declare skills", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await env.createDir("skills/nested-skill", { recursive: true }); + await env.writeFile("skills/README.md", "# Shared skills\n\nDocumentation."); + await env.writeFile("skills/AGENTS.md", "# Agent notes\n\nDocumentation."); + await env.writeFile("skills/CLAUDE.md", "---\ndescription: [invalid\n---\n\nDocumentation."); + await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content"); + await env.writeFile( + "skills/nested-skill/SKILL.md", + "---\nname: nested-skill\ndescription: Nested skill\n---\nNested content", + ); + + const { skills, diagnostics } = await loadSkills(env, "skills"); + + expect(diagnostics).toEqual([]); + expect(skills.map((skill) => skill.name).sort()).toEqual(["nested-skill", "skills"]); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d35b19c5ab1..eb7f6433c48 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,6 +36,7 @@ ### Fixed +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). - Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays in both coding-agent and harness edit tools ([#7835](https://github.com/earendil-works/pi/issues/7835)). - Fixed managed-tool downloads delaying TUI startup and hiding diagnostics in fullscreen mode by mounting the TUI first and showing download progress and warnings inside it. - Fixed opening a model selector immediately after startup cancelling and restarting the in-progress model catalog refresh. diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index d3ffeea8937..198a6c17c99 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -34,9 +34,10 @@ Pi loads skills from: - CLI: `--skill ` (repeatable, additive even with `--no-skills`) Discovery rules: -- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills +- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills when they have valid skill frontmatter with a non-empty `description` - In all skill locations, directories containing `SKILL.md` are discovered recursively - In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored +- Root Markdown files other than `SKILL.md` that do not look like skills are ignored silently Disable discovery with `--no-skills` (explicit `--skill` paths still load). @@ -183,7 +184,7 @@ Pi validates skills against the Agent Skills standard. Most issues produce warni Unknown frontmatter fields are ignored. -**Exception:** Skills with missing description are not loaded. +Declared skills with missing descriptions are not loaded. Malformed `SKILL.md` files and `SKILL.md` files without a description produce warnings and are not loaded. Other Markdown files without valid skill frontmatter are ignored. Name collisions (same name from different locations) warn and keep the first skill found. diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index c104c856454..464129b64b4 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -114,10 +114,10 @@ function validateName(name: string): string[] { /** * Validate description per Agent Skills spec. */ -function validateDescription(description: string | undefined): string[] { +function validateDescription(description: unknown): string[] { const errors: string[] = []; - if (!description || description.trim() === "") { + if (typeof description !== "string" || description.trim() === "") { errors.push("description is required"); } else if (description.length > MAX_DESCRIPTION_LENGTH) { errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); @@ -279,49 +279,69 @@ function loadSkillFromFile( source: string, ): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } { const diagnostics: ResourceDiagnostic[] = []; + const isDeclaredSkill = basename(filePath) === "SKILL.md"; + let rawContent: string; try { - const rawContent = readFileSync(filePath, "utf-8"); - const { frontmatter } = parseFrontmatter(rawContent); - const skillDir = dirname(filePath); - const parentDirName = basename(skillDir); - - // Validate description - const descErrors = validateDescription(frontmatter.description); - for (const error of descErrors) { - diagnostics.push({ type: "warning", message: error, path: filePath }); + rawContent = readFileSync(filePath, "utf-8"); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read skill file"; + diagnostics.push({ type: "warning", message, path: filePath }); + return { skill: null, diagnostics }; + } + + let frontmatter: SkillFrontmatter; + try { + ({ frontmatter } = parseFrontmatter(rawContent)); + } catch (error) { + if (isDeclaredSkill) { + const message = error instanceof Error ? error.message : "failed to parse skill file"; + diagnostics.push({ type: "warning", message, path: filePath }); } + return { skill: null, diagnostics }; + } + + const description = frontmatter.description; + const hasDescription = typeof description === "string" && description.trim() !== ""; + if (!isDeclaredSkill && !hasDescription) { + return { skill: null, diagnostics }; + } - // Use name from frontmatter, or fall back to parent directory name - const name = frontmatter.name || parentDirName; + const skillDir = dirname(filePath); + const parentDirName = basename(skillDir); - // Validate name - const nameErrors = validateName(name); - for (const error of nameErrors) { - diagnostics.push({ type: "warning", message: error, path: filePath }); - } + // Validate description + const descErrors = validateDescription(description); + for (const error of descErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } - // Still load the skill even with warnings (unless description is completely missing) - if (!frontmatter.description || frontmatter.description.trim() === "") { - return { skill: null, diagnostics }; - } + // Use name from frontmatter, or fall back to parent directory name + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; + const name = frontmatterName || parentDirName; - return { - skill: { - name, - description: frontmatter.description, - filePath, - baseDir: skillDir, - sourceInfo: createSkillSourceInfo(filePath, skillDir, source), - disableModelInvocation: frontmatter["disable-model-invocation"] === true, - }, - diagnostics, - }; - } catch (error) { - const message = error instanceof Error ? error.message : "failed to parse skill file"; - diagnostics.push({ type: "warning", message, path: filePath }); + // Validate name + const nameErrors = validateName(name); + for (const error of nameErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + // Still load the skill even with warnings, unless description is missing or empty. + if (!hasDescription) { return { skill: null, diagnostics }; } + + return { + skill: { + name, + description, + filePath, + baseDir: skillDir, + sourceInfo: createSkillSourceInfo(filePath, skillDir, source), + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; } /** From f47faf459f1a88ead70c3033b70931217251b4f3 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:42:51 +0200 Subject: [PATCH 178/284] fix: register flag type mismatch (#8123) --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/extensions/loader.ts | 5 +++++ .../coding-agent/src/core/extensions/types.ts | 16 ++++++++++----- .../test/extensions-runner.test.ts | 20 +++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eb7f6433c48..1aa97263271 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index a16c78530ec..74d38f12cbc 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -295,6 +295,11 @@ function createExtensionAPI( options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, ): void { runtime.assertActive(); + if (options.default !== undefined && typeof options.default !== options.type) { + throw new Error( + `Invalid default for flag "${name}": expected ${options.type}, got ${typeof options.default}`, + ); + } extension.flags.set(name, { name, extensionPath: extension.path, ...options }); if (options.default !== undefined && !runtime.flagValues.has(name)) { runtime.flagValues.set(name, options.default); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index db0ebacaa54..0b8428d0eba 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1271,11 +1271,17 @@ export interface ExtensionAPI { /** Register a CLI flag. */ registerFlag( name: string, - options: { - description?: string; - type: "boolean" | "string"; - default?: boolean | string; - }, + options: + | { + description?: string; + type: "boolean"; + default?: boolean; + } + | { + description?: string; + type: "string"; + default?: string; + }, ): void; /** Get the value of a registered CLI flag. */ diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index f58a4da6279..70f06a3cf64 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -691,6 +691,26 @@ describe("ExtensionRunner", () => { expect(result.runtime.flagValues.get("shared-flag")).toBe(true); }); + it("rejects default values that do not match the flag type", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("safe-mode", { + type: "boolean", + default: "false", + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "bad-flag-default.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.extensions).toHaveLength(0); + expect(result.errors[0]?.error).toContain( + 'Invalid default for flag "safe-mode": expected boolean, got string', + ); + expect(result.runtime.flagValues.has("safe-mode")).toBe(false); + }); + it("can set flag values", async () => { const extCode = ` export default function(pi) { From 86d001d36b898283ef7d63b1df31709e9730c452 Mon Sep 17 00:00:00 2001 From: Poison Date: Mon, 17 Aug 2026 16:41:39 +0800 Subject: [PATCH 179/284] fix(ai): expose low thinking level for DeepSeek V4 Flash on opencode/opencode-go (#8181) DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP (which enables the low effort level) was only applied to deepseek/deepseek-v4-flash, so the same model served through opencode and opencode-go only offered off/high/max. models.dev reports reasoning_options effort values of low/high/max for these providers, and the live opencode-go API accepts reasoning_effort=low (verified against https://opencode.ai/zen/go/v1). Apply the flash map to every deepseek-v4-flash variant on the deepseek, opencode, and opencode-go providers. qwen-token-plan is left unchanged since its gateway support for the low effort level is not verified. --- packages/ai/scripts/generate-models.ts | 3 ++- packages/ai/test/supports-xhigh.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 1f3a49bcbf4..39c1f6da116 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -872,7 +872,8 @@ function applyThinkingLevelMetadata(model: Model): void { model, model.provider === "openrouter" ? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null } - : model.provider === "deepseek" && model.id === "deepseek-v4-flash" + : (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") && + model.id.includes("deepseek-v4-flash") ? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP : DEEPSEEK_V4_THINKING_LEVEL_MAP, ); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 8d97b760712..e2e428a0a9f 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -88,10 +88,10 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "high", "max"]); }); - it("includes only high/max plus off for DeepSeek V4 Flash on opencode-go", () => { + it("includes low/high/max plus off for DeepSeek V4 Flash on opencode-go", () => { const model = getModel("opencode-go", "deepseek-v4-flash"); expect(model).toBeDefined(); - expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high", "max"]); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "high", "max"]); }); it("includes only high plus off for OpenCode Go Kimi K2.6", () => { From c7c763f5c48736fa00cdcf0bcbfcae5cbc585e7c Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 11:03:07 +0200 Subject: [PATCH 180/284] fix(coding-agent): clarify truncated recovery failure closes #8130 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 9 ++++-- .../suite/agent-session-compaction.test.ts | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1aa97263271..5f433be9ad7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). +- Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f0dae74f14a..8e7a59e8979 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2008,8 +2008,9 @@ export class AgentSession { // Automatic cases 1 and 2: context overflow. // A length stop is recoverable when output ended below the model's original desired limit, // independent of the configured context size or any context-clamped provider request limit. + const contextOverflow = sameModel && isContextOverflow(assistantMessage, contextWindow); const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0); - if (sameModel && (isContextOverflow(assistantMessage, contextWindow) || recoverableLength)) { + if (contextOverflow || recoverableLength) { const willRetry = assistantMessage.stopReason !== "stop"; // Case 2: the response completed successfully. Compact, but do not retry because @@ -2019,14 +2020,16 @@ export class AgentSession { } if (this._overflowRecoveryAttempted) { + const errorMessage = contextOverflow + ? "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model." + : "Truncated response recovery failed after one compact-and-retry attempt."; this._emit({ type: "compaction_end", reason: "overflow", result: undefined, aborted: false, willRetry: false, - errorMessage: - "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", + errorMessage, }); return false; } diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index dbb512f3723..6a21f7f9fd4 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -353,6 +353,34 @@ describe("AgentSession compaction characterization", () => { expect(harness.faux.state.callCount).toBe(2); expect(harness.eventsOfType("compaction_start").filter((event) => event.reason === "overflow")).toHaveLength(1); expect(harness.eventsOfType("compaction_end").at(-1)?.errorMessage).toBe( + "Truncated response recovery failed after one compact-and-retry attempt.", + ); + }); + + it("keeps overflow wording when a repeated length stop fills the context window", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100, maxTokens: 100 }], + }); + harnesses.push(harness); + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const lengthOverflowMessage = createAssistant(harness, { + stopReason: "length", + totalTokens: 100, + timestamp: Date.now(), + }); + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + const compactionErrors: string[] = []; + harness.session.subscribe((event) => { + if (event.type === "compaction_end" && event.errorMessage) { + compactionErrors.push(event.errorMessage); + } + }); + + await sessionInternals._checkCompaction(lengthOverflowMessage); + await sessionInternals._checkCompaction({ ...lengthOverflowMessage, timestamp: Date.now() + 1 }); + + expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1); + expect(compactionErrors).toContain( "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", ); }); From 955a543b31a5672ecae52f7919cf5d0a87959656 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:40:11 +0200 Subject: [PATCH 181/284] fix: expose sleeping llama.cpp models (#8235) --- .../coding-agent/src/extensions/llama/provider.ts | 9 +++++++-- .../coding-agent/test/llama-extension.test.ts | 15 +++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/extensions/llama/provider.ts b/packages/coding-agent/src/extensions/llama/provider.ts index 518007cb464..83fa72ddc1a 100644 --- a/packages/coding-agent/src/extensions/llama/provider.ts +++ b/packages/coding-agent/src/extensions/llama/provider.ts @@ -25,6 +25,11 @@ async function resolveServerUrl( return configured ? normalizeLlamaServerUrl(configured) : undefined; } +function modelIsSelectable(model: LlamaModelInfo): boolean { + // llama.cpp reports idle-slept models as "sleeping"; requests wake them automatically. + return model.status.value === "loaded" || model.status.value === "sleeping"; +} + function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-completions"> { const reportedContextWindow = model.meta?.n_ctx ?? model.meta?.n_ctx_train; const contextWindow = reportedContextWindow && reportedContextWindow > 0 ? reportedContextWindow : 128000; @@ -59,7 +64,7 @@ export function createLlamaProvider(): LlamaProviderController { let models: readonly Model<"openai-completions">[] = []; const setCatalog = (catalog: readonly LlamaModelInfo[], serverUrl: string): void => { - models = catalog.filter((model) => model.status.value === "loaded").map((model) => toPiModel(model, serverUrl)); + models = catalog.filter((model) => modelIsSelectable(model)).map((model) => toPiModel(model, serverUrl)); }; const provider: Provider<"openai-completions"> = { @@ -133,7 +138,7 @@ export function createLlamaProvider(): LlamaProviderController { const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal }); if (context.signal.aborted) return; const refreshed = catalog - .filter((model) => model.status.value === "loaded") + .filter((model) => modelIsSelectable(model)) .map((model) => toPiModel(model, serverUrl)); await context.publish({ persist: { models: refreshed, checkedAt: Date.now() }, diff --git a/packages/coding-agent/test/llama-extension.test.ts b/packages/coding-agent/test/llama-extension.test.ts index a89a0940e4d..7d46c76c9ab 100644 --- a/packages/coding-agent/test/llama-extension.test.ts +++ b/packages/coding-agent/test/llama-extension.test.ts @@ -59,7 +59,7 @@ describe("llama.cpp extension", () => { expect(() => normalizeLlamaServerUrl("file:///tmp/llama")).toThrow("http or https"); }); - it("exposes only loaded models with router metadata", () => { + it("exposes loaded and sleeping models with router metadata", () => { const controller = createLlamaProvider(); controller.setCatalog( [ @@ -69,6 +69,7 @@ describe("llama.cpp extension", () => { architecture: { input_modalities: ["text", "image"] }, meta: { n_ctx: 65536, n_ctx_train: 131072 }, }, + { id: "sleeping", status: { value: "sleeping" } }, { id: "unloaded", status: { value: "unloaded" } }, { id: "loading", status: { value: "loading" } }, ], @@ -83,16 +84,21 @@ describe("llama.cpp extension", () => { maxTokens: 65536, input: ["text", "image"], }), + expect.objectContaining({ + id: "sleeping", + baseUrl: "http://localhost:8080/v1", + }), ]); }); - it("persists and restores loaded models for cache-only startup refreshes", async () => { + it("persists and restores selectable models for cache-only startup refreshes", async () => { let cachedEntry: ModelsStoreEntry | undefined; const { url } = await listen((request, response) => { if (request.url === "/models") { json(response, { data: [ { id: "loaded", status: { value: "loaded" }, meta: { n_ctx: 32768 } }, + { id: "sleeping", status: { value: "sleeping" }, meta: { n_ctx: 32768 } }, { id: "unloaded", status: { value: "unloaded" } }, ], }); @@ -115,8 +121,8 @@ describe("llama.cpp extension", () => { allowNetwork: true, signal: new AbortController().signal, }); - expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded"]); - expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded"]); + expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded", "sleeping"]); + expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded", "sleeping"]); const second = createLlamaProvider(); await second.provider.refreshModels?.({ @@ -128,6 +134,7 @@ describe("llama.cpp extension", () => { }); expect(second.provider.getModels()).toEqual([ expect.objectContaining({ id: "loaded", baseUrl: `${url}/v1`, contextWindow: 32768 }), + expect.objectContaining({ id: "sleeping", baseUrl: `${url}/v1`, contextWindow: 32768 }), ]); }); From 374e56e553daccf730c2c8dcb0b6e161980b43c1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 17 Aug 2026 11:40:05 +0200 Subject: [PATCH 182/284] fix(tui): avoid duplicate VS Code right-click paste closes #8186 --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/tui-alt-screen.ts | 8 +++++++- packages/tui/test/tui-alt-screen.test.ts | 11 ++++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5f433be9ad7..d8866f53939 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). +- Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). ## [0.84.2] - 2026-08-14 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ae7c200284c..6c0416351b7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). + ## [0.84.2] - 2026-08-14 ### Added diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 783a92ab8f0..855fa0f710c 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -695,7 +695,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private handleRightClickPaste(event: SgrMouseEvent): boolean { - if (!this.onRightClickPaste || process.platform !== "win32" || event.release || event.button !== 2) { + if ( + !this.onRightClickPaste || + process.platform !== "win32" || + process.env.TERM_PROGRAM?.toLowerCase() === "vscode" || + event.release || + event.button !== 2 + ) { return false; } try { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 2166debe774..a681cfd8c80 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -225,8 +225,9 @@ describe("TuiAltScreen", () => { } }); - it("invokes the right-click paste handler only on Windows", () => { + it("invokes the right-click paste handler only on Windows outside VS Code", () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const termProgram = process.env.TERM_PROGRAM; assert.ok(platformDescriptor); const terminal = new VirtualTerminal(); let pasteCount = 0; @@ -237,17 +238,25 @@ describe("TuiAltScreen", () => { }); try { Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + delete process.env.TERM_PROGRAM; tui.start(); terminal.sendInput("\x1b[<2;1;1M"); terminal.sendInput("\x1b[<2;1;1m"); assert.strictEqual(pasteCount, 1); + process.env.TERM_PROGRAM = "vscode"; + terminal.sendInput("\x1b[<2;1;1M"); + assert.strictEqual(pasteCount, 1); + Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + delete process.env.TERM_PROGRAM; terminal.sendInput("\x1b[<2;1;1M"); assert.strictEqual(pasteCount, 1); } finally { tui.stop(); Object.defineProperty(process, "platform", platformDescriptor); + if (termProgram === undefined) delete process.env.TERM_PROGRAM; + else process.env.TERM_PROGRAM = termProgram; } }); From a1bc0ec79010887210cc7de28714d72c78577dab Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:15:56 +0200 Subject: [PATCH 183/284] fix: llama.cpp guidance as no default (#8236) --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/llama-cpp.md | 2 ++ .../src/modes/interactive/interactive-mode.ts | 11 ++++++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d8866f53939..c497caf2e39 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). +- Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/docs/llama-cpp.md b/packages/coding-agent/docs/llama-cpp.md index 13f314c9e06..bffa13f423a 100644 --- a/packages/coding-agent/docs/llama-cpp.md +++ b/packages/coding-agent/docs/llama-cpp.md @@ -53,6 +53,8 @@ Start Pi and configure the provider: Enter the router URL and optional API key. The default URL is `http://127.0.0.1:8080`. +If you start the router with `--no-models-autoload`, `/login llama.cpp` only stores the connection. Run `/llama` to load a model, then `/model` to select the loaded model for the current session. + Environment variables can configure the same values without `/login`: ```bash diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 6d5e6b884fc..999220398b6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -257,6 +257,12 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof return providerId in defaultModelPerProvider; } +function llamaCppPostLoginGuidance(actionLabel: string, loadedModelCount: number): string { + return loadedModelCount === 0 + ? `${actionLabel}. No llama.cpp models are loaded. Use /llama to load a model, then /model to select it.` + : `${actionLabel}. Use /model to select a loaded llama.cpp model, or /llama to manage models.`; +} + type LoginProviderCompletionOption = { id: string; name: string; @@ -5471,7 +5477,10 @@ export class InteractiveMode { if (isUnknownModel(previousModel)) { const availableModels = this.session.modelRuntime.getAvailableSnapshot(); const providerModels = availableModels.filter((model) => model.provider === providerId); - if (!hasDefaultModelProvider(providerId)) { + // Matches LLAMA_PROVIDER_ID from extensions/llama/provider.ts; kept inline to avoid coupling interactive mode to the built-in extension. + if (providerId === "llama.cpp") { + selectionError = llamaCppPostLoginGuidance(actionLabel, providerModels.length); + } else if (!hasDefaultModelProvider(providerId)) { selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`; } else if (providerModels.length === 0) { selectionError = `${actionLabel}, but no models are available for that provider. Use /model to select a model.`; From d3e3bbc0117751bdb7ff284feaac8082dbe9551c Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:36:17 +0200 Subject: [PATCH 184/284] fix: llama.cpp allow network for model discovery (#8238) --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/extensions/llama/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c497caf2e39..0fd0c73131a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). diff --git a/packages/coding-agent/src/extensions/llama/index.ts b/packages/coding-agent/src/extensions/llama/index.ts index 0cf72141f00..1e3b9ce916f 100644 --- a/packages/coding-agent/src/extensions/llama/index.ts +++ b/packages/coding-agent/src/extensions/llama/index.ts @@ -53,6 +53,8 @@ export default function llamaExtension(pi: ExtensionAPI): void { provider.setCatalog(current, client.serverUrl); const result = await ctx.modelRegistry.refresh({ providers: [LLAMA_PROVIDER_ID], + // /llama already contacted the configured llama.cpp server, so keep this refresh live even in PI_OFFLINE. + allowNetwork: true, signal, }); if (result.aborted) throw new Error("Model catalog refresh timed out."); From 080932e53cb6f82076b111efc424845e4d5c1902 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:44:38 +0200 Subject: [PATCH 185/284] fix(package-manager): use semver.gt for version comparison (#8239) --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/package-manager.ts | 6 ++-- .../coding-agent/test/package-manager.test.ts | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0fd0c73131a..e59f80e56c4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index e221d2e7e3f..c2e9e73d538 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -27,7 +27,7 @@ import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; -import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; +import { gt, maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; @@ -1129,7 +1129,7 @@ export class DefaultPackageManager implements PackageManager { try { const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); - return targetVersion !== installedVersion; + return gt(targetVersion, installedVersion); } catch { // Preserve existing update behavior when version lookup fails. return true; @@ -1463,7 +1463,7 @@ export class DefaultPackageManager implements PackageManager { try { const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); - return targetVersion !== installedVersion; + return gt(targetVersion, installedVersion); } catch { return false; } diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 31c89faffce..86303aab9d4 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -2244,6 +2244,25 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(runCommandSpy).not.toHaveBeenCalled(); }); + it("should skip npm updates when the installed version is newer than the registry version", async () => { + const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "2.0.0" })); + settingsManager.setProjectPackages(["npm:example"]); + + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.9.0"'); + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + await packageManager.update("npm:example"); + + expect(runCommandCaptureSpy).toHaveBeenCalledWith( + "npm", + ["view", "example", "version", "--json"], + expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), + ); + expect(runCommandSpy).not.toHaveBeenCalled(); + }); + it("should migrate legacy user npm installs into the managed npm root during update", async () => { const legacyRoot = join(tempDir, "legacy-global", "node_modules"); const legacyPath = join(legacyRoot, "legacy-pkg"); @@ -2504,6 +2523,18 @@ export default function(api) { api.registerTool({ name: "test", description: "te ]); }); + it("should not report npm updates when the installed version is newer than the registry version", async () => { + const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "2.0.0" })); + settingsManager.setProjectPackages(["npm:example"]); + + vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.9.0"'); + + const updates = await packageManager.checkForAvailableUpdates(); + expect(updates).toEqual([]); + }); + it("should skip pinned packages when checking for updates", async () => { const installedNpmPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedNpmPath, { recursive: true }); From df018b6020181d4245575fba006361ab69a1408b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 17 Aug 2026 12:55:28 +0200 Subject: [PATCH 186/284] fix(coding-agent): retry hung model catalog requests closes #8198 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/remote-catalog-provider.ts | 19 +++++++---- .../coding-agent/src/utils/management-http.ts | 32 ++++++++++++------- .../coding-agent/test/management-http.test.ts | 21 ++++++++++++ 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e59f80e56c4..9440de45e08 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). - Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). +- Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index c79b7e3bbd6..a12c80ba4a1 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -4,6 +4,7 @@ import { fetchWithRetry } from "../utils/management-http.ts"; import { getPiUserAgent } from "../utils/pi-user-agent.ts"; const DEFAULT_CATALOG_BASE_URL = "https://pi.dev"; +const REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS = 4_000; export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000; function mergeModels(baseline: readonly Model[], dynamic: readonly Model[]): Model[] { @@ -78,14 +79,18 @@ export function withRemoteCatalog( // leave the overlay empty. const validator = stored?.models.length ? stored.etag : undefined; const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl); - const response = await fetchWithRetry(url, { - headers: { - accept: "application/json", - "User-Agent": getPiUserAgent(VERSION), - ...(validator ? { "if-none-match": validator } : {}), + const response = await fetchWithRetry( + url, + { + headers: { + accept: "application/json", + "User-Agent": getPiUserAgent(VERSION), + ...(validator ? { "if-none-match": validator } : {}), + }, + signal: context.signal, }, - signal: context.signal, - }); + { attemptTimeoutMs: REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS }, + ); if (context.signal.aborted) return; const checkedAt = Date.now(); // Unchanged: dynamicModels already holds the stored overlay, so only the diff --git a/packages/coding-agent/src/utils/management-http.ts b/packages/coding-agent/src/utils/management-http.ts index bbaab939412..9a7da8144d4 100644 --- a/packages/coding-agent/src/utils/management-http.ts +++ b/packages/coding-agent/src/utils/management-http.ts @@ -7,8 +7,10 @@ export interface FetchRetryOptions { maxRetries?: number; /** Retry transient HTTP responses as well as transport failures. Defaults to true. */ retryOnStatus?: boolean; - /** Per-attempt timeout. A new timeout is created for every attempt. */ + /** Overall time budget shared by all attempts. */ timeoutMs?: number; + /** Per-attempt timeout. A new timeout is created for every attempt. */ + attemptTimeoutMs?: number; } /** @@ -19,8 +21,8 @@ export interface FetchRetryOptions { * agent/model operations: those can fail after the HTTP request starts and are * retried by their semantic caller instead. * - * Caller cancellation is terminal. When timeoutMs is supplied, it is the - * overall time budget shared by all attempts. + * Caller cancellation and timeoutMs are terminal. attemptTimeoutMs aborts + * only the current attempt so a hung connection can be retried. */ export async function fetchWithRetry( input: FetchInput, @@ -32,17 +34,20 @@ export async function fetchWithRetry( ? 2 : Math.max(0, Math.floor(options.maxRetries)); const retryOnStatus = options.retryOnStatus ?? true; - const parentSignal = init?.signal; + const parentSignal = init?.signal ?? undefined; const timeoutSignal = options.timeoutMs !== undefined && options.timeoutMs > 0 ? AbortSignal.timeout(options.timeoutMs) : undefined; - const signal = timeoutSignal - ? parentSignal - ? AbortSignal.any([parentSignal, timeoutSignal]) - : timeoutSignal - : parentSignal; + const attemptTimeoutMs = + options.attemptTimeoutMs !== undefined && options.attemptTimeoutMs > 0 ? options.attemptTimeoutMs : undefined; for (let attempt = 0; ; attempt++) { - signal?.throwIfAborted(); + parentSignal?.throwIfAborted(); + timeoutSignal?.throwIfAborted(); + const attemptTimeoutSignal = attemptTimeoutMs ? AbortSignal.timeout(attemptTimeoutMs) : undefined; + const signals = [parentSignal, timeoutSignal, attemptTimeoutSignal].filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0]; try { const response = await fetch(input, signal ? { ...init, signal } : init); @@ -55,10 +60,15 @@ export async function fetchWithRetry( // do if cancelling its body also fails. } } catch (error) { + const attemptTimedOut = + attemptTimeoutSignal?.aborted === true && !parentSignal?.aborted && !timeoutSignal?.aborted; if ( parentSignal?.aborted || timeoutSignal?.aborted || - (error instanceof Error && error.name === "AbortError" && timeoutSignal === undefined) || + (error instanceof Error && + error.name === "AbortError" && + !attemptTimedOut && + timeoutSignal === undefined) || attempt >= maxRetries ) { throw error; diff --git a/packages/coding-agent/test/management-http.test.ts b/packages/coding-agent/test/management-http.test.ts index b615bb2dc74..cb0809cd4a0 100644 --- a/packages/coding-agent/test/management-http.test.ts +++ b/packages/coding-agent/test/management-http.test.ts @@ -30,6 +30,27 @@ describe("fetchWithRetry", () => { expect(signals[0]).toBe(signals[1]); }); + it("retries an attempt timeout", async () => { + const controllers: AbortController[] = []; + vi.spyOn(AbortSignal, "timeout").mockImplementation(() => { + const controller = new AbortController(); + controllers.push(controller); + return controller.signal; + }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { + if (fetchMock.mock.calls.length === 1) { + controllers[0].abort(); + init?.signal?.throwIfAborted(); + } + return Response.json({ ok: true }); + }); + + await fetchWithRetry("https://example.test", undefined, { attemptTimeoutMs: 4000 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(controllers).toHaveLength(2); + }); + it("retries transient HTTP responses and returns the successful response", async () => { const fetchMock = vi .spyOn(globalThis, "fetch") From a6b1dbceb1af5bf8a21da0b437ef756ce9fe85e6 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:10:08 +0200 Subject: [PATCH 187/284] fix(extensions): emit compaction failed for extensions (#8241) * fix(extensions): emit compaction failed * changelog yeet --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/compaction.md | 15 +++++ packages/coding-agent/docs/extensions.md | 13 ++++- .../coding-agent/src/core/agent-session.ts | 56 ++++++++++++++++--- .../coding-agent/src/core/extensions/index.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 19 ++++++- .../suite/agent-session-compaction.test.ts | 44 +++++++++++++++ 7 files changed, 139 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9440de45e08..7f5ce78b4ac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 9595ca54310..3a9dff1c63c 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -346,6 +346,21 @@ pi.on("session_before_compact", async (event, ctx) => { See [custom-compaction.ts](../examples/extensions/custom-compaction.ts) for a complete example using a different model. +### session_compact_failed + +Fired when manual or automatic compaction fails or is aborted. This is useful for telemetry extensions that need to pair `session_before_compact` attempts with terminal outcomes. + +```typescript +pi.on("session_compact_failed", async (event, ctx) => { + const { reason, errorMessage, aborted, willRetry, fromExtension } = event; + // reason - "manual" (/compact), "threshold", or "overflow" + // errorMessage - present for non-abort failures + // aborted - true for cancelled/aborted compactions + // willRetry - whether the aborted turn would have retried after compaction + // fromExtension - whether extension-provided compaction content was being used +}); +``` + ### session_before_tree Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 9ec65f89bb0..2e069acdf40 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -330,7 +330,8 @@ user sends another prompt ◄───────────────── /compact or auto-compaction ├─► session_before_compact (can cancel or customize) - └─► session_compact + ├─► session_compact (success) + └─► session_compact_failed (failure or abort) /tree navigation ├─► session_before_tree (can cancel or customize) @@ -448,7 +449,7 @@ pi.on("session_before_fork", async (event, ctx) => { After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. -#### session_before_compact / session_compact +#### session_before_compact / session_compact / session_compact_failed Fired on compaction. See [compaction.md](compaction.md) for details. @@ -479,6 +480,14 @@ pi.on("session_compact", async (event, ctx) => { // event.reason - "manual" (/compact), "threshold", or "overflow" // event.willRetry - whether the aborted turn is retried after compaction (overflow recovery) }); + +pi.on("session_compact_failed", async (event, ctx) => { + // event.reason - "manual" (/compact), "threshold", or "overflow" + // event.errorMessage - present for non-abort failures + // event.aborted - true for cancelled/aborted compactions + // event.willRetry - whether the aborted turn would have retried after compaction + // event.fromExtension - whether extension-provided compaction content was being used +}); ``` #### session_before_tree / session_tree diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8e7a59e8979..5914a37125f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -81,6 +81,7 @@ import { type ReplacedSessionContext, type SessionBeforeCompactResult, type SessionBeforeTreeResult, + type SessionCompactFailedEvent, type SessionStartEvent, type ShutdownHandler, type ToolDefinition, @@ -574,6 +575,12 @@ export class AgentSession { }); } + private async _emitSessionCompactFailed(event: Omit): Promise { + if (this._extensionRunner.hasHandlers("session_compact_failed")) { + await this._extensionRunner.emit({ type: "session_compact_failed", ...event }); + } + } + private _getIdleWaitPromise(): Promise { if (!this._idleWaitPromise) { this._idleWaitPromise = new Promise((resolve) => { @@ -1801,6 +1808,7 @@ export class AgentSession { await this.abort(); this._compactionAbortController = new AbortController(); this._emit({ type: "compaction_start", reason: "manual" }); + let fromExtension = false; try { if (!this.model) { @@ -1823,7 +1831,6 @@ export class AgentSession { } let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; if (this._extensionRunner.hasHandlers("session_before_compact")) { const result = (await this._extensionRunner.emit({ @@ -1928,6 +1935,7 @@ export class AgentSession { } catch (error) { const message = error instanceof Error ? error.message : String(error); const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); + const errorMessage = aborted ? undefined : `Compaction failed: ${message}`; this._compactionAbortController = undefined; this._emit({ type: "compaction_end", @@ -1935,7 +1943,14 @@ export class AgentSession { result: undefined, aborted, willRetry: false, - errorMessage: aborted ? undefined : `Compaction failed: ${message}`, + errorMessage, + }); + await this._emitSessionCompactFailed({ + reason: "manual", + errorMessage, + aborted, + willRetry: false, + fromExtension, }); throw error; } finally { @@ -2031,6 +2046,13 @@ export class AgentSession { willRetry: false, errorMessage, }); + await this._emitSessionCompactFailed({ + reason: "overflow", + errorMessage, + aborted: false, + willRetry: false, + fromExtension: false, + }); return false; } @@ -2088,6 +2110,7 @@ export class AgentSession { private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); let started = false; + let fromExtension = false; try { if (!this.model) { @@ -2108,7 +2131,6 @@ export class AgentSession { started = true; let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; if (this._extensionRunner.hasHandlers("session_before_compact")) { const extensionResult = (await this._extensionRunner.emit({ @@ -2129,6 +2151,12 @@ export class AgentSession { aborted: true, willRetry: false, }); + await this._emitSessionCompactFailed({ + reason, + aborted: true, + willRetry: false, + fromExtension: false, + }); return false; } @@ -2182,6 +2210,12 @@ export class AgentSession { aborted: true, willRetry: false, }); + await this._emitSessionCompactFailed({ + reason, + aborted: true, + willRetry: false, + fromExtension, + }); return false; } @@ -2235,16 +2269,24 @@ export class AgentSession { } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; if (started) { + const formattedErrorMessage = + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`; this._emit({ type: "compaction_end", reason, result: undefined, aborted: false, willRetry: false, - errorMessage: - reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, + errorMessage: formattedErrorMessage, + }); + await this._emitSessionCompactFailed({ + reason, + errorMessage: formattedErrorMessage, + aborted: false, + willRetry: false, + fromExtension, }); } return false; diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index f8841aa8eb0..4ef89cff983 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -134,6 +134,7 @@ export type { SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, + SessionCompactFailedEvent, SessionEvent, SessionInfoChangedEvent, SessionShutdownEvent, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 0b8428d0eba..9008d621240 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -601,7 +601,7 @@ export interface SessionBeforeCompactEvent { signal: AbortSignal; } -/** Fired after context compaction */ +/** Fired after context compaction succeeds */ export interface SessionCompactEvent { type: "session_compact"; compactionEntry: CompactionEntry; @@ -612,6 +612,21 @@ export interface SessionCompactEvent { willRetry: boolean; } +/** Fired after context compaction fails or is aborted */ +export interface SessionCompactFailedEvent { + type: "session_compact_failed"; + /** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */ + reason: "manual" | "threshold" | "overflow"; + /** Error text when compaction failed for a non-abort reason. */ + errorMessage?: string; + /** True when compaction was cancelled or aborted. */ + aborted: boolean; + /** True when the aborted turn would have been retried after this compaction (overflow recovery) */ + willRetry: boolean; + /** True when the failing compaction content came from a session_before_compact handler. */ + fromExtension: boolean; +} + /** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */ export interface SessionShutdownEvent { type: "session_shutdown"; @@ -658,6 +673,7 @@ export type SessionEvent = | SessionBeforeForkEvent | SessionBeforeCompactEvent | SessionCompactEvent + | SessionCompactFailedEvent | SessionShutdownEvent | SessionBeforeTreeEvent | SessionTreeEvent; @@ -1214,6 +1230,7 @@ export interface ExtensionAPI { handler: ExtensionHandler, ): void; on(event: "session_compact", handler: ExtensionHandler): void; + on(event: "session_compact_failed", handler: ExtensionHandler): void; on(event: "session_shutdown", handler: ExtensionHandler): void; on(event: "session_before_tree", handler: ExtensionHandler): void; on(event: "session_tree", handler: ExtensionHandler): void; diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 6a21f7f9fd4..5e9e7bcc8de 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -278,6 +278,50 @@ describe("AgentSession compaction characterization", () => { expect(getStreamCallCount()).toBe(1); }); + it("notifies extensions when auto-compaction fails", async () => { + const failedEvents: Array<{ + reason: "manual" | "threshold" | "overflow"; + errorMessage?: string; + aborted: boolean; + willRetry: boolean; + fromExtension: boolean; + }> = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("session_compact_failed", async (event) => { + failedEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + seedCompactableSession(harness); + harness.session.agent.streamFunction = () => { + throw new Error("summary generator blew up"); + }; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + + await expect(sessionInternals._runAutoCompaction("threshold", false)).resolves.toBe(false); + + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "threshold", + aborted: false, + willRetry: false, + errorMessage: "Auto-compaction failed: summary generator blew up", + }); + expect(failedEvents).toEqual([ + expect.objectContaining({ + type: "session_compact_failed", + reason: "threshold", + aborted: false, + willRetry: false, + fromExtension: false, + errorMessage: "Auto-compaction failed: summary generator blew up", + }), + ]); + }); + it("compacts and resumes after a length stop below the desired output limit", async () => { const harness = await createHarness({ models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }], From 1d08508ef6e4951b74190db17814ee73a6a33edc Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:10:23 +0200 Subject: [PATCH 188/284] fix(extension-examples): use agent_settled instead of end (#8242) --- .../coding-agent/examples/extensions/border-status-editor.ts | 2 +- packages/coding-agent/examples/extensions/git-checkpoint.ts | 4 ++-- packages/coding-agent/examples/extensions/notify.ts | 4 +++- packages/coding-agent/examples/extensions/titlebar-spinner.ts | 2 +- packages/coding-agent/examples/rpc-extension-ui.ts | 2 +- packages/coding-agent/examples/sdk/06-extensions.ts | 2 +- packages/coding-agent/examples/sdk/README.md | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/examples/extensions/border-status-editor.ts b/packages/coding-agent/examples/extensions/border-status-editor.ts index dce9e59596a..8be7f94571e 100644 --- a/packages/coding-agent/examples/extensions/border-status-editor.ts +++ b/packages/coding-agent/examples/extensions/border-status-editor.ts @@ -92,7 +92,7 @@ export default function (pi: ExtensionAPI) { activeTui?.requestRender(); }); - pi.on("agent_end", () => { + pi.on("agent_settled", () => { isWorking = false; stopSpinner(); activeTui?.requestRender(); diff --git a/packages/coding-agent/examples/extensions/git-checkpoint.ts b/packages/coding-agent/examples/extensions/git-checkpoint.ts index 7ee5e6af886..6f26cff0a8a 100644 --- a/packages/coding-agent/examples/extensions/git-checkpoint.ts +++ b/packages/coding-agent/examples/extensions/git-checkpoint.ts @@ -46,8 +46,8 @@ export default function (pi: ExtensionAPI) { } }); - pi.on("agent_end", async () => { - // Clear checkpoints after agent completes + pi.on("agent_settled", async () => { + // Clear checkpoints after the full agent run completes checkpoints.clear(); }); } diff --git a/packages/coding-agent/examples/extensions/notify.ts b/packages/coding-agent/examples/extensions/notify.ts index f7b1f819adb..9e91400f2af 100644 --- a/packages/coding-agent/examples/extensions/notify.ts +++ b/packages/coding-agent/examples/extensions/notify.ts @@ -49,7 +49,9 @@ function notify(title: string, body: string): void { } export default function (pi: ExtensionAPI) { - pi.on("agent_end", async () => { + // `agent_end` fires after each low-level run; Pi may still retry, compact, + // or continue with queued follow-ups. Notify only after the full run settles. + pi.on("agent_settled", async () => { notify("Pi", "Ready for input"); }); } diff --git a/packages/coding-agent/examples/extensions/titlebar-spinner.ts b/packages/coding-agent/examples/extensions/titlebar-spinner.ts index 51467acb6db..530471d584a 100644 --- a/packages/coding-agent/examples/extensions/titlebar-spinner.ts +++ b/packages/coding-agent/examples/extensions/titlebar-spinner.ts @@ -48,7 +48,7 @@ export default function (pi: ExtensionAPI) { startAnimation(ctx); }); - pi.on("agent_end", async (_event, ctx) => { + pi.on("agent_settled", async (_event, ctx) => { stopAnimation(ctx); }); diff --git a/packages/coding-agent/examples/rpc-extension-ui.ts b/packages/coding-agent/examples/rpc-extension-ui.ts index ba055d6ae27..996199d1138 100644 --- a/packages/coding-agent/examples/rpc-extension-ui.ts +++ b/packages/coding-agent/examples/rpc-extension-ui.ts @@ -576,7 +576,7 @@ async function main() { return; } - if (data.type === "agent_end") { + if (data.type === "agent_settled") { isStreaming = false; hideLoading(); outputLog.append(""); diff --git a/packages/coding-agent/examples/sdk/06-extensions.ts b/packages/coding-agent/examples/sdk/06-extensions.ts index 6a8e6a11a08..1efdb140bb8 100644 --- a/packages/coding-agent/examples/sdk/06-extensions.ts +++ b/packages/coding-agent/examples/sdk/06-extensions.ts @@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) { }); pi.on("agent_end", async (event) => { - console.log(\`[Extension] Done, \${event.messages.length} messages\`); + console.log(\`[Extension] Low-level run ended, \${event.messages.length} messages\`); }); // Register a custom tool diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index f4e4dbcda94..467cc1df72d 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -132,7 +132,7 @@ session.subscribe((event) => { case "tool_execution_end": console.log(`Result: ${event.result}`); break; - case "agent_end": + case "agent_settled": console.log("Done"); break; } From af2c352238cffd12d404d5a4cd35a21f93a78fe0 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 13:10:28 +0200 Subject: [PATCH 189/284] fix(ai): honor Google thinking level maps closes #8135 --- packages/ai/CHANGELOG.md | 5 + packages/ai/src/api/google-generative-ai.ts | 39 ++-- packages/ai/src/api/google-shared.ts | 37 +++- packages/ai/src/api/google-vertex.ts | 34 ++-- packages/ai/src/index.ts | 2 +- .../ai/test/google-thinking-level-map.test.ts | 170 ++++++++++++++++++ 6 files changed, 247 insertions(+), 40 deletions(-) create mode 100644 packages/ai/test/google-thinking-level-map.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index bfe0c60cf4f..8a69b0a2939 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,9 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Renamed `GoogleThinkingLevel` to `GoogleApiThinkingLevel` and added `ResolvedGoogleThinkingLevel` for normalized adapter levels. + ### Fixed - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). +- Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). ## [0.84.2] - 2026-08-14 diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index b0c5a09ed03..b5e5be57d88 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -17,20 +17,20 @@ import type { TextContent, ThinkingBudgets, ThinkingContent, - ThinkingLevel, ToolCall, } from "../types.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import type { GoogleThinkingLevel } from "./google-shared.ts"; +import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; import { convertMessages, convertTools, isThinkingPart, mapStopReason, resolveGoogleFunctionCallingMode, + resolveGoogleThinkingLevel, retainThoughtSignature, retryGoogleRequest, supportsGoogleStrictToolSampling, @@ -42,7 +42,7 @@ export interface GoogleOptions extends StreamOptions { thinking?: { enabled: boolean; budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; + level?: GoogleApiThinkingLevel; }; } @@ -309,7 +309,7 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp } const clampedReasoning = clampThinkingLevel(model, options.reasoning); - const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning); const googleModel = model as Model<"google-generative-ai">; if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel) || isGemma4Model(googleModel)) { @@ -317,7 +317,7 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp ...base, thinking: { enabled: true, - level: getThinkingLevel(effort, googleModel), + level: getThinkingLevel(resolvedLevel, googleModel), }, } satisfies GoogleOptions); } @@ -326,7 +326,7 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp ...base, thinking: { enabled: true, - budgetTokens: getGoogleBudget(googleModel, effort, options.thinkingBudgets), + budgetTokens: getGoogleBudget(googleModel, resolvedLevel, options.thinkingBudgets), }, } satisfies GoogleOptions); }; @@ -386,7 +386,7 @@ function buildParams( if (options.thinking?.enabled && model.reasoning) { const thinkingConfig: ThinkingConfig = { includeThoughts: true }; if (options.thinking.level !== undefined) { - // Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values + // Cast to any since our GoogleApiThinkingLevel mirrors Google's ThinkingLevel enum values thinkingConfig.thinkingLevel = options.thinking.level as any; } else if (options.thinking.budgetTokens !== undefined) { thinkingConfig.thinkingBudget = options.thinking.budgetTokens; @@ -412,8 +412,6 @@ function buildParams( return params; } -type ClampedThinkingLevel = Exclude; - function isGemma4Model(model: Model<"google-generative-ai">): boolean { return /gemma-?4/.test(model.id.toLowerCase()); } @@ -445,7 +443,10 @@ function getDisabledThinkingConfig(model: Model<"google-generative-ai">): Thinki return { thinkingBudget: 0 }; } -function getThinkingLevel(effort: ClampedThinkingLevel, model: Model<"google-generative-ai">): GoogleThinkingLevel { +function getThinkingLevel( + effort: ResolvedGoogleThinkingLevel, + model: Model<"google-generative-ai">, +): GoogleApiThinkingLevel { if (isGemini3ProModel(model)) { switch (effort) { case "minimal": @@ -480,41 +481,41 @@ function getThinkingLevel(effort: ClampedThinkingLevel, model: Model<"google-gen function getGoogleBudget( model: Model<"google-generative-ai">, - effort: ClampedThinkingLevel, + level: ResolvedGoogleThinkingLevel, customBudgets?: ThinkingBudgets, ): number { - if (customBudgets?.[effort] !== undefined) { - return customBudgets[effort]!; + if (customBudgets?.[level] !== undefined) { + return customBudgets[level]!; } if (model.id.includes("2.5-pro")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 32768, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash-lite")) { - const budgets: Record = { + const budgets: Record = { minimal: 512, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } return -1; diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index fa81624004c..a49c6689300 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -3,7 +3,17 @@ */ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; -import type { Context, ImageContent, Model, StopReason, StreamOptions, TextContent, Tool } from "../types.ts"; +import type { + Context, + ImageContent, + Model, + ModelThinkingLevel, + StopReason, + StreamOptions, + TextContent, + ThinkingLevel, + Tool, +} from "../types.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; @@ -15,7 +25,30 @@ type GoogleApiType = "google-generative-ai" | "google-vertex"; * Thinking level for Gemini 3 models. * Mirrors Google's ThinkingLevel enum values. */ -export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +export type GoogleApiThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +export type ResolvedGoogleThinkingLevel = Exclude; + +/** Resolve a supported pi level or model-specific Google mapping to a standard Google level. */ +export function resolveGoogleThinkingLevel( + model: Model, + level: ModelThinkingLevel, +): ResolvedGoogleThinkingLevel { + if (level === "off") return "high"; + + const mapped = model.thinkingLevelMap?.[level]; + const resolvedLevel = typeof mapped === "string" ? mapped.toLowerCase() : level; + switch (resolvedLevel) { + case "minimal": + case "low": + case "medium": + case "high": + return resolvedLevel; + default: + throw new Error( + `Unsupported Google thinking level mapping for ${model.provider}/${model.id}: ${level} -> ${String(mapped)}`, + ); + } +} /** * Determines whether a streamed Gemini `Part` should be treated as "thinking". diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 17ea6c03c2b..06f272fec2f 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -13,7 +13,6 @@ import type { AssistantMessage, Context, Model, - ThinkingLevel as PiThinkingLevel, ProviderEnv, ProviderHeaders, SimpleStreamOptions, @@ -29,13 +28,14 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import type { GoogleThinkingLevel } from "./google-shared.ts"; +import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; import { convertMessages, convertTools, isThinkingPart, mapStopReason, resolveGoogleFunctionCallingMode, + resolveGoogleThinkingLevel, retainThoughtSignature, retryGoogleRequest, supportsGoogleStrictToolSampling, @@ -47,7 +47,7 @@ export interface GoogleVertexOptions extends StreamOptions { thinking?: { enabled: boolean; budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; + level?: GoogleApiThinkingLevel; }; project?: string; location?: string; @@ -56,7 +56,7 @@ export interface GoogleVertexOptions extends StreamOptions { const API_VERSION = "v1"; const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; -const THINKING_LEVEL_MAP: Record = { +const THINKING_LEVEL_MAP: Record = { THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED, MINIMAL: ThinkingLevel.MINIMAL, LOW: ThinkingLevel.LOW, @@ -324,7 +324,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> } const clampedReasoning = clampThinkingLevel(model, options.reasoning); - const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning); const geminiModel = model as unknown as Model<"google-generative-ai">; if (isGemini3ProModel(geminiModel) || isGemini3FlashModel(geminiModel)) { @@ -332,7 +332,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> ...base, thinking: { enabled: true, - level: getGemini3ThinkingLevel(effort, geminiModel), + level: getGemini3ThinkingLevel(resolvedLevel, geminiModel), }, } satisfies GoogleVertexOptions); } @@ -341,7 +341,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> ...base, thinking: { enabled: true, - budgetTokens: getGoogleBudget(geminiModel, effort, options.thinkingBudgets), + budgetTokens: getGoogleBudget(geminiModel, resolvedLevel, options.thinkingBudgets), }, } satisfies GoogleVertexOptions); }; @@ -510,8 +510,6 @@ function buildParams( return params; } -type ClampedThinkingLevel = Exclude; - function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); } @@ -538,9 +536,9 @@ function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfi } function getGemini3ThinkingLevel( - effort: ClampedThinkingLevel, + effort: ResolvedGoogleThinkingLevel, model: Model<"google-generative-ai">, -): GoogleThinkingLevel { +): GoogleApiThinkingLevel { if (isGemini3ProModel(model)) { switch (effort) { case "minimal": @@ -565,31 +563,31 @@ function getGemini3ThinkingLevel( function getGoogleBudget( model: Model<"google-generative-ai">, - effort: ClampedThinkingLevel, + level: ResolvedGoogleThinkingLevel, customBudgets?: ThinkingBudgets, ): number { - if (customBudgets?.[effort] !== undefined) { - return customBudgets[effort]!; + if (customBudgets?.[level] !== undefined) { + return customBudgets[level]!; } if (model.id.includes("2.5-pro")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 32768, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } return -1; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 4ff678102d5..82bac973852 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -10,7 +10,7 @@ export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from export type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts"; export type { BedrockOptions, BedrockThinkingDisplay } from "./api/bedrock-converse-stream.ts"; export type { GoogleOptions } from "./api/google-generative-ai.ts"; -export type { GoogleThinkingLevel } from "./api/google-shared.ts"; +export type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./api/google-shared.ts"; export type { GoogleVertexOptions } from "./api/google-vertex.ts"; export * from "./api/lazy.ts"; export type { MistralOptions } from "./api/mistral-conversations.ts"; diff --git a/packages/ai/test/google-thinking-level-map.test.ts b/packages/ai/test/google-thinking-level-map.test.ts new file mode 100644 index 00000000000..957c9b14b22 --- /dev/null +++ b/packages/ai/test/google-thinking-level-map.test.ts @@ -0,0 +1,170 @@ +import type { GenerateContentParameters } from "@google/genai"; +import { describe, expect, it } from "vitest"; +import { streamSimple as streamSimpleGoogle } from "../src/api/google-generative-ai.ts"; +import { resolveGoogleThinkingLevel } from "../src/api/google-shared.ts"; +import { streamSimple as streamSimpleVertex } from "../src/api/google-vertex.ts"; +import type { + Context, + Model, + ModelThinkingLevel, + ThinkingBudgets, + ThinkingLevel, + ThinkingLevelMap, +} from "../src/types.ts"; + +const context: Context = { + messages: [{ role: "user", content: "Hello", timestamp: 0 }], +}; + +function googleModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-generative-ai"> { + return { + id, + name: id, + api: "google-generative-ai", + provider: "test-google", + baseUrl: "https://example.invalid/v1beta", + reasoning: true, + thinkingLevelMap, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; +} + +function vertexModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-vertex"> { + return { + id, + name: id, + api: "google-vertex", + provider: "test-vertex", + baseUrl: "https://example.invalid/v1", + reasoning: true, + thinkingLevelMap, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; +} + +async function captureGooglePayload( + model: Model<"google-generative-ai">, + reasoning: ThinkingLevel, + thinkingBudgets?: ThinkingBudgets, +): Promise { + let payload: GenerateContentParameters | undefined; + const result = await streamSimpleGoogle(model, context, { + apiKey: "test", + reasoning, + thinkingBudgets, + onPayload: (request) => { + payload = request as GenerateContentParameters; + throw new Error("payload captured"); + }, + }).result(); + + expect(result.errorMessage).toContain("payload captured"); + if (!payload) throw new Error("Google payload was not captured"); + return payload; +} + +async function captureVertexPayload( + model: Model<"google-vertex">, + reasoning: ThinkingLevel, + thinkingBudgets?: ThinkingBudgets, +): Promise { + let payload: GenerateContentParameters | undefined; + const result = await streamSimpleVertex(model, context, { + apiKey: "test", + reasoning, + thinkingBudgets, + onPayload: (request) => { + payload = request as GenerateContentParameters; + throw new Error("payload captured"); + }, + }).result(); + + expect(result.errorMessage).toContain("payload captured"); + if (!payload) throw new Error("Vertex payload was not captured"); + return payload; +} + +describe("Google thinking level maps", () => { + it("exhaustively resolves supported logical levels and mapping values", () => { + const defaultExpectations = { + off: "high", + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + } as const satisfies Partial>; + for (const [level, expected] of Object.entries(defaultExpectations)) { + expect(resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), level as ModelThinkingLevel)).toBe( + expected, + ); + } + + const mappedExpectations = { + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + MINIMAL: "minimal", + LOW: "low", + MEDIUM: "medium", + HIGH: "high", + } as const; + for (const [mapped, expected] of Object.entries(mappedExpectations)) { + const model = googleModel("gemini-3.7-flash", { high: mapped, xhigh: mapped, max: mapped }); + expect(resolveGoogleThinkingLevel(model, "high")).toBe(expected); + expect(resolveGoogleThinkingLevel(model, "xhigh")).toBe(expected); + expect(resolveGoogleThinkingLevel(model, "max")).toBe(expected); + } + + const invalidModel = googleModel("gemini-3.7-flash", { xhigh: "extreme" }); + expect(() => resolveGoogleThinkingLevel(invalidModel, "xhigh")).toThrow( + "Unsupported Google thinking level mapping for test-google/gemini-3.7-flash: xhigh -> extreme", + ); + expect(() => resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), "max")).toThrow( + "Unsupported Google thinking level mapping for test-google/gemini-3.7-flash: max -> undefined", + ); + }); + + it.each(["xhigh", "max"] as const)("maps Google Generative AI %s to a supported level", async (reasoning) => { + const payload = await captureGooglePayload( + googleModel("gemini-3.7-flash", { xhigh: "high", max: "high" }), + reasoning, + ); + + expect(payload).toMatchObject({ config: { thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" } } }); + }); + + it("honors uppercase provider values for standard Google Generative AI levels", async () => { + const payload = await captureGooglePayload(googleModel("gemini-3.7-flash", { high: "LOW" }), "high"); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingLevel: "LOW" } } }); + }); + + it("uses mapped Google Generative AI levels for token budgets", async () => { + const payload = await captureGooglePayload(googleModel("gemini-2.5-flash", { xhigh: "high" }), "xhigh", { + high: 1234, + }); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: 1234 } } }); + }); + + it("maps Google Vertex extended levels", async () => { + const payload = await captureVertexPayload(vertexModel("gemini-3.7-flash", { xhigh: "high" }), "xhigh"); + + expect(payload).toMatchObject({ config: { thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" } } }); + }); + + it("uses mapped Google Vertex levels for token budgets", async () => { + const payload = await captureVertexPayload(vertexModel("gemini-2.5-flash", { max: "high" }), "max", { + high: 4321, + }); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: 4321 } } }); + }); +}); From 10acee6045e9025a22dff7e5220ed0d7538f12aa Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:24:34 +0200 Subject: [PATCH 190/284] fix(ai): bedrock response to include smithy headers (#8243) --- packages/ai/CHANGELOG.md | 1 + .../ai/src/api/bedrock-converse-stream.ts | 46 +++++++++++++- .../ai/test/bedrock-response-headers.test.ts | 63 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/bedrock-response-headers.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8a69b0a2939..c6692a82668 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). - Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 20f50efe861..d8ad43ebcdf 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -23,7 +23,7 @@ import { ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; import { NodeHttpHandler } from "@smithy/node-http-handler"; -import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types"; +import type { BuildMiddleware, DeserializeMiddleware, DocumentType, HttpResponse, MetadataBearer } from "@smithy/types"; import { HttpProxyAgent } from "http-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent"; import { calculateCost } from "../models.ts"; @@ -35,6 +35,7 @@ import type { ImageContent, Model, ProviderEnv, + ProviderResponse, SimpleStreamOptions, StopReason, StreamFunction, @@ -227,6 +228,12 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = try { const supportsStrictMode = model.compat?.supportsStrictMode ?? false; const client = new BedrockRuntimeClient(config); + let observedRawResponse = false; + if (options.onResponse) { + addResponseHeadersMiddleware(client, options.onResponse, model, () => { + observedRawResponse = true; + }); + } const customHeaders = providerHeadersToRecord(options.headers); if (customHeaders) { addCustomHeadersMiddleware(client, customHeaders); @@ -253,7 +260,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = const response = await client.send(command, { abortSignal: options.signal }); responseRequestId = normalizeDiagnosticValue(response.$metadata.requestId); - if (response.$metadata.httpStatusCode !== undefined) { + if (!observedRawResponse && response.$metadata.httpStatusCode !== undefined) { const responseHeaders: Record = {}; if (response.$metadata.requestId) { responseHeaders["x-amzn-requestid"] = response.$metadata.requestId; @@ -458,6 +465,41 @@ function addCustomHeadersMiddleware(client: BedrockRuntimeClient, headers: Recor client.middlewareStack.add(middleware, { step: "build", name: "pi-ai-custom-headers", priority: "low" }); } +function isSmithyHttpResponse(response: unknown): response is HttpResponse { + if (!response || typeof response !== "object") return false; + const candidate = response as Partial; + return typeof candidate.statusCode === "number" && !!candidate.headers && typeof candidate.headers === "object"; +} + +function toProviderResponse(response: unknown): ProviderResponse | undefined { + if (!isSmithyHttpResponse(response)) return undefined; + return { status: response.statusCode, headers: { ...response.headers } }; +} + +/** + * Bedrock's modeled `$metadata` only preserves selected HTTP metadata (for example + * requestId), so custom gateway headers are otherwise lost before callers see + * `onResponse`. Capture the raw Smithy HTTP response at the deserialize step, + * after the SDK receives the response but before the event stream is consumed. + */ +function addResponseHeadersMiddleware( + client: BedrockRuntimeClient, + onResponse: NonNullable, + model: Model<"bedrock-converse-stream">, + onObserved: () => void, +): void { + const middleware: DeserializeMiddleware = (next) => async (args) => { + const result = await next(args); + const providerResponse = toProviderResponse(result.response); + if (providerResponse) { + onObserved(); + await onResponse(providerResponse, model); + } + return result; + }; + client.middlewareStack.add(middleware, { step: "deserialize", name: "pi-ai-response-headers" }); +} + export const streamSimple: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, diff --git a/packages/ai/test/bedrock-response-headers.test.ts b/packages/ai/test/bedrock-response-headers.test.ts new file mode 100644 index 00000000000..82d264882f3 --- /dev/null +++ b/packages/ai/test/bedrock-response-headers.test.ts @@ -0,0 +1,63 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; +import { getModel } from "../src/compat.ts"; +import type { Model, ProviderResponse } from "../src/types.ts"; + +const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"; + +let server: Server | undefined; + +afterEach(async () => { + if (!server) return; + await new Promise((resolve, reject) => { + server?.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; +}); + +async function startBedrockResponseServer(): Promise { + server = createServer((_req, res) => { + res.writeHead(200, { + "content-type": "application/vnd.amazon.eventstream", + "x-bifrost-provider": "bedrock", + "x-bifrost-resolved-model": MODEL_ID, + "x-amzn-requestid": "req-123", + }); + res.end(); + }); + + await new Promise((resolve) => server?.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test server address"); + return `http://127.0.0.1:${address.port}`; +} + +describe("bedrock response headers", () => { + it("forwards raw Smithy response headers to onResponse", async () => { + const baseModel = getModel("amazon-bedrock", MODEL_ID) as Model<"bedrock-converse-stream">; + const model = { ...baseModel, baseUrl: await startBedrockResponseServer() }; + const responses: ProviderResponse[] = []; + + const result = await streamBedrock( + model, + { messages: [{ role: "user", content: "hello", timestamp: Date.now() }] }, + { + cacheRetention: "none", + env: { AWS_BEDROCK_FORCE_HTTP1: "1", AWS_BEDROCK_SKIP_AUTH: "1" }, + onResponse: (response) => { + responses.push(response); + }, + }, + ).result(); + + // The fake server intentionally returns an empty event stream; this assertion + // documents that the header callback still fires before stream consumption. + expect(result.stopReason).toBe("error"); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].headers["x-amzn-requestid"]).toBe("req-123"); + expect(responses[0].headers["x-bifrost-provider"]).toBe("bedrock"); + expect(responses[0].headers["x-bifrost-resolved-model"]).toBe(MODEL_ID); + }); +}); From 0e4d49541477c4fc6e404f845ad40ed47d157f24 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 14:43:48 +0200 Subject: [PATCH 191/284] fix(ai,coding-agent): remove deprecated Xiaomi models closes #8187 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 1 + packages/ai/test/xiaomi-models.test.ts | 23 ++++++++++++----------- packages/coding-agent/CHANGELOG.md | 1 + 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c6692a82668..5962c9b62a3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). - Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). +- Fixed Xiaomi model catalog generation retaining shut-down MiMo V2 model names after models.dev marked them deprecated ([#8187](https://github.com/earendil-works/pi/issues/8187)). ## [0.84.2] - 2026-08-14 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 39c1f6da116..1d8e8c52311 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2159,6 +2159,7 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(providerModels)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + if (m.status === "deprecated") continue; models.push({ id: modelId, diff --git a/packages/ai/test/xiaomi-models.test.ts b/packages/ai/test/xiaomi-models.test.ts index d39cf8718cd..992a15c4310 100644 --- a/packages/ai/test/xiaomi-models.test.ts +++ b/packages/ai/test/xiaomi-models.test.ts @@ -1,17 +1,18 @@ import { describe, expect, it } from "vitest"; -import { getModel, getModels } from "../src/compat.ts"; +import { getModels } from "../src/compat.ts"; + +const XIAOMI_PROVIDERS = ["xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const; +const DEPRECATED_MODEL_IDS = ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2-pro"] as const; +const REPLACEMENT_MODEL_IDS = ["mimo-v2.5", "mimo-v2.5-pro"] as const; describe("Xiaomi MiMo models", () => { - it.each(["mimo-v2-flash", "mimo-v2-omni"] as const)("keeps %s on the API billing provider", (modelId) => { - expect(getModel("xiaomi", modelId)).toBeDefined(); + it.each(XIAOMI_PROVIDERS)("omits deprecated models from %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const modelId of DEPRECATED_MODEL_IDS) expect(modelIds).not.toContain(modelId); }); - it.each(["xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const)( - "omits API-billing-only models from %s", - (provider) => { - const modelIds = getModels(provider).map((model) => model.id); - expect(modelIds).not.toContain("mimo-v2-flash"); - expect(modelIds).not.toContain("mimo-v2-omni"); - }, - ); + it.each(XIAOMI_PROVIDERS)("keeps replacement models on %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const modelId of REPLACEMENT_MODEL_IDS) expect(modelIds).toContain(modelId); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f5ce78b4ac..4983f6ae8df 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). - Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). - Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). +- Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). ## [0.84.2] - 2026-08-14 From 87205484bf749c2140fef5d1bea68995d57e739c Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 17 Aug 2026 16:22:49 +0200 Subject: [PATCH 192/284] fix(ai): use Chinese ZAI Coding Plan catalog Generate zai-coding-cn models from the matching zhipuai-coding-plan catalog and use available ZAI PAYG prices for usage estimates. Fixes #8220 --- packages/ai/CHANGELOG.md | 4 + packages/ai/scripts/generate-models.ts | 77 ++++++++++--------- .../ai/test/zai-coding-plan-models.test.ts | 53 +++++++++++++ 3 files changed, 99 insertions(+), 35 deletions(-) create mode 100644 packages/ai/test/zai-coding-plan-models.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5962c9b62a3..a1dc7770cf5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,10 @@ - Renamed `GoogleThinkingLevel` to `GoogleApiThinkingLevel` and added `ResolvedGoogleThinkingLevel` for normalized adapter levels. +### Added + +- Added China-specific ZAI Coding Plan models, including GLM-4.6V vision support, and API-equivalent usage cost estimates for models with published PAYG prices ([#8220](https://github.com/earendil-works/pi/issues/8220)). + ### Fixed - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 1d8e8c52311..3f765d1324a 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1655,45 +1655,52 @@ async function loadModelsDevData(): Promise[]> { // Process zAi models const zaiCodingPlanVariants = [ - { provider: "zai", baseUrl: "https://api.z.ai/api/coding/paas/v4" }, - { provider: "zai-coding-cn", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4" }, + { + source: "zai-coding-plan", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + }, + { + source: "zhipuai-coding-plan", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + }, ] as const; - if (data["zai-coding-plan"]?.models) { - for (const { provider, baseUrl } of zaiCodingPlanVariants) { - for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) { - const m = model as ModelsDevModel; - if (m.tool_call !== true) continue; - const supportsImage = m.modalities?.input?.includes("image"); + for (const { source, provider, baseUrl } of zaiCodingPlanVariants) { + for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + const supportsImage = m.modalities?.input?.includes("image"); - const isGlm52 = modelId === "glm-5.2"; + const isGlm52 = modelId === "glm-5.2"; + const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost; - models.push({ - id: modelId, - name: m.name || modelId, - api: "openai-completions", - provider, - baseUrl, - reasoning: m.reasoning === true, - ...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}), - input: supportsImage ? ["text", "image"] : ["text"], - cost: { - input: m.cost?.input || 0, - output: m.cost?.output || 0, - cacheRead: m.cost?.cache_read || 0, - cacheWrite: m.cost?.cache_write || 0, - }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - ...(isGlm52 ? { supportsReasoningEffort: true } : {}), - ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), - }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, - }); - recordModelsDevReasoningOptions(provider, modelId, m); - } + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider, + baseUrl, + reasoning: m.reasoning === true, + ...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}), + input: supportsImage ? ["text", "image"] : ["text"], + cost: { + input: referenceCost?.input || 0, + output: referenceCost?.output || 0, + cacheRead: referenceCost?.cache_read || 0, + cacheWrite: referenceCost?.cache_write || 0, + }, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + ...(isGlm52 ? { supportsReasoningEffort: true } : {}), + ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + recordModelsDevReasoningOptions(provider, modelId, m); } } diff --git a/packages/ai/test/zai-coding-plan-models.test.ts b/packages/ai/test/zai-coding-plan-models.test.ts new file mode 100644 index 00000000000..5ed6658dc65 --- /dev/null +++ b/packages/ai/test/zai-coding-plan-models.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "vitest"; +import { getBuiltinModel } from "../src/providers/all.ts"; + +it("exposes GLM-4.6V on the China Coding Plan catalog", () => { + const model = getBuiltinModel("zai-coding-cn", "glm-4.6v"); + + expect(model).toMatchObject({ + id: "glm-4.6v", + provider: "zai-coding-cn", + api: "openai-completions", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + reasoning: true, + input: ["text", "image"], + cost: { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 32768, + compat: { + maxTokensField: "max_tokens", + thinkingFormat: "zai", + zaiToolStream: true, + }, + }); +}); + +it("uses API-equivalent reference costs for Coding Plan models", () => { + expect(getBuiltinModel("zai", "glm-5.2").cost).toEqual({ + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }); + expect(getBuiltinModel("zai-coding-cn", "glm-5.1").cost).toEqual({ + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }); + expect(getBuiltinModel("zai-coding-cn", "glm-5v-turbo").cost).toEqual({ + input: 1.2, + output: 4, + cacheRead: 0.24, + cacheWrite: 0, + }); +}); + +it("keeps zero costs for Coding Plan models without a matching API price", () => { + const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + + for (const provider of ["zai", "zai-coding-cn"] as const) { + expect(getBuiltinModel(provider, "glm-5.2-highspeed").cost).toEqual(zeroCost); + expect(getBuiltinModel(provider, "glm-5.3").cost).toEqual(zeroCost); + } +}); From 6db110e6fab3a18e0e54a6e1aa5a479c1e282924 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Mon, 17 Aug 2026 16:58:39 +0200 Subject: [PATCH 193/284] fix(ai): add Qwen Token Plan Individual DeepSeek V4 Pro 0813 Add deepseek-v4-pro-0813 to the Qwen Token Plan Individual allowlist and generated catalog. Fixes #8194. --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 1 + packages/ai/test/generate-models-strict.test.ts | 1 + packages/ai/test/qwen-token-plan-models.test.ts | 3 ++- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a1dc7770cf5..8f7cd22e389 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). - Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 3f765d1324a..5edc35070db 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -307,6 +307,7 @@ const QWEN_TOKEN_PLAN_PROVIDER_IDS = new Set([ const QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS = new Set([ "deepseek-v4-flash-0731", "deepseek-v4-pro", + "deepseek-v4-pro-0813", "glm-5.2", "qwen3.6-flash", "qwen3.7-max", diff --git a/packages/ai/test/generate-models-strict.test.ts b/packages/ai/test/generate-models-strict.test.ts index caf75c477d9..30d44a4db40 100644 --- a/packages/ai/test/generate-models-strict.test.ts +++ b/packages/ai/test/generate-models-strict.test.ts @@ -25,6 +25,7 @@ describe("strict model generation", () => { const modelIds = [ "deepseek-v4-flash-0731", "deepseek-v4-pro", + "deepseek-v4-pro-0813", "glm-5.2", "qwen3.6-flash", "qwen3.7-max", diff --git a/packages/ai/test/qwen-token-plan-models.test.ts b/packages/ai/test/qwen-token-plan-models.test.ts index 17705705732..d1dcef09c81 100644 --- a/packages/ai/test/qwen-token-plan-models.test.ts +++ b/packages/ai/test/qwen-token-plan-models.test.ts @@ -60,6 +60,7 @@ const TEXT_MODELS = [ const INDIVIDUAL_TEXT_MODELS = [ "deepseek-v4-flash-0731", "deepseek-v4-pro", + "deepseek-v4-pro-0813", "glm-5.2", "qwen3.6-flash", "qwen3.7-max", @@ -102,7 +103,7 @@ const QWEN_REASONING_EFFORT_MODEL_CASES: QwenTokenPlanModelCase[] = [ ...(["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => QWEN_REASONING_EFFORT_MODELS.map((modelId) => ({ provider, modelId })), ), - ...["deepseek-v4-flash-0731", "deepseek-v4-pro", "glm-5.2"].map((modelId) => ({ + ...["deepseek-v4-flash-0731", "deepseek-v4-pro", "deepseek-v4-pro-0813", "glm-5.2"].map((modelId) => ({ provider: "qwen-token-plan-individual" as const, modelId, })), From 1c28f3032e6a39566c95aa709a0b1cefcf81c5db Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:52:37 +0200 Subject: [PATCH 194/284] fix(ai): update cloudflare gateway sonnet test id (#8260) --- packages/ai/test/stream.test.ts | 4 ++-- packages/coding-agent/src/core/model-resolver.ts | 2 +- packages/coding-agent/test/model-resolver.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index bf68823d296..2fe1604ad43 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -702,9 +702,9 @@ describe("Generate E2E Tests", () => { ); describe.skipIf(!hasCloudflareAiGatewayCredentials() || !process.env.ANTHROPIC_API_KEY)( - "Cloudflare AI Gateway → Anthropic BYOK (claude-sonnet-4-5 via /anthropic messages)", + "Cloudflare AI Gateway → Anthropic BYOK (claude-sonnet-4.5 via /anthropic messages)", () => { - const llm = getModel("cloudflare-ai-gateway", "claude-sonnet-4-5"); + const llm = getModel("cloudflare-ai-gateway", "claude-sonnet-4.5"); const options = { headers: { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` } }; const thinkingOptions = { ...options, diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index aea6717e117..80a84f963ab 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -34,7 +34,7 @@ export const defaultModelPerProvider: Record = { "vercel-ai-gateway": "zai/glm-5.1", xai: "grok-4.6", groq: "openai/gpt-oss-120b", - cerebras: "zai-glm-4.7", + cerebras: "gpt-oss-120b", zai: "glm-5.3", "zai-coding-cn": "glm-5.3", mistral: "devstral-medium-latest", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index c38b59f04a5..6488b7ae8c5 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -705,7 +705,7 @@ describe("default model selection", () => { expect(defaultModelPerProvider["zai-coding-cn"]).toBe("glm-5.3"); expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7"); - expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7"); + expect(defaultModelPerProvider.cerebras).toBe("gpt-oss-120b"); expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T"); }); From 5e11f65865865cbfae662acd69d90e701bf082dd Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:59:55 +0200 Subject: [PATCH 195/284] fix(coding-agent): load nested markdown skills (#8255) --- packages/coding-agent/docs/skills.md | 2 +- packages/coding-agent/src/core/package-manager.ts | 7 ++++++- packages/coding-agent/test/package-manager.test.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index 198a6c17c99..8905b7ff9a3 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -36,7 +36,7 @@ Pi loads skills from: Discovery rules: - In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills when they have valid skill frontmatter with a non-empty `description` - In all skill locations, directories containing `SKILL.md` are discovered recursively -- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored +- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored, but nested `.md` files in grouping folders are discovered when they declare skill frontmatter - Root Markdown files other than `SKILL.md` that do not look like skills are ignored silently Disable discovery with `--no-skills` (explicit `--skill` paths still load). diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index c2e9e73d538..f0f98359c8f 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -397,7 +397,12 @@ function collectSkillEntries( } const relPath = toPosixPath(relative(root, fullPath)); - if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) { + const shouldIncludeMarkdownFile = + isFile && + entry.name.endsWith(".md") && + !ig.ignores(relPath) && + ((mode === "pi" && dir === root) || (mode === "agents" && dir !== root)); + if (shouldIncludeMarkdownFile) { entries.push(fullPath); continue; } diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 86303aab9d4..2de47fcf05a 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -440,13 +440,19 @@ Content`, expect(result.skills.some((r) => r.path === middleSkill && r.enabled)).toBe(true); }); - it("should ignore root markdown files in .agents/skills", async () => { + it("should ignore root markdown files in .agents/skills but discover nested markdown skills", async () => { const agentsSkillsDir = join(tempDir, ".agents", "skills"); mkdirSync(join(agentsSkillsDir, "nested-skill"), { recursive: true }); + mkdirSync(join(agentsSkillsDir, "third-party"), { recursive: true }); + mkdirSync(join(agentsSkillsDir, "third-party", "vendor", "pack"), { recursive: true }); const rootSkill = join(agentsSkillsDir, "root-file.md"); const nestedSkill = join(agentsSkillsDir, "nested-skill", "SKILL.md"); + const nestedMarkdownSkill = join(agentsSkillsDir, "third-party", "child-skill.md"); + const deeplyNestedMarkdownSkill = join(agentsSkillsDir, "third-party", "vendor", "pack", "deep-skill.md"); writeFileSync(rootSkill, "---\nname: root-file\ndescription: Root markdown file\n---\n"); writeFileSync(nestedSkill, "---\nname: nested-skill\ndescription: Nested skill\n---\n"); + writeFileSync(nestedMarkdownSkill, "---\nname: child-skill\ndescription: Nested markdown skill\n---\n"); + writeFileSync(deeplyNestedMarkdownSkill, "---\nname: deep-skill\ndescription: Deep markdown skill\n---\n"); const pm = new DefaultPackageManager({ cwd: join(tempDir, "work"), @@ -458,6 +464,8 @@ Content`, const result = await pm.resolve(); expect(result.skills.some((r) => r.path === rootSkill)).toBe(false); expect(result.skills.some((r) => r.path === nestedSkill && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path === nestedMarkdownSkill && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path === deeplyNestedMarkdownSkill && r.enabled)).toBe(true); }); it("should keep ~/.agents/skills user-scoped when cwd is under home in a non-git directory", async () => { From eb1f87fa9a29e27e0c63dcb40dbed9a3624c82b1 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:04:49 +0200 Subject: [PATCH 196/284] fix(coding-agent/ai): anthropic refusal error and fallbacks (#8258) --- packages/ai/scripts/generate-models.ts | 19 ++++++++++ packages/ai/src/api/anthropic-messages.ts | 36 ++++++++++++++++--- packages/ai/src/types.ts | 10 ++++++ .../src/core/compaction/compaction.ts | 15 ++++++++ .../test/compaction-summary-reasoning.test.ts | 28 ++++++++++++++- 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 5edc35070db..2b09d9bd03e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -255,6 +255,10 @@ const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([ "github-copilot:claude-sonnet-4", "github-copilot:claude-sonnet-4.5", ]); +const ANTHROPIC_ALLOWED_FALLBACK_MODELS = { + "claude-fable-5": ["claude-opus-4-8", "claude-opus-5"], + "claude-opus-5": ["claude-opus-4-8"], +} satisfies Record; const DEEPSEEK_V4_THINKING_LEVEL_MAP = { minimal: null, @@ -727,6 +731,14 @@ function applyOpenAICompletionsCompatMetadata(model: Model): void { } } +function applyAnthropicMessagesCompatMetadata(model: Model): void { + if (model.api !== "anthropic-messages") return; + const compat = getAnthropicMessagesCompat(model.provider, model.id); + if (compat) { + mergeAnthropicMessagesCompat(model, compat); + } +} + function applyStrictToolCompatMetadata(model: Model): void { if ( (model.provider === "openai" || model.provider === "cloudflare-ai-gateway") && @@ -944,6 +956,12 @@ function getAnthropicMessagesCompat(provider: string, modelId: string): Anthropi if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) { compat.supportsEagerToolInputStreaming = false; } + if (provider === "anthropic") { + const allowedFallbackModels = ANTHROPIC_ALLOWED_FALLBACK_MODELS[modelId]; + if (allowedFallbackModels) { + compat.allowedFallbackModels = allowedFallbackModels; + } + } if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) { compat.allowEmptySignature = true; } @@ -2754,6 +2772,7 @@ async function generateModels() { for (const model of allModels) { applyOpenAICompletionsCompatMetadata(model); + applyAnthropicMessagesCompatMetadata(model); applyModelsDevReasoningOptionMetadata(model); applyThinkingLevelMetadata(model); applyStrictToolCompatMetadata(model); diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index b9586120dd0..cdca6b699b9 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -10,6 +10,7 @@ import type { import { calculateCost } from "../models.ts"; import type { AnthropicMessagesCompat, + AnthropicRefusalFallback, Api, AssistantMessage, CacheRetention, @@ -168,12 +169,17 @@ export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"; export type AnthropicThinkingDisplay = "summarized" | "omitted"; +type MessageCreateParamsStreamingWithFallbacks = MessageCreateParamsStreaming & { + fallbacks?: AnthropicRefusalFallback; +}; + const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; +const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01"; function getAnthropicCompat( model: Model<"anthropic-messages">, -): Required> { +): Required> { return { supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true, supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, @@ -248,6 +254,12 @@ export interface AnthropicOptions extends StreamOptions { * Default: true. */ interleavedThinking?: boolean; + /** + * Anthropic refusal fallback. When set, the request includes the server-side + * fallback beta and Anthropic retries eligible refusals on the configured + * fallback target before returning a final response. + */ + refusalFallbacks?: AnthropicRefusalFallback; /** * Anthropic tool choice behavior. String values map to Anthropic's built-in * choices; `{ type: "tool", name }` forces a specific tool. @@ -553,6 +565,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, context), + options?.refusalFallbacks !== undefined, options?.headers, options?.fetch, copilotDynamicHeaders, @@ -588,6 +601,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( for await (const event of iterateAnthropicEvents(response, options?.signal)) { if (event.type === "message_start") { output.responseId = event.message.id; + output.model = event.message.model; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early output.usage.input = event.message.usage.input_tokens || 0; @@ -822,7 +836,11 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti const base = buildBaseOptions(model, context, options, options?.apiKey); if (!options?.reasoning) { - return stream(model, context, { ...base, thinkingEnabled: false } satisfies AnthropicOptions); + return stream(model, context, { + ...base, + refusalFallbacks: options?.refusalFallbacks, + thinkingEnabled: false, + } satisfies AnthropicOptions); } // For models with adaptive thinking: use an effort level. @@ -831,6 +849,7 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti const effort = mapThinkingLevelToEffort(model, options.reasoning); return stream(model, context, { ...base, + refusalFallbacks: options?.refusalFallbacks, thinkingEnabled: true, effort, } satisfies AnthropicOptions); @@ -849,6 +868,7 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti return stream(model, context, { ...base, + refusalFallbacks: options?.refusalFallbacks, maxTokens, thinkingEnabled: true, thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)), @@ -864,6 +884,7 @@ function createClient( apiKey: string | undefined, interleavedThinking: boolean, useFineGrainedToolStreamingBeta: boolean, + useServerSideFallbackBeta: boolean, optionsHeaders?: ProviderHeaders, fetch?: typeof globalThis.fetch, dynamicHeaders?: Record, @@ -878,6 +899,9 @@ function createClient( if (needsInterleavedBeta) { betaFeatures.push(INTERLEAVED_THINKING_BETA); } + if (useServerSideFallbackBeta) { + betaFeatures.push(SERVER_SIDE_FALLBACK_BETA); + } // Copilot: Bearer auth, selective betas. if (model.provider === "github-copilot") { @@ -959,7 +983,7 @@ function buildParams( context: Context, isOAuthToken: boolean, options?: AnthropicOptions, -): MessageCreateParamsStreaming { +): MessageCreateParamsStreamingWithFallbacks { const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env); const compat = getAnthropicCompat(model); const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); @@ -976,7 +1000,7 @@ function buildParams( deferredTools = []; } const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name))); - const params: MessageCreateParamsStreaming = { + const params: MessageCreateParamsStreamingWithFallbacks = { model: model.id, messages: convertMessages( transformedMessages, @@ -1088,6 +1112,10 @@ function buildParams( } } + if (options?.refusalFallbacks !== undefined) { + params.fallbacks = options.refusalFallbacks; + } + return params; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7e1fd00a378..aa5200896cc 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -300,9 +300,13 @@ export interface ImagesOptions extends ProviderRequestOptions; +export type AnthropicRefusalFallback = "default" | readonly { model: string }[]; + // Unified options with reasoning passed to streamSimple() and completeSimple() export interface SimpleStreamOptions extends StreamOptions { reasoning?: ThinkingLevel; + /** Anthropic server-side fallback for eligible refusal stop reasons. Anthropic providers only. */ + refusalFallbacks?: AnthropicRefusalFallback; /** Ask a capable provider to return a durable handle and continue the request asynchronously. */ deferred?: boolean | { window?: "15m" | "1h" | "24h" }; /** Custom token budgets for thinking levels (token-based providers only) */ @@ -672,6 +676,12 @@ export interface AnthropicMessagesCompat { allowEmptySignature?: boolean; /** Whether the provider supports Anthropic strict tool schemas. Default: false; generated Anthropic models enable it explicitly. */ supportsStrictTools?: boolean; + /** + * Model ids Anthropic accepts in `fallbacks` for server-side refusal fallback. + * When absent or empty, callers must omit `fallbacks`; Anthropic rejects the + * field for models with no permitted fallback targets. + */ + allowedFallbackModels?: string[]; /** * Whether the provider supports deferred tools loaded by `tool_reference` * blocks in tool results. Default: true for first-party Anthropic models diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index ee121052e96..8792fa5502a 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -26,6 +26,17 @@ import { serializeConversation, } from "./utils.ts"; +function getAnthropicSummarizationFallback(model: Model): readonly { model: string }[] | undefined { + if (model.provider !== "anthropic" || model.api !== "anthropic-messages") { + return undefined; + } + + const allowedFallbackModels = (model as Model<"anthropic-messages">).compat?.allowedFallbackModels; + // Use the primary permitted fallback for now. If future Anthropic models expose + // broader fallback behavior, this can become a user/config pick or a full chain. + return allowedFallbackModels && allowedFallbackModels.length > 0 ? [{ model: allowedFallbackModels[0] }] : undefined; +} + // ============================================================================ // File Operation Tracking // ============================================================================ @@ -547,6 +558,10 @@ function createSummarizationOptions( sessionId: string | undefined, ): SimpleStreamOptions { const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; + const refusalFallbacks = getAnthropicSummarizationFallback(model); + if (refusalFallbacks) { + options.refusalFallbacks = refusalFallbacks; + } if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 7259edf9f80..e30260e332b 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -21,7 +21,11 @@ vi.mock("@earendil-works/pi-ai/compat", async (importOriginal) => { }; }); -function createModel(reasoning: boolean, maxTokens = 8192): Model<"anthropic-messages"> { +function createModel( + reasoning: boolean, + maxTokens = 8192, + compat?: Model<"anthropic-messages">["compat"], +): Model<"anthropic-messages"> { return { id: reasoning ? "reasoning-model" : "non-reasoning-model", name: reasoning ? "Reasoning Model" : "Non-reasoning Model", @@ -33,6 +37,7 @@ function createModel(reasoning: boolean, maxTokens = 8192): Model<"anthropic-mes cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens, + ...(compat ? { compat } : {}), }; } @@ -156,6 +161,27 @@ describe("generateSummary reasoning options", () => { expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning"); }); + it("sets Anthropic refusal fallback from model metadata", async () => { + await generateSummary( + messages, + createModel(true, 8192, { allowedFallbackModels: ["claude-opus-4-8", "claude-opus-5"] }), + 2000, + "test-key", + ); + + expect(completeSimpleMock).toHaveBeenCalledTimes(1); + expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ + refusalFallbacks: [{ model: "claude-opus-4-8" }], + }); + }); + + it("does not set Anthropic refusal fallback for models without allowed fallback targets", async () => { + await generateSummary(messages, createModel(true), 2000, "test-key"); + + expect(completeSimpleMock).toHaveBeenCalledTimes(1); + expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("refusalFallbacks"); + }); + it("clamps compaction summary maxTokens to the model output cap", async () => { const preparation: CompactionPreparation = { firstKeptEntryId: "entry-keep", From 54d22b74b3b538836f997ad0903fd44eaac45c93 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 17 Aug 2026 22:21:44 +0200 Subject: [PATCH 197/284] fix(coding-agent): reduce redundant git update tests --- packages/coding-agent/test/git-update.test.ts | 212 +----------------- 1 file changed, 4 insertions(+), 208 deletions(-) diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index 7b2a8f7e4a9..80308277cb3 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -3,7 +3,7 @@ * * These tests verify that DefaultPackageManager.update() handles: * - Normal git updates (no force-push) - * - Force-pushed remotes gracefully (currently fails, fix needed) + * - Force-pushed remotes after a complete history rewrite */ import { spawnSync } from "node:child_process"; @@ -104,12 +104,8 @@ describe("DefaultPackageManager git update", () => { } }); - /** - * Sets up a "remote" repository and clones it to the installed directory. - * This simulates what packageManager.install() would do. - * @param sourceOverride Optional source string to use instead of gitSource (e.g., with @ref for pinned tests) - */ - function setupRemoteAndInstall(sourceOverride?: string): void { + /** Sets up a "remote" repository and clones it to the installed directory. */ + function setupRemoteAndInstall(): void { // Create "remote" repository mkdirSync(remoteDir, { recursive: true }); initGitRepo(remoteDir); @@ -122,7 +118,7 @@ describe("DefaultPackageManager git update", () => { git(["config", "--local", "user.name", "Test"], installedDir); // Add to global packages so update() processes this source - settingsManager.setPackages([sourceOverride ?? gitSource]); + settingsManager.setPackages([gitSource]); } describe("normal updates (no force-push)", () => { @@ -180,99 +176,9 @@ describe("DefaultPackageManager git update", () => { expect(getCurrentCommit(installedDir)).toBe(newCommit); expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); }); - - it("should handle multiple commits ahead", async () => { - setupRemoteAndInstall(); - - // Add multiple commits to remote - createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - createCommit(remoteDir, "extension.ts", "// v3", "Third commit"); - const latestCommit = createCommit(remoteDir, "extension.ts", "// v4", "Fourth commit"); - - await packageManager.update(); - - expect(getCurrentCommit(installedDir)).toBe(latestCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v4"); - }); - - it("should update even when local checkout has no upstream", async () => { - setupRemoteAndInstall(); - createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - const latestCommit = createCommit(remoteDir, "extension.ts", "// v3", "Third commit"); - - const detachedCommit = getCurrentCommit(installedDir); - git(["checkout", detachedCommit], installedDir); - - const executedCommands: string[] = []; - const managerWithInternals = packageManager as unknown as { - runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; - }; - managerWithInternals.runCommand = async (command, args, options) => { - executedCommands.push(`${command} ${args.join(" ")}`); - const result = spawnSync(command, args, { - cwd: options?.cwd, - encoding: "utf-8", - }); - if (result.status !== 0) { - throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`); - } - }; - - await packageManager.update(); - - expect(executedCommands).toContain( - "git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main", - ); - expect(getCurrentCommit(installedDir)).toBe(latestCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v3"); - }); }); describe("force-push scenarios", () => { - it("should recover when remote history is rewritten", async () => { - setupRemoteAndInstall(); - const initialCommit = getCurrentCommit(remoteDir); - - // Add commit to remote - createCommit(remoteDir, "extension.ts", "// v2", "Commit to keep"); - - // Update to get the new commit - await packageManager.update(); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); - - // Now force-push to rewrite history on remote - git(["reset", "--hard", initialCommit], remoteDir); - const rewrittenCommit = createCommit(remoteDir, "extension.ts", "// v2-rewritten", "Rewritten commit"); - - // Update should succeed despite force-push - await packageManager.update(); - - expect(getCurrentCommit(installedDir)).toBe(rewrittenCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v2-rewritten"); - }); - - it("should recover when local commit no longer exists in remote", async () => { - setupRemoteAndInstall(); - - // Add commits to remote - createCommit(remoteDir, "extension.ts", "// v2", "Commit A"); - createCommit(remoteDir, "extension.ts", "// v3", "Commit B"); - - // Update to get all commits - await packageManager.update(); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v3"); - - // Force-push remote to remove commits A and B - git(["reset", "--hard", "HEAD~2"], remoteDir); - const newCommit = createCommit(remoteDir, "extension.ts", "// v2-new", "New commit replacing A and B"); - - // Update should succeed - the commits we had locally no longer exist - await packageManager.update(); - - expect(getCurrentCommit(installedDir)).toBe(newCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v2-new"); - }); - it("should handle complete history rewrite", async () => { setupRemoteAndInstall(); @@ -297,32 +203,6 @@ describe("DefaultPackageManager git update", () => { }); describe("pinned sources", () => { - it("should not move pinned git sources past their configured ref", async () => { - // Create remote repo first to get the initial commit - mkdirSync(remoteDir, { recursive: true }); - initGitRepo(remoteDir); - const initialCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); - - // Install with pinned ref from the start - full clone to ensure commit is available - mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); - git(["clone", remoteDir, installedDir], tempDir); - git(["checkout", initialCommit], installedDir); - git(["config", "--local", "user.email", "test@test.com"], installedDir); - git(["config", "--local", "user.name", "Test"], installedDir); - - // Add to global packages with pinned ref - settingsManager.setPackages([`${gitSource}@${initialCommit}`]); - - // Add new commit to remote - createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - - await packageManager.update(); - - // Should still be on initial commit - expect(getCurrentCommit(installedDir)).toBe(initialCommit); - expect(getFileContent(installedDir, "extension.ts")).toBe("// v1"); - }); - it("should checkout the configured pinned git ref during full and targeted updates", async () => { mkdirSync(remoteDir, { recursive: true }); initGitRepo(remoteDir); @@ -351,42 +231,6 @@ describe("DefaultPackageManager git update", () => { expect(getCurrentCommit(installedDir)).toBe(v2Commit); expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); }); - - it("should not reset an annotated tag checkout that already matches the configured ref", async () => { - mkdirSync(remoteDir, { recursive: true }); - initGitRepo(remoteDir); - const taggedCommit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); - git(["tag", "-a", "v1", "-m", "v1"], remoteDir); - - mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); - git(["clone", remoteDir, installedDir], tempDir); - git(["checkout", "v1"], installedDir); - expect(getCurrentCommit(installedDir)).toBe(taggedCommit); - - settingsManager.setPackages([`${gitSource}@v1`]); - - const executedCommands: string[] = []; - const managerWithInternals = packageManager as unknown as { - runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; - }; - managerWithInternals.runCommand = async (command, args, options) => { - executedCommands.push(`${command} ${args.join(" ")}`); - const result = spawnSync(command, args, { - cwd: options?.cwd, - encoding: "utf-8", - }); - if (result.status !== 0) { - throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`); - } - }; - - await packageManager.update(); - - expect(executedCommands).toContain("git fetch origin v1"); - expect(executedCommands.some((command) => command.startsWith("git reset --hard"))).toBe(false); - expect(executedCommands).not.toContain("git clean -fdx"); - expect(getCurrentCommit(installedDir)).toBe(taggedCommit); - }); }); describe("temporary git sources", () => { @@ -434,53 +278,5 @@ describe("DefaultPackageManager git update", () => { ); expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// fresh"); }); - - it("should not refresh pinned temporary git sources", async () => { - const managerWithPaths = packageManager as unknown as PackageManagerPathInternals; - const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary"); - const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts"); - - rmSync(cachedDir, { recursive: true, force: true }); - mkdirSync(join(cachedDir, "pi-extensions"), { recursive: true }); - writeFileSync( - join(cachedDir, "package.json"), - JSON.stringify({ pi: { extensions: ["./pi-extensions"] } }, null, 2), - ); - writeFileSync(extensionFile, "// pinned"); - - const executedCommands: string[] = []; - const managerWithInternals = packageManager as unknown as { - runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; - }; - managerWithInternals.runCommand = async (command, args) => { - executedCommands.push(`${command} ${args.join(" ")}`); - }; - - await packageManager.resolveExtensionSources([`${gitSource}@main`], { temporary: true }); - - expect(executedCommands).toEqual([]); - expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// pinned"); - }); - }); - - describe("scope-aware update", () => { - it("should not install locally when source is only registered globally", async () => { - setupRemoteAndInstall(); - - // Add a new commit to remote - createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); - - // The project-scope install path should not exist before or after update - const projectGitDir = join(tempDir, ".pi", "git", "github.com", "test", "extension"); - expect(existsSync(projectGitDir)).toBe(false); - - await packageManager.update(gitSource); - - // Global install should be updated - expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); - - // Project-scope directory should NOT have been created - expect(existsSync(projectGitDir)).toBe(false); - }); }); }); From b82a374c7d4a08cdd00a4087b31351f39682a320 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 17 Aug 2026 22:57:18 +0200 Subject: [PATCH 198/284] fix(coding-agent): reduce redundant slow tests --- packages/coding-agent/src/cli/args.ts | 5 + packages/coding-agent/src/main.ts | 8 +- packages/coding-agent/test/args.test.ts | 22 +- .../test/session-id-readonly.test.ts | 219 ++++++++---------- .../test/startup-session-name.test.ts | 13 -- .../test/stdout-cleanliness.test.ts | 30 +-- .../agent-session-tool-result-images.test.ts | 77 +----- packages/coding-agent/test/tools.test.ts | 5 +- 8 files changed, 145 insertions(+), 234 deletions(-) diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 3c0e01b3c0d..bfb0ff6e285 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -63,6 +63,11 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel { return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); } +export function normalizeSessionName(value: string): string | undefined { + const name = value.trim(); + return name.length > 0 ? name : undefined; +} + export function parseArgs(args: string[]): Args { const result: Args = { messages: [], diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c6a66251455..85d9523591c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -8,7 +8,7 @@ import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import chalk from "chalk"; -import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; +import { type Args, type Mode, normalizeSessionName, parseArgs, printHelp } from "./cli/args.ts"; import { type AuthCheckResult, checkProviderAuth, @@ -357,7 +357,7 @@ function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string, } } -async function createSessionManager( +export async function createSessionManager( parsed: Args, cwd: string, sessionDir: string | undefined, @@ -694,8 +694,8 @@ export async function main(args: string[], options?: MainOptions) { } } if (parsed.name !== undefined) { - const name = parsed.name.trim(); - if (!name) { + const name = normalizeSessionName(parsed.name); + if (name === undefined) { console.error(chalk.red("Error: --name requires a non-empty value")); process.exit(1); } diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index a8be4f8eb4d..710ed7b09d0 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseArgs } from "../src/cli/args.ts"; +import { normalizeSessionName, parseArgs } from "../src/cli/args.ts"; describe("parseArgs", () => { describe("--version flag", () => { @@ -173,6 +173,11 @@ describe("parseArgs", () => { expect(result.name).toBe(""); }); + test("normalizes display names and rejects whitespace-only values", () => { + expect(normalizeSessionName(" named session ")).toBe("named session"); + expect(normalizeSessionName(" ")).toBeUndefined(); + }); + test("reports missing value", () => { const result = parseArgs(["--name"]); expect(result.diagnostics).toEqual([{ type: "error", message: "--name requires a value" }]); @@ -192,6 +197,21 @@ describe("parseArgs", () => { const result = parseArgs(["--no-session"]); expect(result.noSession).toBe(true); }); + + test("preserves custom session IDs for non-persisting commands", () => { + expect(parseArgs(["--session-id", "ephemeral-id", "--help"])).toMatchObject({ + sessionId: "ephemeral-id", + help: true, + }); + expect(parseArgs(["--session-id", "ephemeral-id", "--list-models"])).toMatchObject({ + sessionId: "ephemeral-id", + listModels: true, + }); + expect(parseArgs(["--session-id", "ephemeral-id", "--no-session"])).toMatchObject({ + sessionId: "ephemeral-id", + noSession: true, + }); + }); }); describe("--extension flag", () => { diff --git a/packages/coding-agent/test/session-id-readonly.test.ts b/packages/coding-agent/test/session-id-readonly.test.ts index 726a7577f38..5527193ec2e 100644 --- a/packages/coding-agent/test/session-id-readonly.test.ts +++ b/packages/coding-agent/test/session-id-readonly.test.ts @@ -1,23 +1,19 @@ import { spawn } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Args } from "../src/cli/args.ts"; import { ENV_AGENT_DIR } from "../src/config.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { createSessionManager } from "../src/main.ts"; const cliPath = resolve(__dirname, "../src/cli.ts"); const tempDirs: string[] = []; afterEach(() => { + vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -50,141 +46,128 @@ function hasSessionWithId(root: string, sessionId: string): boolean { return false; } -interface CliDirs { - agentDir: string; - projectDir: string; - sessionDir: string; -} - -async function runCli( - args: string[] | ((dirs: CliDirs) => string[]), - setup?: (dirs: CliDirs) => void, -): Promise<{ code: number | null; agentDir: string; stderr: string }> { +async function runCli(args: string[]): Promise<{ code: number | null; agentDir: string }> { const tempRoot = createTempDir(); - const dirs: CliDirs = { - agentDir: join(tempRoot, "agent"), - projectDir: join(tempRoot, "project"), - sessionDir: join(tempRoot, "sessions"), - }; - mkdirSync(dirs.agentDir, { recursive: true }); - mkdirSync(dirs.projectDir, { recursive: true }); - setup?.(dirs); - const resolvedArgs = typeof args === "function" ? args(dirs) : args; + const agentDir = join(tempRoot, "agent"); + const projectDir = join(tempRoot, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); - let stderr = ""; const code = await new Promise((resolvePromise, reject) => { - const child = spawn(process.execPath, [cliPath, ...resolvedArgs], { - cwd: dirs.projectDir, + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: projectDir, env: { ...process.env, - [ENV_AGENT_DIR]: dirs.agentDir, + [ENV_AGENT_DIR]: agentDir, PI_OFFLINE: "1", TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), }, - stdio: ["ignore", "ignore", "pipe"], - }); - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); + stdio: ["ignore", "ignore", "ignore"], }); child.on("error", reject); child.on("close", resolvePromise); }); - return { code, agentDir: dirs.agentDir, stderr }; + return { code, agentDir }; } -function writeSession(sessionDir: string, cwd: string, id: string): void { - writeFileSync( - join(sessionDir, `${id}.jsonl`), - `${JSON.stringify({ type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd })}\n`, - ); +function args(overrides: Partial): Args { + return { + messages: [], + fileArgs: [], + unknownFlags: new Map(), + diagnostics: [], + ...overrides, + }; } -describe("--session-id read-only commands", () => { - it("does not reserve a session for --help", async () => { - const result = await runCli(["--session-id", "read-only-help", "--help"]); - - expect(result.code).toBe(0); - expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-help")).toBe(false); - }); - - it("allows --no-session with --session-id", async () => { - const result = await runCli(["--no-session", "--session-id", "ephemeral-id", "--help"]); - - expect(result.code).toBe(0); - expect(hasSessionWithId(join(result.agentDir, "sessions"), "ephemeral-id")).toBe(false); +function persistSession(session: SessionManager, content: string): void { + session.appendMessage({ role: "user", content, timestamp: Date.now() }); + session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "persisted" }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), }); +} - it("does not reserve a session for --list-models", async () => { - const result = await runCli(["--session-id", "read-only-models", "--list-models"]); +describe("--session-id", () => { + it("does not persist a custom ID for metadata commands", async () => { + const result = await runCli(["--session-id", "read-only-help", "--help"]); expect(result.code).toBe(0); - expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-models")).toBe(false); + expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-help")).toBe(false); }); - it("warns when a missing --session-id creates a new session", async () => { - const result = await runCli((dirs) => [ - "--session-dir", - dirs.sessionDir, - "--session-id", - "missing-session-id", - "--model", - "missing-model", - "-p", - "hi", - ]); - - expect(result.code).toBe(1); - expect(result.stderr).toContain( - "Warning: No project session found with id 'missing-session-id'; creating a new session with that id.", + it("creates missing IDs and reopens existing IDs in process", async () => { + const tempRoot = createTempDir(); + const projectDir = join(tempRoot, "project"); + const sessionDir = join(tempRoot, "sessions"); + mkdirSync(projectDir, { recursive: true }); + const settingsManager = SettingsManager.inMemory(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + const readOnly = await createSessionManager( + args({ sessionId: "read-only", help: true }), + projectDir, + sessionDir, + settingsManager, ); - }); - - it("does not warn when --session-id opens an existing session", async () => { - const result = await runCli( - (dirs) => [ - "--session-dir", - dirs.sessionDir, - "--session-id", - "existing-session-id", - "--model", - "missing-model", - "-p", - "hi", - ], - (dirs) => { - mkdirSync(dirs.sessionDir, { recursive: true }); - writeSession(dirs.sessionDir, dirs.projectDir, "existing-session-id"); - }, + expect(readOnly.getSessionId()).toBe("read-only"); + expect(readOnly.getSessionFile()).toBeUndefined(); + + const created = await createSessionManager( + args({ sessionId: "persisted-id" }), + projectDir, + sessionDir, + settingsManager, ); - - expect(result.code).toBe(1); - expect(result.stderr).not.toContain("No project session found with id 'existing-session-id'"); - }); - - it("rejects an existing fork target session id", async () => { - const result = await runCli( - (dirs) => ["--session-dir", dirs.sessionDir, "--fork", "source-id", "--session-id", "existing-id", "-p", "hi"], - (dirs) => { - mkdirSync(dirs.sessionDir, { recursive: true }); - writeSession(dirs.sessionDir, dirs.projectDir, "source-id"); - writeSession(dirs.sessionDir, dirs.projectDir, "existing-id"); - }, + persistSession(created, "persist me"); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("creating a new session")); + + consoleError.mockClear(); + const reopened = await createSessionManager( + args({ sessionId: "persisted-id" }), + projectDir, + sessionDir, + settingsManager, ); - - expect(result.code).toBe(1); - expect(result.stderr).toContain("Session already exists with id 'existing-id'"); + expect(reopened.getSessionFile()).toBe(created.getSessionFile()); + expect(consoleError).not.toHaveBeenCalled(); }); -}); -describe("--session-id validation", () => { - it("rejects ids invalid under SessionManager rules without stack traces", async () => { - for (const id of ["-bad", "bad id"]) { - const result = await runCli(["--session-id", id, "-p", "hi"]); + it("rejects an existing fork target in process", async () => { + const tempRoot = createTempDir(); + const projectDir = join(tempRoot, "project"); + const sessionDir = join(tempRoot, "sessions"); + mkdirSync(projectDir, { recursive: true }); + const source = SessionManager.create(projectDir, sessionDir, { id: "source-id" }); + persistSession(source, "source"); + const target = SessionManager.create(projectDir, sessionDir, { id: "existing-id" }); + persistSession(target, "target"); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit:${code}`); + }); - expect(result.code).toBe(1); - expect(result.stderr).toContain("Session id must be non-empty"); - expect(result.stderr).not.toContain("SessionManager.create"); - } + await expect( + createSessionManager( + args({ fork: "source-id", sessionId: "existing-id" }), + projectDir, + sessionDir, + SettingsManager.inMemory(), + ), + ).rejects.toThrow("exit:1"); }); }); diff --git a/packages/coding-agent/test/startup-session-name.test.ts b/packages/coding-agent/test/startup-session-name.test.ts index 2d6f94ce553..3571ce66618 100644 --- a/packages/coding-agent/test/startup-session-name.test.ts +++ b/packages/coding-agent/test/startup-session-name.test.ts @@ -119,17 +119,4 @@ describe("startup session name", () => { expect(result.signal).toBeNull(); expect(readSessionInfoNames(dirs.sessionFile)).toEqual(["CLI Named Session"]); }); - - it("rejects empty --name values without appending session metadata", async () => { - const dirs = setup(); - const result = await runCli( - ["--session", dirs.sessionFile, "--name", " ", "--model", "missing-model", "-p", "hi"], - dirs, - ); - - expect(result.code).toBe(1); - expect(result.signal).toBeNull(); - expect(result.stderr).toContain("--name requires a non-empty value"); - expect(readSessionInfoNames(dirs.sessionFile)).toEqual([]); - }); }); diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index 83abde7c959..a5ffa5e68ef 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -85,20 +85,14 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe("stdout cleanliness in non-interactive modes", () => { - it("prints --version to stdout when stdout is redirected", async () => { - const result = await runCli(["--version"]); - - expect(result.code).toBe(0); - expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); - expect(result.stderr).toBe(""); - }); - it("prints plain --help to stdout when stdout is redirected", async () => { const result = await runCli(["--help"]); expect(result.code).toBe(0); expect(result.stdout).toContain("Usage:"); expect(result.stderr).not.toContain("Usage:"); + expect(result.stderr).not.toContain("changed 1 package in 471ms"); + expect(result.stderr).not.toContain("found 0 vulnerabilities"); }); it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => { @@ -110,24 +104,4 @@ describe("stdout cleanliness in non-interactive modes", () => { expect(result.stderr).toContain("found 0 vulnerabilities"); expect(result.stderr).toContain("Usage:"); }); - - it("keeps stdout empty for -p --help while routing trusted startup chatter to stderr", async () => { - const result = await runCli(["-p", "--help", "--approve"]); - - expect(result.code).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("changed 1 package in 471ms"); - expect(result.stderr).toContain("found 0 vulnerabilities"); - expect(result.stderr).toContain("Usage:"); - }); - - it("ignores untrusted project package installs for help", async () => { - const result = await runCli(["-p", "--help"]); - - expect(result.code).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).not.toContain("changed 1 package in 471ms"); - expect(result.stderr).not.toContain("found 0 vulnerabilities"); - expect(result.stderr).toContain("Usage:"); - }); }); diff --git a/packages/coding-agent/test/suite/agent-session-tool-result-images.test.ts b/packages/coding-agent/test/suite/agent-session-tool-result-images.test.ts index 6a37b184130..884ef2f264b 100644 --- a/packages/coding-agent/test/suite/agent-session-tool-result-images.test.ts +++ b/packages/coding-agent/test/suite/agent-session-tool-result-images.test.ts @@ -1,95 +1,40 @@ -import { crc32, deflateSync } from "node:zlib"; import type { AgentTool } from "@earendil-works/pi-agent-core"; -import type { ImageContent } from "@earendil-works/pi-ai"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import { Type } from "typebox"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createHarness, type Harness } from "./harness.ts"; -function pngChunk(type: string, body: Buffer): Buffer { - const header = Buffer.alloc(8); - header.writeUInt32BE(body.length, 0); - header.write(type, 4, "ascii"); - const checksum = Buffer.alloc(4); - checksum.writeUInt32BE(crc32(Buffer.concat([header.subarray(4), body])), 0); - return Buffer.concat([header, body, checksum]); -} +const normalizeToolResultImages = vi.hoisted(() => vi.fn(async (content: unknown[]) => content)); +vi.mock("../../src/utils/tool-result-images.ts", () => ({ normalizeToolResultImages })); -/** Build an 8-bit grayscale PNG of arbitrary dimensions without pulling in an encoder. */ -function createPng(width: number, height: number): Buffer { - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; // bit depth - ihdr[9] = 0; // color type: grayscale - const raw = Buffer.alloc((width + 1) * height); - for (let row = 0; row < height; row++) { - raw.fill(row % 256, row * (width + 1) + 1, (row + 1) * (width + 1)); - } - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - pngChunk("IHDR", ihdr), - pngChunk("IDAT", deflateSync(raw)), - pngChunk("IEND", Buffer.alloc(0)), - ]); -} +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; -function readPngDimensions(base64Data: string): { width: number; height: number } { - const buffer = Buffer.from(base64Data, "base64"); - return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; -} - -const OVERSIZED_PNG_BASE64 = createPng(2400, 4800).toString("base64"); - -/** Stands in for extension, MCP bridge, or screenshot tools that return images they produced. */ const screenshotTool: AgentTool = { name: "screenshot", label: "Screenshot", - description: "Return an oversized screenshot", + description: "Return a screenshot", parameters: Type.Object({}), execute: async () => ({ content: [ { type: "text", text: "captured" }, - { type: "image", data: OVERSIZED_PNG_BASE64, mimeType: "image/png" }, + { type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }, ], details: {}, }), }; -function getToolResultImages(harness: Harness): ImageContent[] { - return harness.session.messages - .filter((message) => message.role === "toolResult") - .flatMap((message) => message.content) - .filter((block): block is ImageContent => block.type === "image"); -} - describe("AgentSession tool result images", () => { const harnesses: Harness[] = []; afterEach(() => { + normalizeToolResultImages.mockClear(); while (harnesses.length > 0) { harnesses.pop()?.cleanup(); } }); - it("resizes oversized tool result images before they enter history", async () => { - const harness = await createHarness({ tools: [screenshotTool] }); - harnesses.push(harness); - harness.setResponses([ - fauxAssistantMessage([fauxToolCall("screenshot", {})], { stopReason: "toolUse" }), - fauxAssistantMessage("done"), - ]); - - await harness.session.prompt("take a screenshot"); - - const images = getToolResultImages(harness); - expect(images).toHaveLength(1); - const { width, height } = readPngDimensions(images[0].data); - expect(width).toBeLessThanOrEqual(2000); - expect(height).toBeLessThanOrEqual(2000); - }); - - it("honors images.autoResize being disabled", async () => { + it("passes images.autoResize to tool result normalization", async () => { const harness = await createHarness({ tools: [screenshotTool], settings: { images: { autoResize: false } }, @@ -102,8 +47,6 @@ describe("AgentSession tool result images", () => { await harness.session.prompt("take a screenshot"); - const images = getToolResultImages(harness); - expect(images).toHaveLength(1); - expect(images[0].data).toBe(OVERSIZED_PNG_BASE64); + expect(normalizeToolResultImages).toHaveBeenCalledWith(expect.any(Array), { autoResizeImages: false }); }); }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 63b7f626064..e9c395aa644 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -486,9 +486,8 @@ describe("Coding Agent Tools", () => { }); it("should respect timeout", async () => { - await expect(bashTool.execute("test-call-10", { command: "sleep 5", timeout: 1 })).rejects.toThrow( - /timed out/i, - ); + const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`; + await expect(bashTool.execute("test-call-10", { command, timeout: 0.05 })).rejects.toThrow(/timed out/i); }); it("should include full output path for truncated timeout and abort errors", async () => { From 209bc7b9a89b01c8fd05861cf5bbdda3e300037a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 17 Aug 2026 23:44:56 +0200 Subject: [PATCH 199/284] fix(ai): remove unused opentelemetry dependency --- package-lock.json | 10 ---------- packages/ai/package.json | 1 - packages/coding-agent/install-lock/package-lock.json | 10 ---------- packages/coding-agent/npm-shrinkwrap.json | 10 ---------- 4 files changed, 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f77aa69a44..7042de0da61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1564,15 +1564,6 @@ "node": ">= 8" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -5441,7 +5432,6 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", - "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", diff --git a/packages/ai/package.json b/packages/ai/package.json index fe1f336d89b..e2f4345a0f6 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -64,7 +64,6 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", - "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 5f43256d30e..1e2f28d28b4 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -474,7 +474,6 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", - "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -783,15 +782,6 @@ } ] }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 2f2c309b350..e58a04fa769 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -501,7 +501,6 @@ "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@earendil-works/pi-telemetry": "^0.84.2", "@google/genai": "1.52.0", - "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -773,15 +772,6 @@ } ] }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", From 9117326b4c9c4657d2ff5e41d229f52c8273e8fc Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 22:19:20 +0200 Subject: [PATCH 200/284] fix(ai): forward Azure Responses tool choice --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/azure-openai-responses.ts | 4 ++ .../ai/test/azure-openai-tool-choice.test.ts | 49 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 packages/ai/test/azure-openai-tool-choice.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8f7cd22e389..e6bb6acf5b7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. - Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 519fc0dcf35..0b9cd45538e 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -55,6 +55,7 @@ function formatAzureOpenAIError(error: unknown): string { // Azure OpenAI Responses-specific options export interface AzureOpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; reasoningSummary?: "auto" | "detailed" | "concise" | null; azureApiVersion?: string; azureResourceName?: string; @@ -303,6 +304,9 @@ function buildParams( supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false, }); } + if (options?.toolChoice !== undefined) { + params.tool_choice = options.toolChoice; + } if (model.reasoning) { if (options?.reasoningEffort || options?.reasoningSummary) { diff --git a/packages/ai/test/azure-openai-tool-choice.test.ts b/packages/ai/test/azure-openai-tool-choice.test.ts new file mode 100644 index 00000000000..65e1d0f7d2a --- /dev/null +++ b/packages/ai/test/azure-openai-tool-choice.test.ts @@ -0,0 +1,49 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { stream } from "../src/api/azure-openai-responses.ts"; +import type { Model } from "../src/types.ts"; + +const model: Model<"azure-openai-responses"> = { + id: "test-deployment", + name: "Test Deployment", + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "http://127.0.0.1:9/openai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 10_000, + maxTokens: 1_000, +}; + +describe("Azure OpenAI tool choice", () => { + it("forwards provider-specific tool choice while preserving tool definitions", async () => { + let payload: unknown; + const result = stream( + model, + { + messages: [{ role: "user", content: "Summarize this", timestamp: 1 }], + tools: [ + { + name: "read", + description: "Read a file", + parameters: Type.Object({ path: Type.String() }), + }, + ], + }, + { + apiKey: "test-key", + toolChoice: "required", + onPayload: (requestPayload) => { + payload = requestPayload; + throw new Error("payload captured"); + }, + }, + ); + + await result.result(); + + expect(payload).toMatchObject({ tool_choice: "required" }); + expect((payload as { tools?: unknown[] }).tools).toHaveLength(1); + }); +}); From ad58801ce793ca4ca2f6fb64b307e9eaffd2c471 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 18 Aug 2026 08:19:46 +0200 Subject: [PATCH 201/284] fix(ai): update Baseten GLM input modalities --- packages/ai/test/baseten-models.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/test/baseten-models.test.ts b/packages/ai/test/baseten-models.test.ts index 60e8cf74573..3157747fc96 100644 --- a/packages/ai/test/baseten-models.test.ts +++ b/packages/ai/test/baseten-models.test.ts @@ -31,7 +31,7 @@ describe("Baseten models", () => { xhigh: null, max: "max", }, - input: ["text"], + input: ["text", "image"], contextWindow: 1048576, maxTokens: 262144, cost: { From e5dde9a76bfec3c4eff764d1b6db3b60e5dd0b30 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 18 Aug 2026 08:41:24 +0200 Subject: [PATCH 202/284] feat(ai): add simple tool choice option --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/anthropic-messages.ts | 5 ++- packages/ai/src/api/azure-openai-responses.ts | 5 ++- .../ai/src/api/bedrock-converse-stream.ts | 5 ++- packages/ai/src/api/google-generative-ai.ts | 5 ++- packages/ai/src/api/google-vertex.ts | 5 ++- packages/ai/src/api/mistral-conversations.ts | 5 ++- packages/ai/src/api/openai-codex-responses.ts | 5 ++- packages/ai/src/api/openai-completions.ts | 7 ++-- packages/ai/src/api/openai-responses.ts | 5 ++- packages/ai/src/api/pi-messages.ts | 2 +- packages/ai/src/types.ts | 3 ++ .../ai/test/azure-openai-tool-choice.test.ts | 32 ++++++++++++++++++- packages/ai/test/pi-messages.test.ts | 4 +-- 14 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e6bb6acf5b7..b4de62a0072 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- Added provider-neutral `toolChoice` support to simple stream requests. - Added China-specific ZAI Coding Plan models, including GLM-4.6V vision support, and API-equivalent usage cost estimates for models with published PAYG prices ([#8220](https://github.com/earendil-works/pi/issues/8220)). ### Fixed diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index cdca6b699b9..f0e89a5583f 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -834,7 +834,10 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti ): AssistantMessageEventStream => { assertRequestAuth(model.provider, options?.apiKey, options?.headers); - const base = buildBaseOptions(model, context, options, options?.apiKey); + const base = { + ...buildBaseOptions(model, context, options, options?.apiKey), + toolChoice: options?.toolChoice, + } satisfies AnthropicOptions; if (!options?.reasoning) { return stream(model, context, { ...base, diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 0b9cd45538e..3f106af87a7 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -169,7 +169,10 @@ export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStream throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, context, options, apiKey); + const base = { + ...buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + } satisfies AzureOpenAIResponsesOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index d8ad43ebcdf..57aab387f44 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -505,7 +505,10 @@ export const streamSimple: StreamFunction<"bedrock-converse-stream", SimpleStrea context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const base = buildBaseOptions(model, context, options, undefined); + const base = { + ...buildBaseOptions(model, context, options, undefined), + toolChoice: options?.toolChoice, + } satisfies BedrockOptions; if (!options?.reasoning) { return stream(model, context, { ...base, reasoning: undefined } satisfies BedrockOptions); } diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index b5e5be57d88..fc534f2b732 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -303,7 +303,10 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, context, options, apiKey); + const base = { + ...buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + } satisfies GoogleOptions; if (!options?.reasoning) { return stream(model, context, { ...base, thinking: { enabled: false } } satisfies GoogleOptions); } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 06f272fec2f..e0b7edc6551 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -315,7 +315,10 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { - const base = buildBaseOptions(model, context, options, undefined); + const base = { + ...buildBaseOptions(model, context, options, undefined), + toolChoice: options?.toolChoice, + } satisfies GoogleVertexOptions; if (!options?.reasoning) { return stream(model, context, { ...base, diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index b52eae4cf8f..d0834939759 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -187,7 +187,10 @@ export const streamSimple: StreamFunction<"mistral-conversations", SimpleStreamO throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, context, options, apiKey); + const base = { + ...buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + } satisfies MistralOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoning = clampedReasoning === "off" ? undefined : clampedReasoning; const shouldUseReasoning = model.reasoning && reasoning !== undefined; diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 71e0ee9bc98..71a917d675d 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -499,7 +499,10 @@ export const streamSimple: StreamFunction<"openai-codex-responses", SimpleStream throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, context, options, apiKey); + const base = { + ...buildBaseOptions(model, context, options, apiKey), + toolChoice: options?.toolChoice, + } satisfies OpenAICodexResponsesOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index fd58a2284c8..d261e82a407 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -622,15 +622,16 @@ export const streamSimple: StreamFunction<"openai-completions", SimpleStreamOpti ): AssistantMessageEventStream => { getClientApiKey(model.provider, options?.apiKey, options?.headers); - const base = buildBaseOptions(model, context, options, options?.apiKey); + const base = { + ...buildBaseOptions(model, context, options, options?.apiKey), + toolChoice: options?.toolChoice, + } satisfies OpenAICompletionsOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; - const toolChoice = (options as OpenAICompletionsOptions | undefined)?.toolChoice; return stream(model, context, { ...base, reasoningEffort, - toolChoice, thinkingBudgets: options?.thinkingBudgets, } satisfies OpenAICompletionsOptions); }; diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 58d179c7ccf..52ff366d5a2 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -202,7 +202,10 @@ export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOption ): AssistantMessageEventStream => { getClientApiKey(model.provider, options?.apiKey, options?.headers); - const base = buildBaseOptions(model, context, options, options?.apiKey); + const base = { + ...buildBaseOptions(model, context, options, options?.apiKey), + toolChoice: options?.toolChoice, + } satisfies OpenAIResponsesOptions; const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 31a9dbbd72b..6bedc667476 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -427,7 +427,7 @@ export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> = return stream(model, context, { ...options, reasoning: options?.reasoning, - toolChoice: extra?.toolChoice, + toolChoice: options?.toolChoice, debug: extra?.debug, }); }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index aa5200896cc..832222133b4 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -79,6 +79,7 @@ export type KnownImagesProvider = "openrouter"; export type ImagesProviderId = KnownImagesProvider | string; +export type ToolChoice = "auto" | "none"; export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; export type ModelThinkingLevel = "off" | ThinkingLevel; export type ThinkingLevelMap = Partial>; @@ -304,6 +305,8 @@ export type AnthropicRefusalFallback = "default" | readonly { model: string }[]; // Unified options with reasoning passed to streamSimple() and completeSimple() export interface SimpleStreamOptions extends StreamOptions { + /** Provider-neutral tool selection for simple requests. Default: "auto". */ + toolChoice?: ToolChoice; reasoning?: ThinkingLevel; /** Anthropic server-side fallback for eligible refusal stop reasons. Anthropic providers only. */ refusalFallbacks?: AnthropicRefusalFallback; diff --git a/packages/ai/test/azure-openai-tool-choice.test.ts b/packages/ai/test/azure-openai-tool-choice.test.ts index 65e1d0f7d2a..3ce768e1c63 100644 --- a/packages/ai/test/azure-openai-tool-choice.test.ts +++ b/packages/ai/test/azure-openai-tool-choice.test.ts @@ -1,6 +1,6 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { stream } from "../src/api/azure-openai-responses.ts"; +import { stream, streamSimple } from "../src/api/azure-openai-responses.ts"; import type { Model } from "../src/types.ts"; const model: Model<"azure-openai-responses"> = { @@ -46,4 +46,34 @@ describe("Azure OpenAI tool choice", () => { expect(payload).toMatchObject({ tool_choice: "required" }); expect((payload as { tools?: unknown[] }).tools).toHaveLength(1); }); + + it("forwards provider-neutral tool choice from simple options", async () => { + let payload: unknown; + const result = streamSimple( + model, + { + messages: [{ role: "user", content: "Summarize this", timestamp: 1 }], + tools: [ + { + name: "read", + description: "Read a file", + parameters: Type.Object({ path: Type.String() }), + }, + ], + }, + { + apiKey: "test-key", + toolChoice: "none", + onPayload: (requestPayload) => { + payload = requestPayload; + throw new Error("payload captured"); + }, + }, + ); + + await result.result(); + + expect(payload).toMatchObject({ tool_choice: "none" }); + expect((payload as { tools?: unknown[] }).tools).toHaveLength(1); + }); }); diff --git a/packages/ai/test/pi-messages.test.ts b/packages/ai/test/pi-messages.test.ts index f0c49a962db..62f7fae434d 100644 --- a/packages/ai/test/pi-messages.test.ts +++ b/packages/ai/test/pi-messages.test.ts @@ -165,13 +165,13 @@ describe("pi-messages", () => { const model = createModel(baseUrl); let observedHeaders: Record | undefined; - const options: PiMessagesOptions = { + const options = { apiKey: "test-key", debug: true, onResponse: (response) => { observedHeaders = response.headers; }, - }; + } satisfies PiMessagesOptions; const message = await streamSimple(model, context, options).result(); expect(message.stopReason).toBe("stop"); From 90305d90a049d3f7784f15821d117fc6932248e7 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 20:00:34 +0200 Subject: [PATCH 203/284] fix(coding-agent): disable tools during summarization --- .../core/compaction/branch-summarization.ts | 3 + .../src/core/compaction/compaction.ts | 7 ++ .../test/branch-summarization.test.ts | 90 +++++++++++++++++++ .../test/compaction-summary-reasoning.test.ts | 35 +++++++- 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/branch-summarization.test.ts diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index dbb1217e575..8b0d6d3c53a 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -357,6 +357,9 @@ export async function generateBranchSummary( if (response.stopReason === "error") { return { error: response.errorMessage || "Summarization failed" }; } + if (response.content.some((block) => block.type === "toolCall")) { + return { error: "Branch summarization attempted to call a tool" }; + } let summary = contentText(response.content); diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 8792fa5502a..4a3734961d1 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -589,6 +589,7 @@ export async function completeSummarization( ...options, cacheRetention: "none", sessionId: options.sessionId ?? uuidv7(), + toolChoice: "none", }; const produce = async (): Promise => streamFn @@ -708,6 +709,9 @@ export async function generateSummaryWithUsage( if (response.stopReason === "error") { throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); } + if (response.content.some((block) => block.type === "toolCall")) { + throw new Error("Summarization attempted to call a tool"); + } const textContent = contentText(response.content); @@ -996,6 +1000,9 @@ async function generateTurnPrefixSummary( if (response.stopReason === "error") { throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); } + if (response.content.some((block) => block.type === "toolCall")) { + throw new Error("Turn prefix summarization attempted to call a tool"); + } return { text: contentText(response.content), diff --git a/packages/coding-agent/test/branch-summarization.test.ts b/packages/coding-agent/test/branch-summarization.test.ts new file mode 100644 index 00000000000..875dce881b3 --- /dev/null +++ b/packages/coding-agent/test/branch-summarization.test.ts @@ -0,0 +1,90 @@ +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { + type AssistantMessage, + createAssistantMessageEventStream, + fauxAssistantMessage, + type Model, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { generateBranchSummary } from "../src/core/compaction/index.ts"; +import type { SessionEntry } from "../src/core/session-manager.ts"; + +const model: Model<"anthropic-messages"> = { + id: "test-model", + name: "Test Model", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, +}; + +const entries: SessionEntry[] = [ + { + type: "message", + id: "branch-user", + parentId: null, + timestamp: new Date(1).toISOString(), + message: { role: "user", content: "Abandoned request", timestamp: 1 }, + }, +]; + +function response(content: AssistantMessage["content"]): AssistantMessage { + return { + ...fauxAssistantMessage(""), + content, + api: model.api, + provider: model.provider, + model: model.id, + }; +} + +describe("branch summarization", () => { + it("disables tools for branch summaries", async () => { + let requestOptions: SimpleStreamOptions | undefined; + const streamFn: StreamFn = (_model, _context, options) => { + requestOptions = options; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => + stream.push({ type: "done", reason: "stop", message: response([{ type: "text", text: "summary" }]) }), + ); + return stream; + }; + + await generateBranchSummary(entries, { + model, + signal: new AbortController().signal, + streamFn, + }); + + expect(requestOptions?.toolChoice).toBe("none"); + }); + + it("rejects tool calls from branch summaries", async () => { + const streamFn: StreamFn = () => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => + stream.push({ + type: "done", + reason: "toolUse", + message: response([ + { type: "toolCall", id: "tool-call-1", name: "read", arguments: { path: "README.md" } }, + ]), + }), + ); + return stream; + }; + + const result = await generateBranchSummary(entries, { + model, + signal: new AbortController().signal, + streamFn, + }); + + expect(result.error).toBe("Branch summarization attempted to call a tool"); + }); +}); diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index e30260e332b..2d5f155f709 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -59,6 +59,12 @@ const mockSummaryResponse: AssistantMessage = { timestamp: Date.now(), }; +const mockToolCallResponse: AssistantMessage = { + ...mockSummaryResponse, + content: [{ type: "toolCall", id: "tool-call-1", name: "read", arguments: { path: "README.md" } }], + stopReason: "toolUse", +}; + const messages: AgentMessage[] = [{ role: "user", content: "Summarize this.", timestamp: Date.now() }]; describe("generateSummary reasoning options", () => { @@ -103,6 +109,7 @@ describe("generateSummary reasoning options", () => { const requestOptions = completeSimpleMock.mock.calls.map((call) => call[2]); expect(requestOptions).toHaveLength(2); expect(requestOptions.every((options) => options?.cacheRetention === "none")).toBe(true); + expect(requestOptions.every((options) => options?.toolChoice === "none")).toBe(true); const sessionIds = requestOptions.map((options) => options?.sessionId); expect(sessionIds[0]).not.toBe(sessionIds[1]); @@ -112,15 +119,41 @@ describe("generateSummary reasoning options", () => { await completeSummarization( createModel(false), { systemPrompt: "Summarize", messages: [] }, - { sessionId: "current-routing-session", cacheRetention: "long" }, + { sessionId: "current-routing-session", cacheRetention: "long", toolChoice: "auto" }, ); expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ sessionId: "current-routing-session", cacheRetention: "none", + toolChoice: "none", }); }); + it("rejects tool calls from conversation summaries", async () => { + completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse); + + await expect(generateSummaryWithUsage(messages, createModel(false), 2000, "test-key")).rejects.toThrow( + "Summarization attempted to call a tool", + ); + }); + + it("rejects tool calls from split-turn summaries", async () => { + completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await expect(compact(preparation, createModel(false), "test-key")).rejects.toThrow( + "Turn prefix summarization attempted to call a tool", + ); + }); + it("does not set reasoning when thinking is off", async () => { await generateSummary( messages, From 8af7690c4f0c41fad274620829c75a3243d07aa3 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 18 Aug 2026 09:26:07 +0200 Subject: [PATCH 204/284] fix(coding-agent): skip trusted subagent prompts closes #8261 --- packages/coding-agent/CHANGELOG.md | 1 + .../examples/extensions/subagent/README.md | 2 +- .../examples/extensions/subagent/index.ts | 7 +- .../8261-subagent-project-trust.test.ts | 82 +++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/8261-subagent-project-trust.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4983f6ae8df..d64c60d224c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). diff --git a/packages/coding-agent/examples/extensions/subagent/README.md b/packages/coding-agent/examples/extensions/subagent/README.md index 9290342dc17..da74f32660f 100644 --- a/packages/coding-agent/examples/extensions/subagent/README.md +++ b/packages/coding-agent/examples/extensions/subagent/README.md @@ -62,7 +62,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust. -When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable. +When running interactively, the tool prompts for confirmation before running project-local agents in untrusted projects. Trusted projects skip the additional prompt. Set `confirmProjectAgents: false` to disable confirmation. ## Usage diff --git a/packages/coding-agent/examples/extensions/subagent/index.ts b/packages/coding-agent/examples/extensions/subagent/index.ts index fa3b09e3804..71b1a33dc75 100644 --- a/packages/coding-agent/examples/extensions/subagent/index.ts +++ b/packages/coding-agent/examples/extensions/subagent/index.ts @@ -517,7 +517,12 @@ export default function (pi: ExtensionAPI) { }; } - if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) { + if ( + (agentScope === "project" || agentScope === "both") && + confirmProjectAgents && + ctx.hasUI && + !ctx.isProjectTrusted() + ) { const requestedAgentNames = new Set(); if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent); if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent); diff --git a/packages/coding-agent/test/suite/regressions/8261-subagent-project-trust.test.ts b/packages/coding-agent/test/suite/regressions/8261-subagent-project-trust.test.ts new file mode 100644 index 00000000000..211e1bfa51e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8261-subagent-project-trust.test.ts @@ -0,0 +1,82 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { describe, expect, it, vi } from "vitest"; +import subagentExtension from "../../../examples/extensions/subagent/index.ts"; +import type { ExtensionUIContext } from "../../../src/core/extensions/index.ts"; +import { createHarness, getMessageText } from "../harness.ts"; + +vi.mock("@earendil-works/pi-coding-agent", () => ({ + CONFIG_DIR_NAME: ".pi", + getAgentDir: () => "/missing-user-agent-dir", + getMarkdownTheme: () => ({}), + parseFrontmatter: (content: string) => ({ + frontmatter: { name: "project-agent", description: "Project test agent" }, + body: content, + }), + withFileMutationQueue: async (_path: string, fn: () => Promise) => fn(), +})); + +interface RunOptions { + trusted: boolean; + confirmResult?: boolean; +} + +async function runProjectAgent(options: RunOptions): Promise<{ confirmCalls: number; toolResult: string }> { + const harness = await createHarness({ extensionFactories: [subagentExtension] }); + const confirm = vi.fn(async () => options.confirmResult ?? false); + + try { + const agentsDir = join(harness.tempDir, ".pi", "agents"); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "project-agent.md"), + "---\nname: project-agent\ndescription: Project test agent\n---\n\nHandle the delegated task.\n", + ); + harness.settingsManager.setProjectTrusted(options.trusted); + + await harness.session.bindExtensions({ + uiContext: { confirm } as unknown as ExtensionUIContext, + mode: "tui", + }); + + harness.setResponses([ + fauxAssistantMessage( + fauxToolCall("subagent", { + agent: "project-agent", + task: "Test project trust", + agentScope: "project", + cwd: join(harness.tempDir, "missing-cwd"), + }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("Delegate this task"); + + const toolResult = harness.session.messages.find((message) => message.role === "toolResult"); + return { + confirmCalls: confirm.mock.calls.length, + toolResult: getMessageText(toolResult), + }; + } finally { + harness.cleanup(); + } +} + +describe("regression #8261: subagent project trust", () => { + it("skips per-call confirmation for trusted projects", async () => { + const result = await runProjectAgent({ trusted: true }); + + expect(result.confirmCalls).toBe(0); + expect(result.toolResult).not.toContain("Canceled:"); + }); + + it("keeps confirmation for untrusted interactive projects", async () => { + const result = await runProjectAgent({ trusted: false, confirmResult: false }); + + expect(result.confirmCalls).toBe(1); + expect(result.toolResult).toContain("Canceled: project-local agents not approved."); + }); +}); From 2509b5c037d366979f2febfce4174b88aeaadc6a Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 20:14:22 +0200 Subject: [PATCH 205/284] feat(agent): expose provider context construction --- packages/agent/src/agent-loop.ts | 31 +++++++++++------ packages/agent/src/agent.ts | 16 ++++++++- packages/agent/test/agent.test.ts | 57 ++++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index a251fede0a9..1f9d44599a2 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -274,17 +274,12 @@ async function runLoop( await emit({ type: "agent_end", messages: newMessages }); } -/** - * Stream an assistant response from the LLM. - * This is where AgentMessage[] gets transformed to Message[] for the LLM. - */ -async function streamAssistantResponse( +/** Build the provider context using the same transform and conversion pipeline as an agent request. */ +export async function buildProviderContext( context: AgentContext, - config: AgentLoopConfig, - signal: AbortSignal | undefined, - emit: AgentEventSink, - streamFunction: StreamFn, -): Promise { + config: Pick, + signal?: AbortSignal, +): Promise { // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; if (config.transformContext) { @@ -295,11 +290,25 @@ async function streamAssistantResponse( const llmMessages = await config.convertToLlm(messages); // Build LLM context - const llmContext: Context = { + return { systemPrompt: context.systemPrompt, messages: llmMessages, tools: context.tools, }; +} + +/** + * Stream an assistant response from the LLM. + * This is where AgentMessage[] gets transformed to Message[] for the LLM. + */ +async function streamAssistantResponse( + context: AgentContext, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFunction: StreamFn, +): Promise { + const llmContext = await buildProviderContext(context, config, signal); // Resolve API key (important for expiring tokens) const resolvedApiKey = diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 0de7edd8302..dfd4f93915e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,4 +1,5 @@ import type { + Context, ImageContent, Message, Model, @@ -7,7 +8,11 @@ import type { ThinkingBudgets, Transport, } from "@earendil-works/pi-ai"; -import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; +import { + buildProviderContext as buildProviderContextFromAgentContext, + runAgentLoop, + runAgentLoopContinue, +} from "./agent-loop.ts"; import { getDefaultStreamFn } from "./stream-fn.ts"; import type { AfterToolCallContext, @@ -261,6 +266,15 @@ export class Agent { return this._state; } + /** Build a provider context through the same transform and conversion pipeline used by agent requests. */ + async buildProviderContext(context: AgentContext, signal?: AbortSignal): Promise { + return buildProviderContextFromAgentContext( + context, + { convertToLlm: this.convertToLlm, transformContext: this.transformContext }, + signal, + ); + } + /** Controls how queued steering messages are drained. */ set steeringMode(mode: QueueMode) { this.steeringQueue.mode = mode; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 672c4a6789c..7642374b388 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,9 +1,16 @@ -import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat"; +import { + type AssistantMessage, + type AssistantMessageEvent, + EventStream, + getModel, + type Message, +} from "@earendil-works/pi-ai/compat"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { Agent, type AgentEvent, + type AgentMessage, type AgentTool, type AgentToolUpdateCallback, type StreamFn, @@ -134,6 +141,54 @@ describe("Agent", () => { expect(agent.state.thinkingLevel).toBe("low"); }); + it("builds provider context through configured transforms", async () => { + const sourceMessages: AgentMessage[] = [ + { role: "user", content: "discard", timestamp: 1 }, + { role: "user", content: "keep", timestamp: 2 }, + ]; + const transformedMessages: AgentMessage[] = [sourceMessages[1]]; + const callOrder: string[] = []; + const abortController = new AbortController(); + let transformInput: AgentMessage[] | undefined; + let convertInput: AgentMessage[] | undefined; + const agent = new Agent({ + streamFn: unusedStreamFunction, + transformContext: async (messages, signal) => { + callOrder.push("transform"); + transformInput = messages; + expect(signal).toBe(abortController.signal); + return transformedMessages; + }, + convertToLlm: async (messages) => { + callOrder.push("convert"); + convertInput = messages; + return messages.filter( + (message): message is Message => + message.role === "user" || message.role === "assistant" || message.role === "toolResult", + ); + }, + }); + const tools: AgentTool[] = []; + + const context = await agent.buildProviderContext( + { + systemPrompt: "System prompt", + messages: sourceMessages, + tools, + }, + abortController.signal, + ); + + expect(callOrder).toEqual(["transform", "convert"]); + expect(transformInput).toBe(sourceMessages); + expect(convertInput).toBe(transformedMessages); + expect(context).toEqual({ + systemPrompt: "System prompt", + messages: transformedMessages, + tools, + }); + }); + it("should subscribe to events", () => { const agent = new Agent({ streamFn: unusedStreamFunction }); From 836aee6d38f60428ab6bd2679f93dce43a55dab3 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 18 Aug 2026 12:56:34 +0200 Subject: [PATCH 206/284] feat(coding-agent): show compaction usage notices --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/settings.md | 2 +- .../coding-agent/src/core/settings-manager.ts | 2 +- .../components/settings-selector.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 55 ++++++- .../test/interactive-mode-compaction.test.ts | 148 +++++++++++++++++- 6 files changed, 202 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d64c60d224c..e857000f5eb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled. + ### Fixed - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 585160806b2..c56340b1d12 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -31,7 +31,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `defaultModel` | string | - | Default model ID | | `defaultThinkingLevel` | string | - | `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | | `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output | -| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses | +| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses and compaction or branch-summary usage | | `thinkingBudgets` | object | - | Custom token budgets per thinking level | #### thinkingBudgets diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 9a744f0270b..22e72b8df07 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -100,7 +100,7 @@ export interface Settings { branchSummary?: BranchSummarySettings; retry?: RetrySettings; hideThinkingBlock?: boolean; - showCacheMissNotices?: boolean; // default: false - show transcript notices for significant prompt-cache misses + showCacheMissNotices?: boolean; // default: false - show prompt-cache miss and compaction cost notices externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows); supports leading ~ expansion quietStartup?: boolean; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 20c6b7586c7..1d626270e25 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -549,7 +549,7 @@ export class SettingsSelectorComponent extends Container { { id: "cache-miss-notices", label: "Cache miss notices", - description: "Show transcript notices for significant prompt-cache misses", + description: "Show transcript notices for significant prompt-cache misses and compaction costs", currentValue: config.showCacheMissNotices ? "true" : "false", values: ["true", "false"], }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 999220398b6..76c69b5440c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -9,7 +9,7 @@ import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; -import type { AssistantMessage, ImageContent, Message, Model } from "@earendil-works/pi-ai/compat"; +import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat"; import type { AutocompleteItem, AutocompleteProvider, @@ -204,12 +204,22 @@ type CompactionQueuedMessage = { mode: "steer" | "followUp"; }; -type RenderSessionItem = AgentMessage | Extract; +type CompactionCostNotice = { + type: "compaction_cost"; + kind: "compaction" | "branch_summary"; + usage: Usage; +}; + +type RenderSessionItem = AgentMessage | Extract | CompactionCostNotice; function isCustomSessionEntry(item: RenderSessionItem): item is Extract { return "type" in item && item.type === "custom"; } +function isCompactionCostNotice(item: RenderSessionItem): item is CompactionCostNotice { + return "type" in item && item.type === "compaction_cost"; +} + const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]); function isDeadTerminalError(error: unknown): boolean { @@ -3349,8 +3359,13 @@ export class InteractiveMode { this.showStatus("Auto-compaction cancelled"); } } else if (event.result) { + const entries = this.sessionManager.buildContextEntries(); + if (entries[0]?.type !== "compaction") { + throw new Error("Completed compaction is missing from the session context"); + } this.chatContainer.clear(); - this.rebuildChatFromMessages(); + // The latest compaction is prepended for model context; append it below at its chronological position. + this.renderSessionEntries(entries.slice(1)); this.addMessageToChat( createCompactionSummaryMessage( event.result.summary, @@ -3358,6 +3373,13 @@ export class InteractiveMode { new Date().toISOString(), ), ); + if (event.result.usage) { + this.addCompactionCostNotice({ + type: "compaction_cost", + kind: "compaction", + usage: event.result.usage, + }); + } this.footer.invalidate(); } else if (event.errorMessage) { if (event.reason === "manual") { @@ -3629,6 +3651,10 @@ export class InteractiveMode { this.addCustomEntryToChat(item); continue; } + if (isCompactionCostNotice(item)) { + this.addCompactionCostNotice(item); + continue; + } const message = item; // Assistant messages need special handling for tool calls @@ -3706,11 +3732,32 @@ export class InteractiveMode { if (entry.type === "custom") { return [entry]; } - return sessionEntryToContextMessages(entry); + const messages = sessionEntryToContextMessages(entry); + if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) { + return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }]; + } + return messages; }); this.renderSessionItems(items, options); } + /** + * Render billing usage for a compaction or branch summary. The notice is derived + * from persisted summary usage and is not stored as a separate session entry. + */ + private addCompactionCostNotice(notice: CompactionCostNotice): void { + if (!this.settingsManager.getShowCacheMissNotices()) return; + + const { usage } = notice; + const tokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite; + const cost = usage.cost.total >= 0.01 ? ` (~$${usage.cost.total.toFixed(2)})` : ""; + const label = notice.kind === "compaction" ? "Compaction" : "Branch summary"; + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Text(theme.fg("warning", `${label}: ${formatTokens(tokens)} tokens billed${cost}`), 1, 0), + ); + } + /** * Show a transcript notice when a completed assistant message paid for a * significant cache miss. Only states observable facts: the miss itself, diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index dd019f8f4dd..ece2d43cac7 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -1,8 +1,140 @@ +import type { Usage } from "@earendil-works/pi-ai"; +import { Container } from "@earendil-works/pi-tui"; import { describe, expect, test, vi } from "vitest"; +import type { SessionEntry } from "../src/core/session-manager.ts"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; describe("InteractiveMode compaction events", () => { - test("rebuilds chat and appends a synthetic compaction summary at the bottom", async () => { + test("uses the cache miss notice setting for compaction and branch summary costs", () => { + const usage: Usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.065, total: 0.125 }, + }; + const addCompactionCostNotice = Reflect.get(InteractiveMode.prototype, "addCompactionCostNotice") as ( + this: { chatContainer: Container; settingsManager: { getShowCacheMissNotices(): boolean } }, + notice: { + type: "compaction_cost"; + kind: "compaction" | "branch_summary"; + usage: Usage; + }, + ) => void; + + initTheme("dark"); + const enabled = { + chatContainer: new Container(), + settingsManager: { getShowCacheMissNotices: () => true }, + }; + addCompactionCostNotice.call(enabled, { type: "compaction_cost", kind: "compaction", usage }); + addCompactionCostNotice.call(enabled, { + type: "compaction_cost", + kind: "branch_summary", + usage, + }); + const output = stripAnsi(enabled.chatContainer.render(120).join("\n")); + expect(output).toContain("Compaction: 100 tokens billed (~$0.13)"); + expect(output).toContain("Branch summary: 100 tokens billed (~$0.13)"); + + const disabled = { + chatContainer: new Container(), + settingsManager: { getShowCacheMissNotices: () => false }, + }; + addCompactionCostNotice.call(disabled, { type: "compaction_cost", kind: "compaction", usage }); + expect(disabled.chatContainer.children).toHaveLength(0); + }); + + test("renders each compaction cost after its summary", () => { + const currentUsage: Usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, + }; + const previousUsage: Usage = { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.001, output: 0.002, cacheRead: 0.003, cacheWrite: 0.004, total: 0.01 }, + }; + const entries: SessionEntry[] = [ + { + type: "compaction", + id: "current", + parentId: "previous", + timestamp: "2025-01-02T00:00:00Z", + summary: "current summary", + firstKeptEntryId: "kept", + tokensBefore: 200, + usage: currentUsage, + }, + { + type: "compaction", + id: "previous", + parentId: null, + timestamp: "2025-01-01T00:00:00Z", + summary: "previous summary", + firstKeptEntryId: "kept", + tokensBefore: 100, + usage: previousUsage, + }, + ]; + const fakeThis = { renderSessionItems: vi.fn() }; + const renderSessionEntries = Reflect.get(InteractiveMode.prototype, "renderSessionEntries") as ( + this: typeof fakeThis, + entries: SessionEntry[], + ) => void; + + renderSessionEntries.call(fakeThis, entries); + + expect(fakeThis.renderSessionItems).toHaveBeenCalledWith( + [ + expect.objectContaining({ role: "compactionSummary", summary: "current summary" }), + { type: "compaction_cost", kind: "compaction", usage: currentUsage }, + expect.objectContaining({ role: "compactionSummary", summary: "previous summary" }), + { type: "compaction_cost", kind: "compaction", usage: previousUsage }, + ], + {}, + ); + }); + + test("renders retained entries and appends the latest summary cost at the bottom", async () => { + const usage: Usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.065, total: 0.125 }, + }; + const latestCompaction: SessionEntry = { + type: "compaction", + id: "latest", + parentId: "previous", + timestamp: "2025-01-02T00:00:00Z", + summary: "summary", + firstKeptEntryId: "kept", + tokensBefore: 123, + usage, + }; + const previousCompaction: SessionEntry = { + type: "compaction", + id: "previous", + parentId: null, + timestamp: "2025-01-01T00:00:00Z", + summary: "previous summary", + firstKeptEntryId: "kept", + tokensBefore: 100, + usage, + }; const fakeThis = { isInitialized: true, footer: { invalidate: vi.fn() }, @@ -11,8 +143,10 @@ describe("InteractiveMode compaction events", () => { defaultEditor: {}, statusContainer: { clear: vi.fn() }, chatContainer: { clear: vi.fn() }, - rebuildChatFromMessages: vi.fn(), + sessionManager: { buildContextEntries: vi.fn().mockReturnValue([latestCompaction, previousCompaction]) }, + renderSessionEntries: vi.fn(), addMessageToChat: vi.fn(), + addCompactionCostNotice: vi.fn(), showError: vi.fn(), showStatus: vi.fn(), clearStatusIndicator: vi.fn(), @@ -26,7 +160,7 @@ describe("InteractiveMode compaction events", () => { event: { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; - result: { tokensBefore: number; summary: string } | undefined; + result: { tokensBefore: number; summary: string; usage?: Usage } | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string; @@ -39,13 +173,14 @@ describe("InteractiveMode compaction events", () => { result: { tokensBefore: 123, summary: "summary", + usage, }, aborted: false, willRetry: false, }); expect(fakeThis.chatContainer.clear).toHaveBeenCalledTimes(1); - expect(fakeThis.rebuildChatFromMessages).toHaveBeenCalledTimes(1); + expect(fakeThis.renderSessionEntries).toHaveBeenCalledWith([previousCompaction]); expect(fakeThis.addMessageToChat).toHaveBeenCalledTimes(1); expect(fakeThis.addMessageToChat).toHaveBeenCalledWith( expect.objectContaining({ @@ -54,6 +189,11 @@ describe("InteractiveMode compaction events", () => { summary: "summary", }), ); + expect(fakeThis.addCompactionCostNotice).toHaveBeenCalledWith({ + type: "compaction_cost", + kind: "compaction", + usage, + }); expect(fakeThis.flushCompactionQueue).toHaveBeenCalledWith({ willRetry: false }); }); From ef8dc7385b1ed41c40cd0a4bbf612326ec18bd23 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 18 Aug 2026 14:17:38 +0200 Subject: [PATCH 207/284] feat(coding-agent): centralize compaction summary requests --- .../coding-agent/src/core/agent-session.ts | 44 ++++++++++++++----- .../src/core/compaction/compaction.ts | 41 ++++++++--------- .../test/compaction-summary-reasoning.test.ts | 21 ++++++++- .../suite/agent-session-compaction.test.ts | 40 ++++++++++++++++- 4 files changed, 111 insertions(+), 35 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5914a37125f..74280d56411 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -54,6 +54,7 @@ import { normalizeToolResultImages } from "../utils/tool-result-images.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; import { + type CompactionPreparation, type CompactionResult, calculateContextTokens, collectEntriesForBranchSummary, @@ -1789,6 +1790,33 @@ export class AgentSession { // Compaction // ========================================================================= + /** Generate Pi's built-in compaction summary for manual and automatic compaction. */ + private async _runDefaultCompaction( + preparation: CompactionPreparation, + requestModel: Model, + apiKey: string | undefined, + headers: Record | undefined, + customInstructions: string | undefined, + signal: AbortSignal, + env: Record | undefined, + reason: "manual" | "threshold" | "overflow", + ): Promise { + return compact( + preparation, + requestModel, + apiKey, + headers, + customInstructions, + signal, + this.thinkingLevel, + this.agent.streamFunction, + env, + this.settingsManager.getRetrySettings(), + this._summarizationRetryCallbacks({ source: "compaction", reason }), + undefined, // sessionId + ); + } + /** * Manually compact the session context. * @@ -1868,19 +1896,15 @@ export class AgentSession { details = extensionCompaction.details; } else { // Shared default summary generator, also used by automatic compaction. - const result = await compact( + const result = await this._runDefaultCompaction( preparation, requestModel, apiKey, headers, customInstructions, this._compactionAbortController.signal, - this.thinkingLevel, - this.agent.streamFunction, env, - this.settingsManager.getRetrySettings(), - this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }), - undefined, // sessionId + "manual", ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -2181,19 +2205,15 @@ export class AgentSession { details = extensionCompaction.details; } else { // Shared default summary generator, also used by manual compaction. - const compactResult = await compact( + const compactResult = await this._runDefaultCompaction( preparation, requestModel, apiKey, headers, undefined, this._autoCompactionAbortController.signal, - this.thinkingLevel, - this.agent.streamFunction, env, - this.settingsManager.getRetrySettings(), - this._summarizationRetryCallbacks({ source: "compaction", reason }), - undefined, // sessionId + reason, ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 4a3734961d1..5fbeefc2a52 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -508,9 +508,7 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; -const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. - -Update the existing structured summary with new information. RULES: +const UPDATE_SUMMARIZATION_INSTRUCTIONS = `Update the existing structured summary with new information. RULES: - PRESERVE all existing information from the previous summary - ADD new progress, decisions, and context from the new messages - UPDATE the Progress section: move items from "In Progress" to "Done" when completed @@ -547,6 +545,10 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; + function createSummarizationOptions( model: Model, maxTokens: number, @@ -638,6 +640,20 @@ export async function generateSummary( ).text; } +/** Build the provider context for a standalone summary request. */ +function buildSummarizationContext(promptText: string): Context { + return { + systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, + messages: [ + { + role: "user", + content: [{ type: "text", text: promptText }], + timestamp: Date.now(), + }, + ], + }; +} + /** Generate or update a conversation summary and return its provider usage. */ export async function generateSummaryWithUsage( currentMessages: AgentMessage[], @@ -678,14 +694,6 @@ export async function generateSummaryWithUsage( } promptText += basePrompt; - const summarizationMessages = [ - { - role: "user" as const, - content: [{ type: "text" as const, text: promptText }], - timestamp: Date.now(), - }, - ]; - const completionOptions = createSummarizationOptions( model, maxTokens, @@ -699,7 +707,7 @@ export async function generateSummaryWithUsage( const response = await completeSummarization( model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + buildSummarizationContext(promptText), completionOptions, streamFn, retry, @@ -980,17 +988,10 @@ async function generateTurnPrefixSummary( const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; - const summarizationMessages = [ - { - role: "user" as const, - content: [{ type: "text" as const, text: promptText }], - timestamp: Date.now(), - }, - ]; const response = await completeSummarization( model, - { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + buildSummarizationContext(promptText), createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId), streamFn, retry, diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 2d5f155f709..340d5c1f6db 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; +import type { AssistantMessage, Context, Model } from "@earendil-works/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { type CompactionPreparation, @@ -129,6 +129,25 @@ describe("generateSummary reasoning options", () => { }); }); + it("preserves the standalone split-turn summary prompt", async () => { + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await compact(preparation, createModel(false), "test-key"); + + const requestContext = completeSimpleMock.mock.calls[0][1] as Context; + const prompt = JSON.stringify(requestContext.messages); + expect(prompt).toContain("This is the PREFIX of a turn that was too large to keep"); + expect(prompt).toContain(""); + }); + it("rejects tool calls from conversation summaries", async () => { completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 5e9e7bcc8de..7d81358c553 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -1,8 +1,11 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { type AssistantMessage, + type Context, createAssistantMessageEventStream, fauxAssistantMessage, type Model, + type SimpleStreamOptions, } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { estimateTokens } from "../../src/core/compaction/index.ts"; @@ -47,10 +50,15 @@ function createAssistant( }; } -function useSummaryStreamFn(harness: Harness, summary: string): () => number { +function useSummaryStreamFn( + harness: Harness, + summary: string, + onRequest?: (context: Context, options: SimpleStreamOptions | undefined) => void, +): () => number { let callCount = 0; - harness.session.agent.streamFunction = (model) => { + harness.session.agent.streamFunction = (model, context, options) => { callCount++; + onRequest?.(context, options); const stream = createAssistantMessageEventStream(); queueMicrotask(() => { const message: AssistantMessage = { @@ -246,6 +254,34 @@ describe("AgentSession compaction characterization", () => { expect(harness.faux.state.callCount).toBe(1); }); + it("uses the standalone compaction request context", async () => { + const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } } }); + harnesses.push(harness); + seedCompactableSession(harness); + + const transformContext = vi.fn(async (messages: AgentMessage[]) => messages); + harness.session.agent.transformContext = transformContext; + harness.session.agent.sessionId = "active-routing-session"; + harness.session.agent.transport = "websocket"; + + let requestContext: Context | undefined; + let requestOptions: SimpleStreamOptions | undefined; + useSummaryStreamFn(harness, "standalone summary", (context, options) => { + requestContext = context; + requestOptions = options; + }); + + await harness.session.compact(); + + expect(transformContext).not.toHaveBeenCalled(); + expect(requestContext?.systemPrompt).not.toBe(harness.session.agent.state.systemPrompt); + expect(requestContext?.tools).toBeUndefined(); + expect(JSON.stringify(requestContext?.messages)).toContain(""); + expect(requestOptions).toMatchObject({ cacheRetention: "none" }); + expect(requestOptions?.sessionId).not.toBe("active-routing-session"); + expect(requestOptions?.transport).toBeUndefined(); + }); + it("persists usage from pi-generated manual compaction", async () => { const harness = await createHarness({ withConfiguredAuth: false }); harnesses.push(harness); From cff1cf52c6c73ef873dcb6148238ed68f69e1ead Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 18 Aug 2026 14:25:28 +0200 Subject: [PATCH 208/284] feat(coding-agent): add cache-friendly compaction primitives Add provider-context prefix preparation and optional summary request settings needed by cache-friendly compaction. Existing AgentSession callers continue to pass undefined, so standalone summarization remains the default until the feature is wired in. --- packages/ai/src/index.ts | 1 + packages/coding-agent/docs/compaction.md | 2 + .../coding-agent/src/core/agent-session.ts | 2 +- .../src/core/compaction/compaction.ts | 257 +++++++++++++++--- packages/coding-agent/src/index.ts | 1 + .../test/compaction-summary-reasoning.test.ts | 227 +++++++++++++++- packages/coding-agent/test/compaction.test.ts | 85 +++++- 7 files changed, 529 insertions(+), 46 deletions(-) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 82bac973852..64142c999db 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -37,6 +37,7 @@ export * from "./providers/faux.ts"; export * from "./session-resources.ts"; export * from "./types.ts"; export * from "./utils/diagnostics.ts"; +export { estimateContextTokens } from "./utils/estimate.ts"; export * from "./utils/event-stream.ts"; export * from "./utils/json-parse.ts"; export * from "./utils/overflow.ts"; diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 3a9dff1c63c..1ed15fcd62f 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -281,7 +281,9 @@ pi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event; // preparation.messagesToSummarize - messages to summarize + // preparation.sourceMessages - optional active-context history prefix, including any previous summary // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn) + // preparation.turnPrefixSourceMessages - optional active-context prefix through the split turn // preparation.previousSummary - previous compaction summary // preparation.fileOps - extracted file operations // preparation.tokensBefore - context tokens before compaction diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 74280d56411..4d02d194381 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1813,7 +1813,7 @@ export class AgentSession { env, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), - undefined, // sessionId + undefined, // cacheFriendly ); } diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 5fbeefc2a52..a2a1c063f1b 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,7 +6,14 @@ */ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai"; +import { + contentText, + estimateContextTokens as estimateProviderContextTokens, + type RetryCallbacks, + type RetryPolicy, + retryAssistantCall, + uuidv7, +} from "@earendil-works/pi-ai"; import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; import { completeSimple } from "@earendil-works/pi-ai/compat"; import { convertToLlm } from "../messages.ts"; @@ -95,6 +102,27 @@ function getMessageFromEntryForCompaction(entry: SessionEntry): AgentMessage | u return sessionEntryToContextMessages(entry)[0]; } +/** Build an active-context prefix, placing the previous compaction summary before its retained messages. */ +function collectSourceMessages( + entries: SessionEntry[], + startIndex: number, + endIndex: number, + previousCompactionIndex: number, +): AgentMessage[] { + const messages: AgentMessage[] = []; + if (previousCompactionIndex >= 0) { + messages.push(...sessionEntryToContextMessages(entries[previousCompactionIndex])); + } + for (let i = startIndex; i < endIndex; i++) { + // The latest compaction was moved to the front above. Keep older compaction + // entries because buildSessionContext retains them in the active provider prefix. + if (i !== previousCompactionIndex) { + messages.push(...sessionEntryToContextMessages(entries[i])); + } + } + return messages; +} + /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ export interface CompactionResult { summary: string; @@ -140,6 +168,19 @@ export interface CompactionSettings { keepRecentTokens: number; } +/** Active provider contexts and request settings used to preserve cacheable compaction prefixes. */ +export interface CacheFriendlySummaryOptions { + /** Exact provider context prefix containing the history to summarize. */ + sourceContext?: Context; + /** Exact provider context prefix containing a split turn's prefix. */ + turnPrefixSourceContext?: Context; + /** Provider request settings copied from the active agent request path. */ + requestOptions?: Pick< + SimpleStreamOptions, + "sessionId" | "onPayload" | "onResponse" | "transport" | "thinkingBudgets" | "maxRetryDelayMs" + >; +} + export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { enabled: true, reserveTokens: 16384, @@ -549,6 +590,10 @@ const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation mes ${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; +const SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT = `The messages above contain an existing structured summary of earlier conversation history followed by NEW conversation messages. + +${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; + function createSummarizationOptions( model: Model, maxTokens: number, @@ -557,9 +602,18 @@ function createSummarizationOptions( env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, - sessionId: string | undefined, + requestOptions: CacheFriendlySummaryOptions["requestOptions"] | undefined, + cacheRetention: SimpleStreamOptions["cacheRetention"] | undefined, ): SimpleStreamOptions { - const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; + const options: SimpleStreamOptions = { + ...requestOptions, + maxTokens, + signal, + apiKey, + headers, + env, + cacheRetention, + }; const refusalFallbacks = getAnthropicSummarizationFallback(model); if (refusalFallbacks) { options.refusalFallbacks = refusalFallbacks; @@ -585,12 +639,15 @@ export async function completeSummarization( retry?: RetryPolicy, callbacks?: RetryCallbacks, ): Promise { - // Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available; - // callers without a session ID, including branch summaries, receive a fresh routing ID. + // Standalone one-off summaries default to no prompt caching. Cache-friendly callers + // explicitly request short retention so providers can reuse the active prefix. + // Callers without a session ID, including standalone branch summaries, receive a fresh routing ID. const requestOptions: SimpleStreamOptions = { ...options, - cacheRetention: "none", + cacheRetention: options.cacheRetention ?? "none", sessionId: options.sessionId ?? uuidv7(), + // Anthropic invalidates the messages cache when tool_choice changes. Its 20-content-block lookup + // can still reuse an earlier user-message checkpoint, but long tool-heavy turns may need reprocessing. toolChoice: "none", }; const produce = async (): Promise => @@ -618,7 +675,7 @@ export async function generateSummary( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - sessionId?: string, + cacheFriendly?: Pick, ): Promise { return ( await generateSummaryWithUsage( @@ -635,25 +692,47 @@ export async function generateSummary( env, retry, callbacks, - sessionId, + cacheFriendly, ) ).text; } -/** Build the provider context for a standalone summary request. */ -function buildSummarizationContext(promptText: string): Context { +/** Build a standalone summary request or append its instruction to an existing provider context. */ +function buildSummarizationContext(promptText: string, sourceContext?: Context): Context { + const instructionMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }; + + if (sourceContext) { + return { + ...sourceContext, + messages: [...sourceContext.messages, instructionMessage], + }; + } + return { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, - messages: [ - { - role: "user", - content: [{ type: "text", text: promptText }], - timestamp: Date.now(), - }, - ], + messages: [instructionMessage], }; } +/** + * Extra room for provider framing and tokenizer variance omitted by the heuristic context estimate. + * This matches the 4096-token margin used when normal simple requests clamp maxTokens to their context window. + */ +const CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS = 4096; + +/** Whether the source context leaves room for the requested summary output and provider safety margin. */ +function cacheFriendlyContextFits(model: Model, context: Context, maxTokens: number): boolean { + return ( + model.contextWindow <= 0 || + estimateProviderContextTokens(context).tokens + maxTokens + CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS <= + model.contextWindow + ); +} + /** Generate or update a conversation summary and return its provider usage. */ export async function generateSummaryWithUsage( currentMessages: AgentMessage[], @@ -669,31 +748,55 @@ export async function generateSummaryWithUsage( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - sessionId?: string, + cacheFriendly?: Pick, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); - - // Use update prompt if we have a previous summary, otherwise initial prompt - let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; + // Provider-visible history prefix to reuse instead of serializing messages into a standalone prompt. + let sourceContext = cacheFriendly?.sourceContext; + + // Cache-friendly source contexts already contain the previous compaction summary, + // but still need iterative-update instructions so prior information is preserved. + let basePrompt = previousSummary + ? sourceContext + ? SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT + : UPDATE_SUMMARIZATION_PROMPT + : SUMMARIZATION_PROMPT; if (customInstructions) { basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; } - // Serialize conversation to text so model doesn't try to continue it - // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.) - const llmMessages = convertToLlm(currentMessages); - const conversationText = serializeConversation(llmMessages); + // A valid prefix can still be too large to leave the intended summary budget, + // especially during overflow recovery or after switching to a smaller-context model. + // In that case, use standalone serialization, which truncates large tool results. + if ( + sourceContext && + !cacheFriendlyContextFits(model, buildSummarizationContext(basePrompt, sourceContext), maxTokens) + ) { + sourceContext = undefined; + basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; + if (customInstructions) { + basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; + } + } - // Build the prompt with conversation wrapped in tags - let promptText = `\n${conversationText}\n\n\n`; - if (previousSummary) { + // Source contexts already contain the conversation. Standalone requests serialize it + // so the model treats those messages as data rather than continuing the conversation. + let promptText = ""; + if (!sourceContext) { + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); + promptText = `\n${conversationText}\n\n\n`; + } + if (previousSummary && !sourceContext) { promptText += `\n${previousSummary}\n\n\n`; } promptText += basePrompt; + const sourceRequestOptions = sourceContext ? cacheFriendly?.requestOptions : undefined; + const sourceCacheRetention = sourceContext ? "short" : undefined; const completionOptions = createSummarizationOptions( model, maxTokens, @@ -702,12 +805,13 @@ export async function generateSummaryWithUsage( env, signal, thinkingLevel, - sessionId, + sourceRequestOptions, + sourceCacheRetention, ); const response = await completeSummarization( model, - buildSummarizationContext(promptText), + buildSummarizationContext(promptText, sourceContext), completionOptions, streamFn, retry, @@ -735,8 +839,15 @@ export interface CompactionPreparation { firstKeptEntryId: string; /** Messages that will be summarized and discarded */ messagesToSummarize: AgentMessage[]; + /** + * Active-context prefix for the history summary. + * Includes the previous compaction summary before messages retained by that compaction. + */ + sourceMessages?: AgentMessage[]; /** Messages that will be turned into turn prefix summary (if splitting) */ turnPrefixMessages: AgentMessage[]; + /** Active-context prefix through the split-turn prefix, or empty when not splitting. */ + turnPrefixSourceMessages?: AgentMessage[]; /** Whether this is a split turn (cut point in middle of turn) */ isSplitTurn: boolean; tokensBefore: number; @@ -794,6 +905,8 @@ export function prepareCompaction( if (msg) messagesToSummarize.push(msg); } + const sourceMessages = collectSourceMessages(pathEntries, boundaryStart, historyEnd, prevCompactionIndex); + // Messages for turn prefix summary (if splitting a turn) const turnPrefixMessages: AgentMessage[] = []; if (cutPoint.isSplitTurn) { @@ -802,6 +915,9 @@ export function prepareCompaction( if (msg) turnPrefixMessages.push(msg); } } + const turnPrefixSourceMessages = cutPoint.isSplitTurn + ? collectSourceMessages(pathEntries, boundaryStart, cutPoint.firstKeptEntryIndex, prevCompactionIndex) + : []; if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { return undefined; @@ -820,7 +936,9 @@ export function prepareCompaction( return { firstKeptEntryId, messagesToSummarize, + sourceMessages, turnPrefixMessages, + turnPrefixSourceMessages, isSplitTurn: cutPoint.isSplitTurn, tokensBefore, previousSummary, @@ -848,13 +966,30 @@ Summarize the prefix to provide context for the retained suffix: Be concise. Focus on what's needed to understand the kept suffix.`; +const SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT = `The final turn in the source conversation was too large to keep in full. Its SUFFIX (recent work) is retained. + +The source conversation may also contain complete earlier turns for background. Summarize only the final, incomplete turn. It begins with the last user-role request before this instruction. Do not summarize earlier turns except for details needed to understand this final turn's prefix. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.`; + /** * Generate summaries for compaction using prepared data. * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. * * @param preparation - Pre-calculated preparation from prepareCompaction() * @param customInstructions - Optional custom focus for the summary - * @param sessionId - Optional routing session ID forwarded without enabling prompt caching + * @param cacheFriendly - Active provider contexts and request settings for cache-friendly summarization */ export async function compact( preparation: CompactionPreparation, @@ -868,7 +1003,7 @@ export async function compact( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - sessionId?: string, + cacheFriendly?: CacheFriendlySummaryOptions, ): Promise { const { firstKeptEntryId, @@ -903,7 +1038,10 @@ export async function compact( env, retry, callbacks, - sessionId, + { + sourceContext: cacheFriendly?.sourceContext, + requestOptions: cacheFriendly?.requestOptions, + }, ); historyText = historyResult.text; historyUsage = historyResult.usage; @@ -920,7 +1058,10 @@ export async function compact( streamFn, retry, callbacks, - sessionId, + { + sourceContext: cacheFriendly?.turnPrefixSourceContext, + requestOptions: cacheFriendly?.requestOptions, + }, ); // Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; @@ -941,7 +1082,10 @@ export async function compact( env, retry, callbacks, - sessionId, + { + sourceContext: cacheFriendly?.sourceContext, + requestOptions: cacheFriendly?.requestOptions, + }, ); summary = result.text; summaryUsage = result.usage; @@ -979,20 +1123,51 @@ async function generateTurnPrefixSummary( streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks, - sessionId?: string, + cacheFriendly?: Pick, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); // Smaller budget for turn prefix - const llmMessages = convertToLlm(messages); - const conversationText = serializeConversation(llmMessages); - const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; + // Reuse the provider-visible split-turn prefix only when it leaves room for the summary; + // otherwise serialize and truncate the messages in a standalone prompt. + let sourceContext = cacheFriendly?.sourceContext; + if ( + sourceContext && + !cacheFriendlyContextFits( + model, + buildSummarizationContext(SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT, sourceContext), + maxTokens, + ) + ) { + sourceContext = undefined; + } + let promptText: string; + if (sourceContext) { + promptText = SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT; + } else { + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; + } + + const sourceRequestOptions = sourceContext ? cacheFriendly?.requestOptions : undefined; + const sourceCacheRetention = sourceContext ? "short" : undefined; const response = await completeSummarization( model, - buildSummarizationContext(promptText), - createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId), + buildSummarizationContext(promptText, sourceContext), + createSummarizationOptions( + model, + maxTokens, + apiKey, + headers, + env, + signal, + thinkingLevel, + sourceRequestOptions, + sourceCacheRetention, + ), streamFn, retry, callbacks, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 6d64d70c87c..7c9c596a4af 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -28,6 +28,7 @@ export { readStoredCredential } from "./core/auth-storage.ts"; export { type BranchPreparation, type BranchSummaryResult, + type CacheFriendlySummaryOptions, type CollectEntriesResult, type CompactionResult, type CutPointResult, diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 340d5c1f6db..9bafc9d5d7f 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -115,7 +115,7 @@ describe("generateSummary reasoning options", () => { expect(sessionIds[0]).not.toBe(sessionIds[1]); }); - it("honors a caller-supplied routing session without prompt caching", async () => { + it("honors caller-supplied cache retention and routing", async () => { await completeSummarization( createModel(false), { systemPrompt: "Summarize", messages: [] }, @@ -124,7 +124,7 @@ describe("generateSummary reasoning options", () => { expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ sessionId: "current-routing-session", - cacheRetention: "none", + cacheRetention: "long", toolChoice: "none", }); }); @@ -148,6 +148,229 @@ describe("generateSummary reasoning options", () => { expect(prompt).toContain(""); }); + it("appends instructions to a cache-friendly source context", async () => { + const sourceContext: Context = { + systemPrompt: "You are a coding agent.", + messages: [{ role: "user", content: "Previous summary and original request", timestamp: 1 }], + tools: [], + }; + const onPayload = async (payload: unknown) => payload; + const onResponse = async () => {}; + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: messages, + turnPrefixMessages: [], + isSplitTurn: false, + tokensBefore: 100, + previousSummary: "Previous summary", + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await compact( + preparation, + createModel(false), + "test-key", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + sourceContext, + requestOptions: { + sessionId: "routing-session", + onPayload, + onResponse, + transport: "websocket", + thinkingBudgets: { low: 1234 }, + maxRetryDelayMs: 4321, + }, + }, + ); + + const requestContext = completeSimpleMock.mock.calls[0][1] as Context; + expect(requestContext.systemPrompt).toBe(sourceContext.systemPrompt); + expect(requestContext.tools).toBe(sourceContext.tools); + expect(requestContext.messages.slice(0, -1)).toEqual(sourceContext.messages); + const instruction = JSON.stringify(requestContext.messages.at(-1)); + expect(instruction).toContain("existing structured summary of earlier conversation history"); + expect(instruction).toContain("PRESERVE all existing information from the previous summary"); + expect(instruction).not.toContain(""); + expect(instruction).not.toContain(""); + expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ + cacheRetention: "short", + sessionId: "routing-session", + toolChoice: "none", + transport: "websocket", + thinkingBudgets: { low: 1234 }, + maxRetryDelayMs: 4321, + }); + expect(completeSimpleMock.mock.calls[0][2]?.onPayload).toBe(onPayload); + expect(completeSimpleMock.mock.calls[0][2]?.onResponse).toBe(onResponse); + expect(sourceContext.messages).toHaveLength(1); + }); + + it("falls back to standalone summarization when a cache-friendly source cannot leave the summary budget", async () => { + const oversizedToolResult: AgentMessage = { + role: "toolResult", + toolCallId: "tool-call-1", + toolName: "read", + content: [{ type: "text", text: "x".repeat(800_000) }], + isError: false, + timestamp: 1, + }; + const sourceContext: Context = { + systemPrompt: "You are a coding agent.", + messages: [oversizedToolResult], + tools: [], + }; + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [oversizedToolResult], + turnPrefixMessages: [], + isSplitTurn: false, + tokensBefore: 250_000, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await compact( + preparation, + createModel(false), + "test-key", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { sourceContext, requestOptions: { sessionId: "routing-session" } }, + ); + + const requestContext = completeSimpleMock.mock.calls[0][1] as Context; + const prompt = JSON.stringify(requestContext.messages); + expect(requestContext.systemPrompt).not.toBe(sourceContext.systemPrompt); + expect(requestContext.tools).toBeUndefined(); + expect(prompt).toContain(""); + expect(prompt).toContain("more characters truncated"); + expect(prompt).not.toContain("x".repeat(3000)); + expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ cacheRetention: "none", toolChoice: "none" }); + expect(completeSimpleMock.mock.calls[0][2]?.sessionId).not.toBe("routing-session"); + }); + + it("falls back to standalone summarization for an oversized split-turn source", async () => { + const splitUser = { role: "user" as const, content: "Large final request", timestamp: 1 }; + const oversizedToolResult: AgentMessage = { + role: "toolResult", + toolCallId: "tool-call-1", + toolName: "read", + content: [{ type: "text", text: "x".repeat(800_000) }], + isError: false, + timestamp: 2, + }; + const turnPrefixSourceContext: Context = { + systemPrompt: "You are a coding agent.", + messages: [splitUser, oversizedToolResult], + tools: [], + }; + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: [splitUser, oversizedToolResult], + isSplitTurn: true, + tokensBefore: 250_000, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await compact( + preparation, + createModel(false), + "test-key", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { turnPrefixSourceContext, requestOptions: { sessionId: "routing-session" } }, + ); + + const requestContext = completeSimpleMock.mock.calls[0][1] as Context; + const prompt = JSON.stringify(requestContext.messages); + expect(requestContext.systemPrompt).not.toBe(turnPrefixSourceContext.systemPrompt); + expect(requestContext.tools).toBeUndefined(); + expect(prompt).toContain("This is the PREFIX of a turn that was too large to keep"); + expect(prompt).toContain("more characters truncated"); + expect(prompt).not.toContain("source conversation may also contain complete earlier turns"); + expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ cacheRetention: "none", toolChoice: "none" }); + expect(completeSimpleMock.mock.calls[0][2]?.sessionId).not.toBe("routing-session"); + }); + + it("limits a cache-friendly split-turn summary to the final incomplete turn", async () => { + const earlierUser = { role: "user" as const, content: "Earlier request", timestamp: 1 }; + const earlierAssistant: AssistantMessage = { + ...mockSummaryResponse, + content: [{ type: "text", text: "Earlier work completed" }], + timestamp: 2, + }; + const splitUser = { role: "user" as const, content: "Large final request", timestamp: 3 }; + const earlyAssistant: AssistantMessage = { + ...mockSummaryResponse, + content: [{ type: "text", text: "Early work in final turn" }], + timestamp: 4, + }; + const turnPrefixSourceContext: Context = { + systemPrompt: "You are a coding agent.", + messages: [earlierUser, earlierAssistant, splitUser, earlyAssistant], + tools: [], + }; + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: [splitUser, earlyAssistant], + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await compact( + preparation, + createModel(false), + "test-key", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { turnPrefixSourceContext }, + ); + + const requestContext = completeSimpleMock.mock.calls[0][1] as Context; + expect(requestContext.messages.slice(0, -1)).toEqual(turnPrefixSourceContext.messages); + const instruction = JSON.stringify(requestContext.messages.at(-1)); + expect(instruction).toContain("Summarize only the final, incomplete turn"); + expect(instruction).toContain("last user-role request before this instruction"); + expect(instruction).toContain("Do not summarize earlier turns"); + expect(instruction).not.toContain("Earlier request"); + expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ + cacheRetention: "short", + toolChoice: "none", + }); + }); + it("rejects tool calls from conversation summaries", async () => { completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 12c9d0be220..8fe754ce6a7 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -5,6 +5,7 @@ import { readFileSync } from "fs"; import { join } from "path"; import { beforeEach, describe, expect, it } from "vitest"; import { + type CompactionPreparation, type CompactionSettings, calculateContextTokens, compact, @@ -150,6 +151,17 @@ function createCustomMessageEntry(content: string): CustomMessageEntry { return entry; } +function requireSourceMessages( + preparation: CompactionPreparation | undefined, +): asserts preparation is CompactionPreparation & { + sourceMessages: AgentMessage[]; + turnPrefixSourceMessages: AgentMessage[]; +} { + if (!preparation?.sourceMessages || !preparation.turnPrefixSourceMessages) { + throw new Error("Expected compaction source messages"); + } +} + function extractText(messages: AgentMessage[]): string { return messages .map((message) => { @@ -461,6 +473,44 @@ describe("buildSessionContext", () => { }); }); +describe("prepareCompaction source messages", () => { + it("provides the active history prefix for a normal compaction", () => { + const u1 = createMessageEntry(createUserMessage("old user ".repeat(20))); + const a1 = createMessageEntry(createAssistantMessage("old assistant ".repeat(20))); + const u2 = createMessageEntry(createUserMessage("recent user")); + const a2 = createMessageEntry(createAssistantMessage("ok")); + const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 3 }; + + const preparation = prepareCompaction([u1, a1, u2, a2], settings); + + expect(preparation).toBeDefined(); + requireSourceMessages(preparation); + expect(preparation.isSplitTurn).toBe(false); + expect(preparation.sourceMessages).toEqual(preparation.messagesToSummarize); + expect(extractText(preparation.sourceMessages)).toContain("old user"); + expect(extractText(preparation.sourceMessages)).not.toContain("recent user"); + expect(preparation.turnPrefixSourceMessages).toEqual([]); + }); + + it("provides the active prefix through a split turn", () => { + const user = createMessageEntry(createUserMessage("large request ".repeat(20))); + const earlyAssistant = createMessageEntry(createAssistantMessage("early work ".repeat(20))); + const keptAssistant = createMessageEntry(createAssistantMessage("kept")); + const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 }; + + const preparation = prepareCompaction([user, earlyAssistant, keptAssistant], settings); + + expect(preparation).toBeDefined(); + requireSourceMessages(preparation); + expect(preparation.isSplitTurn).toBe(true); + expect(preparation.messagesToSummarize).toEqual([]); + expect(preparation.sourceMessages).toEqual([]); + expect(preparation.turnPrefixSourceMessages).toEqual(preparation.turnPrefixMessages); + expect(extractText(preparation.turnPrefixSourceMessages)).toContain("large request"); + expect(extractText(preparation.turnPrefixSourceMessages)).not.toContain("kept"); + }); +}); + describe("prepareCompaction with previous compaction", () => { it("should skip repeated compactions when kept messages still fit", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)")); @@ -479,6 +529,33 @@ describe("prepareCompaction with previous compaction", () => { expect(preparation).toBeUndefined(); }); + it("preserves retained older summaries in the active source prefix", () => { + const u1 = createMessageEntry(createUserMessage("user 1")); + const a1 = createMessageEntry(createAssistantMessage("assistant 1")); + const u2 = createMessageEntry(createUserMessage("user 2 ".repeat(20))); + const a2 = createMessageEntry(createAssistantMessage("assistant 2 ".repeat(20))); + const compaction1 = createCompactionEntry("First summary", u1.id); + const u3 = createMessageEntry(createUserMessage("user 3 ".repeat(20))); + const a3 = createMessageEntry(createAssistantMessage("assistant 3 ".repeat(20))); + const compaction2 = createCompactionEntry("Second summary", u2.id); + const u4 = createMessageEntry(createUserMessage("user 4 ".repeat(20))); + const a4 = createMessageEntry(createAssistantMessage("kept")); + const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 }; + const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3, compaction2, u4, a4]; + + const preparation = prepareCompaction(pathEntries, settings); + + expect(preparation).toBeDefined(); + requireSourceMessages(preparation); + const activeMessages = buildSessionContext(pathEntries).messages; + expect(preparation.sourceMessages).toEqual(activeMessages.slice(0, preparation.sourceMessages.length)); + expect(preparation.turnPrefixSourceMessages).toEqual( + activeMessages.slice(0, preparation.turnPrefixSourceMessages.length), + ); + const sourceSummaries = preparation.sourceMessages.filter((message) => message.role === "compactionSummary"); + expect(sourceSummaries.map((message) => message.summary)).toEqual(["Second summary", "First summary"]); + }); + it("should re-summarize previously kept messages when the recent window moves past them", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)".repeat(4))); const a1 = createMessageEntry(createAssistantMessage("assistant msg 1".repeat(4))); @@ -497,11 +574,15 @@ describe("prepareCompaction with previous compaction", () => { const preparation = prepareCompaction([u1, a1, u2, a2, u3, a3, compaction1, u4, a4], settings); expect(preparation).toBeDefined(); - const summarizedText = extractText(preparation!.messagesToSummarize); + requireSourceMessages(preparation); + const summarizedText = extractText(preparation.messagesToSummarize); expect(summarizedText).toContain("user msg 2 - kept by compaction1"); expect(summarizedText).toContain("user msg 3 - kept by compaction1"); expect(summarizedText).not.toContain("First summary"); - expect(preparation!.previousSummary).toBe("First summary"); + expect(preparation.previousSummary).toBe("First summary"); + expect(preparation.sourceMessages[0]?.role).toBe("compactionSummary"); + expect(extractText(preparation.sourceMessages)).toContain("First summary"); + expect(preparation.sourceMessages.slice(1)).toEqual(preparation.messagesToSummarize); }); }); From a6c6f801802a7083609541dbd49eec203804d8fc Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:28:12 +0200 Subject: [PATCH 209/284] fix(ai): anthropic fallback usage (#8308) * fix(ai): anthropic fallback usage * changeloggg --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/anthropic-messages.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b4de62a0072..e15dab0cfea 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). - Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. - Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index f0e89a5583f..f96d940523b 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -8,6 +8,7 @@ import type { RefusalStopDetails, } from "@anthropic-ai/sdk/resources/messages.js"; import { calculateCost } from "../models.ts"; +import { ANTHROPIC_MODELS } from "../providers/anthropic.models.ts"; import type { AnthropicMessagesCompat, AnthropicRefusalFallback, @@ -540,6 +541,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( try { let client: Anthropic; let isOAuth: boolean; + let usageModel = model; if (options?.client) { client = options.client; @@ -602,6 +604,11 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( if (event.type === "message_start") { output.responseId = event.message.id; output.model = event.message.model; + usageModel = + model.provider === "anthropic" + ? ((ANTHROPIC_MODELS as Record | undefined>)[output.model] ?? + model) + : model; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early output.usage.input = event.message.usage.input_tokens || 0; @@ -612,7 +619,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; - calculateCost(model, output.usage); + calculateCost(usageModel, output.usage); } else if (event.type === "content_block_start") { if (event.content_block.type === "text") { const block: Block = { @@ -769,7 +776,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; - calculateCost(model, output.usage); + calculateCost(usageModel, output.usage); } } From b237412699fa33773921d295d2af14bf3031315e Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 18 Aug 2026 17:35:35 +0300 Subject: [PATCH 210/284] feat(ai): generalize openai-completions thinking token budget fields (#8275) #7638 hardcoded vLLM's thinking_token_budget. Map thinkingBudgets through thinkingTokenBudgetField, keep the boolean as a vLLM alias, clamp so the answer keeps 1024 tokens, and document it. fixes #8274 Co-authored-by: Cursor Co-authored-by: Mario Zechner --- packages/ai/README.md | 6 +- packages/ai/scripts/generate-models.ts | 7 +- packages/ai/src/api/openai-completions.ts | 71 ++++++---- packages/ai/src/api/simple-options.ts | 31 +++-- packages/ai/src/types.ts | 19 ++- ...penai-completions-thinking-as-text.test.ts | 7 +- ...-completions-thinking-token-budget.test.ts | 130 ++++++++++++++---- ...nai-completions-tool-result-images.test.ts | 4 +- packages/coding-agent/docs/custom-provider.md | 7 +- packages/coding-agent/docs/models.md | 10 +- packages/coding-agent/docs/settings.md | 2 +- 11 files changed, 218 insertions(+), 76 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index e010fdf2589..dde9c83b48f 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1183,8 +1183,10 @@ interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'baseten' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'baseten' uses configurable chat_template_args plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai) - chatTemplateKwargs?: Record; // chat_template_kwargs values; use $var for pi-controlled thinking values - chatTemplateArgs?: Record; // chat_template_args values for thinkingFormat: 'baseten'; use $var for pi-controlled thinking values + chatTemplateKwargs?: Record; // chat_template_kwargs values; use $var for pi-controlled thinking values + chatTemplateArgs?: Record; // chat_template_args values for thinkingFormat: 'baseten'; use $var for pi-controlled thinking values + thinkingTokenBudgetField?: 'thinking_token_budget' | 'thinking_budget' | 'thinking_budget_tokens'; // Top-level field that caps reasoning tokens from thinkingBudgets (vLLM / Qwen / llama.cpp). Off by default. + supportsThinkingTokenBudget?: boolean; // Alias for thinkingTokenBudgetField: 'thinking_token_budget' (vLLM). Prefer thinkingTokenBudgetField. Default: false. cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 2b09d9bd03e..418b0a6d96c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -594,7 +594,12 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = { supportsOpenAIGrammarTools: false, sendSessionAffinityHeaders: false, supportsLongCacheRetention: true, -} satisfies Required> & { +} satisfies Required< + Omit< + OpenAICompletionsCompat, + "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField" + > +> & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; }; diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index d261e82a407..8d968521a3e 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -30,6 +30,7 @@ import type { TextContent, ThinkingBudgets, ThinkingContent, + ThinkingTokenBudgetField, Tool, ToolCall, ToolResultMessage, @@ -54,7 +55,7 @@ import { } from "./constrained-sampling.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; -import { buildBaseOptions, clampReasoning, MIN_ANSWER_TOKENS } from "./simple-options.ts"; +import { buildBaseOptions, clampThinkingBudgetToAnswerRoom, thinkingBudgetForLevel } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; /** @@ -144,7 +145,7 @@ function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedR export interface OpenAICompletionsOptions extends StreamOptions { toolChoice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; - /** Token budgets per thinking level. Only used when `compat.supportsThinkingTokenBudget` is set. */ + /** Token budgets per thinking level. Used when `compat.thinkingTokenBudgetField` or `compat.supportsThinkingTokenBudget` is set, or by `{ "$var": "thinking.budget" }`. */ thinkingBudgets?: ThinkingBudgets; } @@ -159,11 +160,12 @@ interface OpenAICompatCacheControl { type ResolvedOpenAICompletionsCompat = Omit< Required, - "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" + "cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField" > & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; supportsThinkingTokenBudget?: OpenAICompletionsCompat["supportsThinkingTokenBudget"]; + thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; }; type ResolvedChatTemplateKwargValue = string | number | boolean | null; @@ -752,6 +754,9 @@ function buildParams( params.tool_choice = options.toolChoice; } + const thinkingTokenBudgetField = resolveThinkingTokenBudgetField(compat); + const thinkingBudget = resolveClampedThinkingBudget(model, options, params); + if (compat.thinkingFormat === "zai" && model.reasoning) { const zaiParams = params as Omit & { thinking?: { type: "enabled" | "disabled"; clear_thinking?: boolean }; @@ -779,7 +784,7 @@ function buildParams( preserve_thinking: true, }; } else if (compat.thinkingFormat === "chat-template" && model.reasoning) { - const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs); + const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs, thinkingBudget); if (chatTemplateKwargs) { (params as any).chat_template_kwargs = chatTemplateKwargs; } @@ -788,7 +793,7 @@ function buildParams( chat_template_args?: Record; reasoning_effort?: string; }; - const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs); + const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs, thinkingBudget); if (chatTemplateArgs) { basetenParams.chat_template_args = chatTemplateArgs; } @@ -851,25 +856,12 @@ function buildParams( } } - // vLLM caps reasoning with a top-level thinking_token_budget. Independent of - // thinkingFormat: the same server can serve zai, qwen or chat-template models. - // Reasoning and the answer share max_tokens here, so an uncapped reasoning - // phase can consume the whole response and leave no answer and no tool call. - if (compat.supportsThinkingTokenBudget && options?.reasoningEffort && model.reasoning) { - const level = clampReasoning(options.reasoningEffort)!; - const budgets: ThinkingBudgets = { - minimal: 1024, - low: 2048, - medium: 8192, - high: 16384, - ...options.thinkingBudgets, - }; - const ceiling = (params as { max_tokens?: number }).max_tokens ?? params.max_completion_tokens ?? model.maxTokens; - // Always leave room for the answer, otherwise the budget recreates the bug it prevents. - const budget = Math.min(budgets[level]!, Math.max(0, ceiling - MIN_ANSWER_TOKENS)); - if (budget > 0) { - (params as { thinking_token_budget?: number }).thinking_token_budget = budget; - } + // Cap reasoning with a top-level budget field. Independent of thinkingFormat: the + // same server can serve zai, qwen or chat-template models. Reasoning and the answer + // share max_tokens here, so an uncapped reasoning phase can consume the whole + // response and leave no answer and no tool call. + if (thinkingTokenBudgetField && thinkingBudget !== undefined) { + Object.assign(params, { [thinkingTokenBudgetField]: thinkingBudget }); } // OpenRouter provider routing preferences @@ -896,15 +888,38 @@ function buildParams( return params; } +function resolveThinkingTokenBudgetField( + compat: Pick, +): ThinkingTokenBudgetField | undefined { + if (compat.thinkingTokenBudgetField) return compat.thinkingTokenBudgetField; + if (compat.supportsThinkingTokenBudget) return "thinking_token_budget"; + return undefined; +} + +function resolveClampedThinkingBudget( + model: Model<"openai-completions">, + options: OpenAICompletionsOptions | undefined, + params: { max_tokens?: number | null; max_completion_tokens?: number | null }, +): number | undefined { + if (!options?.reasoningEffort || !model.reasoning) return undefined; + const ceiling = params.max_tokens ?? params.max_completion_tokens ?? model.maxTokens; + const budget = clampThinkingBudgetToAnswerRoom( + thinkingBudgetForLevel(options.reasoningEffort, options.thinkingBudgets), + ceiling, + ); + return budget > 0 ? budget : undefined; +} + function buildChatTemplateValues( model: Model<"openai-completions">, options: OpenAICompletionsOptions | undefined, values: Record, + thinkingBudget?: number, ): Record | undefined { const resolvedValues: Record = {}; for (const [key, value] of Object.entries(values)) { - const resolved = resolveChatTemplateKwargValue(model, options, value); + const resolved = resolveChatTemplateKwargValue(model, options, value, thinkingBudget); if (resolved !== undefined) { resolvedValues[key] = resolved; } @@ -917,6 +932,7 @@ function resolveChatTemplateKwargValue( model: Model<"openai-completions">, options: OpenAICompletionsOptions | undefined, value: ChatTemplateKwargValue, + thinkingBudget?: number, ): ResolvedChatTemplateKwargValue | undefined { if (typeof value !== "object" || value === null) { return value; @@ -929,6 +945,9 @@ function resolveChatTemplateKwargValue( if (value.$var === "thinking.enabled") { return !!reasoningEffort; } + if (value.$var === "thinking.budget") { + return thinkingBudget; + } const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off; return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined; @@ -1532,6 +1551,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet chatTemplateArgs: {}, zaiToolStream: false, supportsThinkingTokenBudget: false, + thinkingTokenBudgetField: undefined, supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsOpenAIGrammarTools: false, cacheControlFormat, @@ -1577,6 +1597,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion chatTemplateArgs: model.compat.chatTemplateArgs ?? detected.chatTemplateArgs, zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream, supportsThinkingTokenBudget: model.compat.supportsThinkingTokenBudget ?? detected.supportsThinkingTokenBudget, + thinkingTokenBudgetField: model.compat.thinkingTokenBudgetField ?? detected.thinkingTokenBudgetField, supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools, cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, diff --git a/packages/ai/src/api/simple-options.ts b/packages/ai/src/api/simple-options.ts index 067d01e5951..2a30265ad25 100644 --- a/packages/ai/src/api/simple-options.ts +++ b/packages/ai/src/api/simple-options.ts @@ -54,10 +54,28 @@ export function buildBaseOptions( /** Tokens always left for the answer when a thinking budget shares the response ceiling. */ export const MIN_ANSWER_TOKENS = 1024; +export const DEFAULT_THINKING_BUDGETS: ThinkingBudgets = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, +}; + export function clampReasoning(effort: ThinkingLevel | undefined): Exclude | undefined { return effort === "xhigh" || effort === "max" ? "high" : effort; } +export function thinkingBudgetForLevel(reasoningLevel: ThinkingLevel, customBudgets?: ThinkingBudgets): number { + const budgets = { ...DEFAULT_THINKING_BUDGETS, ...customBudgets }; + const level = clampReasoning(reasoningLevel)!; + return budgets[level]!; +} + +/** Cap a thinking budget so at least MIN_ANSWER_TOKENS remain under a shared response ceiling. */ +export function clampThinkingBudgetToAnswerRoom(thinkingBudget: number, ceiling: number): number { + return Math.min(thinkingBudget, Math.max(0, ceiling - MIN_ANSWER_TOKENS)); +} + export function adjustMaxTokensForThinking( // Undefined means no explicit caller cap. Use the model cap and fit thinking inside it. baseMaxTokens: number | undefined, @@ -65,21 +83,12 @@ export function adjustMaxTokensForThinking( reasoningLevel: ThinkingLevel, customBudgets?: ThinkingBudgets, ): { maxTokens: number; thinkingBudget: number } { - const defaultBudgets: ThinkingBudgets = { - minimal: 1024, - low: 2048, - medium: 8192, - high: 16384, - }; - const budgets = { ...defaultBudgets, ...customBudgets }; - - const level = clampReasoning(reasoningLevel)!; - let thinkingBudget = budgets[level]!; + let thinkingBudget = thinkingBudgetForLevel(reasoningLevel, customBudgets); const maxTokens = baseMaxTokens === undefined ? modelMaxTokens : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens); if (maxTokens <= thinkingBudget) { - thinkingBudget = Math.max(0, maxTokens - MIN_ANSWER_TOKENS); + thinkingBudget = clampThinkingBudgetToAnswerRoom(thinkingBudget, maxTokens); } return { maxTokens, thinkingBudget }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 832222133b4..e7df833d504 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -89,10 +89,13 @@ export type ChatTemplateKwargValue = | boolean | null | { - $var: "thinking.enabled" | "thinking.effort"; + $var: "thinking.enabled" | "thinking.effort" | "thinking.budget"; omitWhenOff?: boolean; }; +/** Top-level request field used to cap reasoning tokens on OpenAI-compatible servers. */ +export type ThinkingTokenBudgetField = "thinking_token_budget" | "thinking_budget" | "thinking_budget_tokens"; + /** Token budgets for each thinking level (token-based providers only) */ export interface ThinkingBudgets { minimal?: number; @@ -583,9 +586,9 @@ export interface OpenAICompletionsCompat { | "qwen-chat-template" | "string-thinking" | "ant-ling"; - /** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */ + /** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values. */ chatTemplateKwargs?: Record; - /** Arguments to send as `chat_template_args` when `thinkingFormat` is `baseten`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */ + /** Arguments to send as `chat_template_args` when `thinkingFormat` is `baseten`. Use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values. */ chatTemplateArgs?: Record; /** OpenRouter-compatible routing preferences sent as the `provider` request field. */ openRouterRouting?: OpenRouterRouting; @@ -593,7 +596,15 @@ export interface OpenAICompletionsCompat { vercelGatewayRouting?: VercelGatewayRouting; /** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */ zaiToolStream?: boolean; - /** Whether the provider supports top-level `thinking_token_budget` to cap reasoning tokens (vLLM). Reasoning and the answer share `max_tokens` on these endpoints, so without a budget a reasoning-heavy turn can consume the whole response and emit no answer. Default: false. */ + /** + * Top-level request field used to cap reasoning tokens from `thinkingBudgets`. + * Reasoning and the answer share `max_tokens` on these endpoints, so without a budget a + * reasoning-heavy turn can consume the whole response and emit no answer. + * `"thinking_token_budget"` is vLLM, `"thinking_budget"` is Qwen/DashScope/SGLang, + * `"thinking_budget_tokens"` is llama.cpp. Off by default; not set on the generated catalog. + */ + thinkingTokenBudgetField?: ThinkingTokenBudgetField; + /** Alias for `thinkingTokenBudgetField: "thinking_token_budget"` (vLLM). Prefer `thinkingTokenBudgetField`. Default: false. */ supportsThinkingTokenBudget?: boolean; /** Whether the provider supports OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */ supportsOpenAIGrammarTools?: boolean; diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index 2e451f6b715..44e14416b31 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -39,15 +39,20 @@ const compat = { chatTemplateArgs: {}, zaiToolStream: false, supportsThinkingTokenBudget: false, + thinkingTokenBudgetField: undefined, supportsStrictMode: true, supportsOpenAIGrammarTools: false, cacheControlFormat: undefined, sendSessionAffinityHeaders: false, sessionAffinityFormat: "openai", supportsLongCacheRetention: true, -} satisfies Omit, "cacheControlFormat" | "deferredToolsMode"> & { +} satisfies Omit< + Required, + "cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField" +> & { cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; + thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; }; function buildModel(baseUrl = "http://127.0.0.1:1"): Model<"openai-completions"> { diff --git a/packages/ai/test/openai-completions-thinking-token-budget.test.ts b/packages/ai/test/openai-completions-thinking-token-budget.test.ts index 2ca0c3ce03e..3f5cfa4ea5d 100644 --- a/packages/ai/test/openai-completions-thinking-token-budget.test.ts +++ b/packages/ai/test/openai-completions-thinking-token-budget.test.ts @@ -41,21 +41,35 @@ vi.mock("openai", () => { return { default: FakeOpenAI }; }); -// vLLM-served reasoning model: reasoning and the answer share max_tokens. -const vllmModel: Model<"openai-completions"> = { - id: "zai-org/glm-5.2", - name: "GLM 5.2 (local vLLM)", - api: "openai-completions", - provider: "local-vllm", - baseUrl: "http://localhost:8000/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 262144, - maxTokens: 16384, - compat: { thinkingFormat: "zai", supportsThinkingTokenBudget: true }, +type CapturedParams = { + thinking_token_budget?: number; + thinking_budget?: number; + thinking_budget_tokens?: number; + thinking?: unknown; + chat_template_kwargs?: Record; }; +function vllmModel( + compat: Model<"openai-completions">["compat"] = { + thinkingFormat: "zai", + supportsThinkingTokenBudget: true, + }, +): Model<"openai-completions"> { + return { + id: "zai-org/glm-5.2", + name: "GLM 5.2 (local vLLM)", + api: "openai-completions", + provider: "local-vllm", + baseUrl: "http://localhost:8000/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 16384, + compat, + }; +} + async function capture( model: Model<"openai-completions">, options?: { @@ -63,7 +77,7 @@ async function capture( thinkingBudgets?: ThinkingBudgets; maxTokens?: number; }, -): Promise<{ thinking_token_budget?: number; thinking?: unknown }> { +): Promise { let payload: unknown; await streamSimple( @@ -80,45 +94,109 @@ async function capture( }, ).result(); - return (payload ?? mockState.lastParams) as { thinking_token_budget?: number; thinking?: unknown }; + return (payload ?? mockState.lastParams) as CapturedParams; } -describe("openai-completions thinking_token_budget", () => { +describe("openai-completions thinking token budget", () => { beforeEach(() => { mockState.lastParams = undefined; }); it("sends the configured budget for the requested level", async () => { - const params = await capture(vllmModel, { reasoning: "medium", thinkingBudgets: { medium: 4096 } }); + const params = await capture(vllmModel(), { reasoning: "medium", thinkingBudgets: { medium: 4096 } }); expect(params.thinking_token_budget).toBe(4096); }); - it("omits the budget when the compat flag is not set", async () => { - const model = { ...vllmModel, compat: { thinkingFormat: "zai" } } as Model<"openai-completions">; - const params = await capture(model, { reasoning: "medium", thinkingBudgets: { medium: 4096 } }); + it("omits the budget when neither the field nor the alias is set", async () => { + const params = await capture(vllmModel({ thinkingFormat: "zai" }), { + reasoning: "medium", + thinkingBudgets: { medium: 4096 }, + }); expect(params.thinking_token_budget).toBeUndefined(); + expect(params.thinking_budget).toBeUndefined(); + expect(params.thinking_budget_tokens).toBeUndefined(); }); it("omits the budget when thinking is off", async () => { - const params = await capture(vllmModel, { reasoning: undefined, thinkingBudgets: { high: 8192 } }); + const params = await capture(vllmModel(), { reasoning: undefined, thinkingBudgets: { high: 8192 } }); expect(params.thinking_token_budget).toBeUndefined(); }); it("clamps xhigh and max to the high budget", async () => { - const xhigh = await capture(vllmModel, { reasoning: "xhigh", thinkingBudgets: { high: 8192 } }); - const max = await capture(vllmModel, { reasoning: "max", thinkingBudgets: { high: 8192 } }); + const xhigh = await capture(vllmModel(), { reasoning: "xhigh", thinkingBudgets: { high: 8192 } }); + const max = await capture(vllmModel(), { reasoning: "max", thinkingBudgets: { high: 8192 } }); expect(xhigh.thinking_token_budget).toBe(8192); expect(max.thinking_token_budget).toBe(8192); }); it("leaves room for the answer when the budget meets the response ceiling", async () => { - // Default high budget (16384) equals the model ceiling, which would leave no answer. - const params = await capture(vllmModel, { reasoning: "high" }); + const params = await capture(vllmModel(), { reasoning: "high" }); expect(params.thinking_token_budget).toBe(16384 - 1024); }); it("uses the caller max_tokens as the ceiling when it is lower than the model cap", async () => { - const params = await capture(vllmModel, { reasoning: "high", thinkingBudgets: { high: 8192 }, maxTokens: 4096 }); + const params = await capture(vllmModel(), { + reasoning: "high", + thinkingBudgets: { high: 8192 }, + maxTokens: 4096, + }); expect(params.thinking_token_budget).toBe(4096 - 1024); }); + + it.each(["thinking_budget", "thinking_budget_tokens"] as const)( + "sends %s when thinkingTokenBudgetField is set", + async (field) => { + const params = await capture(vllmModel({ thinkingFormat: "qwen", thinkingTokenBudgetField: field }), { + reasoning: "medium", + thinkingBudgets: { medium: 4096 }, + }); + expect(params[field]).toBe(4096); + expect(params.thinking_token_budget).toBeUndefined(); + }, + ); + + it("lets thinkingTokenBudgetField win over the boolean alias", async () => { + const params = await capture( + vllmModel({ + thinkingFormat: "zai", + supportsThinkingTokenBudget: true, + thinkingTokenBudgetField: "thinking_budget", + }), + { reasoning: "medium", thinkingBudgets: { medium: 4096 } }, + ); + expect(params.thinking_budget).toBe(4096); + expect(params.thinking_token_budget).toBeUndefined(); + }); + + it("puts the clamped budget in chat_template_kwargs when $var is thinking.budget", async () => { + const params = await capture( + vllmModel({ + thinkingFormat: "chat-template", + chatTemplateKwargs: { + enable_thinking: { $var: "thinking.enabled" }, + thinking_budget: { $var: "thinking.budget" }, + }, + }), + { reasoning: "high" }, + ); + expect(params.chat_template_kwargs).toEqual({ + enable_thinking: true, + thinking_budget: 16384 - 1024, + }); + expect(params.thinking_token_budget).toBeUndefined(); + }); + + it("omits thinking.budget from chat_template_kwargs when thinking is off", async () => { + const params = await capture( + vllmModel({ + thinkingFormat: "chat-template", + chatTemplateKwargs: { + enable_thinking: { $var: "thinking.enabled" }, + thinking_budget: { $var: "thinking.budget" }, + }, + }), + { reasoning: undefined }, + ); + expect(params.chat_template_kwargs).toEqual({ enable_thinking: false }); + }); }); diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 6beb7f284e1..de692a66518 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -19,8 +19,9 @@ const emptyUsage: Usage = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; -const compat: Omit, "deferredToolsMode"> & { +const compat: Omit, "deferredToolsMode" | "thinkingTokenBudgetField"> & { deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; + thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; } = { supportsStore: true, supportsDeveloperRole: true, @@ -39,6 +40,7 @@ const compat: Omit, "deferredToolsMode"> & { chatTemplateArgs: {}, zaiToolStream: false, supportsThinkingTokenBudget: false, + thinkingTokenBudgetField: undefined, supportsStrictMode: true, supportsOpenAIGrammarTools: false, cacheControlFormat: "anthropic", diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 858b992eff6..cdf823eb08f 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -752,8 +752,10 @@ interface ProviderModelConfig { requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "baseten" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling"; - chatTemplateKwargs?: Record; - chatTemplateArgs?: Record; + chatTemplateKwargs?: Record; + chatTemplateArgs?: Record; + thinkingTokenBudgetField?: "thinking_token_budget" | "thinking_budget" | "thinking_budget_tokens"; + supportsThinkingTokenBudget?: boolean; cacheControlFormat?: "anthropic"; sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter"; sendSessionAffinityHeaders?: boolean; @@ -771,4 +773,5 @@ interface ProviderModelConfig { ``` `openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`. Use `thinkingFormat: "baseten"` with `chatTemplateArgs` when the provider expects toggle values under `chat_template_args` and optionally supports top-level `reasoning_effort`. +`thinkingTokenBudgetField` sends a clamped per-level thinking budget as a top-level request field (`thinking_token_budget` on vLLM, `thinking_budget` on Qwen/SGLang, `thinking_budget_tokens` on llama.cpp). `supportsThinkingTokenBudget: true` is an alias for the vLLM field name. Do not combine it with `reasoning_effort` on DashScope Qwen models. `cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 53c702e137b..76a5bf291f7 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -254,6 +254,8 @@ Current behavior: Only OpenAI-compatible APIs apply it (`openai-completions`, `openai-responses`, `azure-openai-responses`); other APIs ignore it. Keys override pi's named request fields (for example a `temperature` key here beats the request-level temperature), so prefer it as the single source of sampling truth for a model. In `modelOverrides`, `samplingParams` merges per key with the base model's value. +A constant thinking-token cap can go here too, but it will not follow `thinkingBudgets` or leave room for the answer. Prefer `compat.thinkingTokenBudgetField` (or the `supportsThinkingTokenBudget` alias) for that. + ### Thinking Level Map Use `thinkingLevelMap` on a model to describe model-specific thinking controls. Keys are pi thinking levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Maps may contain holes; for example, a model can expose `high` and `max` without exposing `xhigh`. @@ -467,8 +469,10 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `requiresThinkingAsText` | Convert thinking blocks to plain text | | `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled | | `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `baseten`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters | -| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | -| `chatTemplateArgs` | `chat_template_args` values for `thinkingFormat: "baseten"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values | +| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values | +| `chatTemplateArgs` | `chat_template_args` values for `thinkingFormat: "baseten"`; use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values | +| `thinkingTokenBudgetField` | Top-level request field used to cap reasoning tokens from `thinkingBudgets`, clamped so at least 1024 tokens remain for the answer. `"thinking_token_budget"` (vLLM), `"thinking_budget"` (Qwen/DashScope/SGLang), `"thinking_budget_tokens"` (llama.cpp). Off by default; not set on the generated catalog. | +| `supportsThinkingTokenBudget` | Alias for `thinkingTokenBudgetField: "thinking_token_budget"` (vLLM). Prefer `thinkingTokenBudgetField`. Default: `false`. | | `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result text content. Currently only `anthropic` is supported. | | `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. | | `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. | @@ -481,6 +485,8 @@ For providers with partial OpenAI compatibility, use the `compat` field. `openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking` and `preserve_thinking`. Use `chat-template` for vLLM/Hugging Face chat templates that need configurable `chat_template_kwargs`, such as `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }` for DeepSeek V3.x templates. Use `thinkingFormat: "baseten"` with `chatTemplateArgs` for providers that expose toggle controls through `chat_template_args` and optionally support top-level `reasoning_effort`. +`thinkingTokenBudgetField` is independent of `thinkingFormat`. Do not enable it on the generated Qwen catalog: those models already send `reasoning_effort`, and DashScope rejects `thinking_budget` together with `reasoning_effort`. + `cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions. Example: diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index c56340b1d12..b2339284353 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -32,7 +32,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `defaultThinkingLevel` | string | - | `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | | `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output | | `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses and compaction or branch-summary usage | -| `thinkingBudgets` | object | - | Custom token budgets per thinking level | +| `thinkingBudgets` | object | - | Custom token budgets per thinking level. Anthropic, Google, and Bedrock use these natively. OpenAI-compatible models use them when `compat.thinkingTokenBudgetField` (or `supportsThinkingTokenBudget`) is set. | #### thinkingBudgets From c0613289814c54a28c649add556f509d9c79714a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 18 Aug 2026 16:29:26 +0200 Subject: [PATCH 211/284] fix(coding-agent): load extensions in Node SEA hosts, closes #8237 --- .../src/core/extensions/loader.ts | 13 +++-- .../8237-node-sea-extension-loading.test.ts | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/8237-node-sea-extension-loading.test.ts diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 74d38f12cbc..2f00807f29c 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -46,7 +46,7 @@ import type { ToolDefinition, } from "./types.ts"; -/** Modules available to extensions via virtualModules (for compiled Bun binary) */ +/** Modules available to extensions via virtualModules (for compiled binaries) */ const VIRTUAL_MODULES: Record = { typebox: _bundledTypebox, "typebox/compile": _bundledTypeboxCompile, @@ -75,11 +75,14 @@ const VIRTUAL_MODULES: Record = { const require = createRequire(import.meta.url); +const isNodeSeaBinary = + ("sea" in process.features && process.features.sea === true) || + process.getBuiltinModule("node:sea")?.isSea() === true; const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts"; /** * Get aliases for jiti (used in built Node.js mode). - * In Bun binary mode, virtualModules is used instead. + * In compiled binary mode, virtualModules is used instead. */ let _aliases: Record | null = null; @@ -448,9 +451,9 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension const jiti = createJiti(import.meta.url, { moduleCache: false, - // Bun uses modules embedded in the executable. Source TypeScript reuses the - // host-resolved modules and root tsconfig paths. Built Node uses dist aliases. - ...(isBunBinary + // Compiled binaries use modules embedded in the executable. Source TypeScript + // reuses host modules and root tsconfig paths. Built Node uses dist aliases. + ...(isBunBinary || isNodeSeaBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true } diff --git a/packages/coding-agent/test/suite/regressions/8237-node-sea-extension-loading.test.ts b/packages/coding-agent/test/suite/regressions/8237-node-sea-extension-loading.test.ts new file mode 100644 index 00000000000..160bf7dc3d5 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8237-node-sea-extension-loading.test.ts @@ -0,0 +1,50 @@ +import { afterAll, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => { + const originalGetBuiltinModule = Object.getOwnPropertyDescriptor(process, "getBuiltinModule"); + const getBuiltinModule = process.getBuiltinModule.bind(process); + Object.defineProperty(process, "getBuiltinModule", { + configurable: true, + value: (id: string) => (id === "node:sea" ? { isSea: () => true } : getBuiltinModule(id)), + }); + return { + originalGetBuiltinModule, + createJiti: vi.fn((_id: unknown, _options: unknown) => ({ + import: vi.fn(async () => () => {}), + })), + }; +}); + +vi.mock("jiti/static", () => ({ createJiti: state.createJiti })); + +import { loadExtensions } from "../../../src/core/extensions/loader.ts"; + +interface JitiOptionsProbe { + alias?: unknown; + tryNative?: boolean; + virtualModules?: Record; +} + +afterAll(() => { + if (state.originalGetBuiltinModule) { + Object.defineProperty(process, "getBuiltinModule", state.originalGetBuiltinModule); + } +}); + +describe("Node SEA extension loading", () => { + it("uses bundled virtual modules instead of filesystem aliases", async () => { + const result = await loadExtensions(["/extension.ts"], "/"); + + expect(result.errors).toEqual([]); + expect(result.extensions).toHaveLength(1); + expect(state.createJiti).toHaveBeenCalledOnce(); + + const options = state.createJiti.mock.calls[0][1] as JitiOptionsProbe; + // Source TypeScript also uses virtual modules, so tryNative: false is what + // proves the compiled-binary branch took precedence over the source branch. + expect(options.tryNative).toBe(false); + expect(options.alias).toBeUndefined(); + expect(options.virtualModules?.typebox).toBeDefined(); + expect(options.virtualModules?.["@earendil-works/pi-coding-agent"]).toBeDefined(); + }); +}); From f0c5d86d20f85bbeee957b0e9b06ee085ea40c80 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 18 Aug 2026 16:49:14 +0200 Subject: [PATCH 212/284] fix(tui): fit text padding to narrow widths, closes #8252 --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/text.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6c0416351b7..33dfa5812db 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). +- Fixed padded text exceeding narrow terminal widths ([#8252](https://github.com/earendil-works/pi/issues/8252)). ## [0.84.2] - 2026-08-14 diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 3809a48a86f..7a50e272102 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -60,15 +60,16 @@ export class Text implements Component { // Replace tabs with 3 spaces const normalizedText = this.text.replace(/\t/g, " "); - // Calculate content width (subtract left/right margins) - const contentWidth = Math.max(1, width - this.paddingX * 2); + // Reduce margins when necessary so content and padding fit within the available width. + const paddingX = Math.min(this.paddingX, Math.max(0, Math.floor((width - 1) / 2))); + const contentWidth = Math.max(1, width - paddingX * 2); // Wrap text (this preserves ANSI codes but does NOT pad) const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth); // Add margins and background to each line - const leftMargin = " ".repeat(this.paddingX); - const rightMargin = " ".repeat(this.paddingX); + const leftMargin = " ".repeat(paddingX); + const rightMargin = " ".repeat(paddingX); const contentLines: string[] = []; for (const line of wrappedLines) { From 59a71b235dadb4ad0d67557a8abb0aaa093e68b4 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:10:35 +0200 Subject: [PATCH 213/284] Revert "fix(ai): anthropic fallback usage (#8308)" (#8313) This reverts commit a6c6f801802a7083609541dbd49eec203804d8fc. --- packages/ai/CHANGELOG.md | 1 - packages/ai/src/api/anthropic-messages.ts | 11 ++--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e15dab0cfea..b4de62a0072 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,7 +13,6 @@ ### Fixed -- Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). - Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. - Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index f96d940523b..f0e89a5583f 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -8,7 +8,6 @@ import type { RefusalStopDetails, } from "@anthropic-ai/sdk/resources/messages.js"; import { calculateCost } from "../models.ts"; -import { ANTHROPIC_MODELS } from "../providers/anthropic.models.ts"; import type { AnthropicMessagesCompat, AnthropicRefusalFallback, @@ -541,7 +540,6 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( try { let client: Anthropic; let isOAuth: boolean; - let usageModel = model; if (options?.client) { client = options.client; @@ -604,11 +602,6 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( if (event.type === "message_start") { output.responseId = event.message.id; output.model = event.message.model; - usageModel = - model.provider === "anthropic" - ? ((ANTHROPIC_MODELS as Record | undefined>)[output.model] ?? - model) - : model; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early output.usage.input = event.message.usage.input_tokens || 0; @@ -619,7 +612,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; - calculateCost(usageModel, output.usage); + calculateCost(model, output.usage); } else if (event.type === "content_block_start") { if (event.content_block.type === "text") { const block: Block = { @@ -776,7 +769,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; - calculateCost(usageModel, output.usage); + calculateCost(model, output.usage); } } From 4809c2abcaa86257337aa9a44801f4af91144dbc Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:11:45 +0200 Subject: [PATCH 214/284] fix(ai): anthropic fallback usage (#8319) * fix: anthropic fallback usage * small cleanup --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 27 ++++++++++++++++++- packages/ai/src/api/anthropic-messages.ts | 18 ++++++++++--- packages/ai/src/types.ts | 10 +++++-- .../src/core/compaction/compaction.ts | 4 +-- .../test/compaction-summary-reasoning.test.ts | 10 +++++-- 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b4de62a0072..e15dab0cfea 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). - Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. - Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 418b0a6d96c..d376149dcb0 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -744,6 +744,30 @@ function applyAnthropicMessagesCompatMetadata(model: Model): void { } } +function isAnthropicFallbackMetadataModel(model: Model): model is Model<"anthropic-messages"> { + if (model.provider !== "anthropic" || model.api !== "anthropic-messages") return false; + return ( + model.id in ANTHROPIC_ALLOWED_FALLBACK_MODELS || + Object.values(ANTHROPIC_ALLOWED_FALLBACK_MODELS).some((fallbackModelIds) => fallbackModelIds.includes(model.id)) + ); +} + +function applyAnthropicFallbackCostMetadata(models: readonly Model<"anthropic-messages">[]): void { + const modelsById = new Map(models.map((model) => [model.id, model])); + for (const [modelId, fallbackModelIds] of Object.entries(ANTHROPIC_ALLOWED_FALLBACK_MODELS)) { + const model = modelsById.get(modelId); + if (!model?.compat?.allowedFallbackModels) continue; + + for (const fallbackModelId of fallbackModelIds) { + const fallback = model.compat.allowedFallbackModels.find((target) => target.model === fallbackModelId); + const fallbackModel = modelsById.get(fallbackModelId); + if (fallback && fallbackModel) { + fallback.cost = fallbackModel.cost; + } + } + } +} + function applyStrictToolCompatMetadata(model: Model): void { if ( (model.provider === "openai" || model.provider === "cloudflare-ai-gateway") && @@ -964,7 +988,7 @@ function getAnthropicMessagesCompat(provider: string, modelId: string): Anthropi if (provider === "anthropic") { const allowedFallbackModels = ANTHROPIC_ALLOWED_FALLBACK_MODELS[modelId]; if (allowedFallbackModels) { - compat.allowedFallbackModels = allowedFallbackModels; + compat.allowedFallbackModels = allowedFallbackModels.map((fallbackModel) => ({ model: fallbackModel })); } } if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) { @@ -2785,6 +2809,7 @@ async function generateModels() { applyOpenAIToolSearchMetadata(model); applyOpenAIExplicitPromptCacheMetadata(model); } + applyAnthropicFallbackCostMetadata(allModels.filter(isAnthropicFallbackMetadataModel)); // Group by provider and deduplicate by model ID const providers: Record>> = {}; diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index f0e89a5583f..b224e52d204 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -540,6 +540,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( try { let client: Anthropic; let isOAuth: boolean; + let usageModel = model; if (options?.client) { client = options.client; @@ -602,6 +603,14 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( if (event.type === "message_start") { output.responseId = event.message.id; output.model = event.message.model; + const fallbackCost = + output.model === model.id + ? undefined + : ((Array.isArray(options?.refusalFallbacks) + ? options.refusalFallbacks.find((fallback) => fallback.model === output.model)?.cost + : undefined) ?? + model.compat?.allowedFallbackModels?.find((fallback) => fallback.model === output.model)?.cost); + usageModel = fallbackCost ? { ...model, id: output.model, cost: fallbackCost } : model; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early output.usage.input = event.message.usage.input_tokens || 0; @@ -612,7 +621,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; - calculateCost(model, output.usage); + calculateCost(usageModel, output.usage); } else if (event.type === "content_block_start") { if (event.content_block.type === "text") { const block: Block = { @@ -769,7 +778,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; - calculateCost(model, output.usage); + calculateCost(usageModel, output.usage); } } @@ -1116,7 +1125,10 @@ function buildParams( } if (options?.refusalFallbacks !== undefined) { - params.fallbacks = options.refusalFallbacks; + params.fallbacks = + options.refusalFallbacks === "default" + ? "default" + : options.refusalFallbacks.map((fallback) => ({ model: fallback.model })); } return params; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e7df833d504..271873099a7 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -304,7 +304,13 @@ export interface ImagesOptions extends ProviderRequestOptions; -export type AnthropicRefusalFallback = "default" | readonly { model: string }[]; +export interface AnthropicRefusalFallbackTarget { + model: string; + /** Local pricing for this fallback target. Stripped before sending the provider request. @internal */ + cost?: ModelCost; +} + +export type AnthropicRefusalFallback = "default" | readonly AnthropicRefusalFallbackTarget[]; // Unified options with reasoning passed to streamSimple() and completeSimple() export interface SimpleStreamOptions extends StreamOptions { @@ -695,7 +701,7 @@ export interface AnthropicMessagesCompat { * When absent or empty, callers must omit `fallbacks`; Anthropic rejects the * field for models with no permitted fallback targets. */ - allowedFallbackModels?: string[]; + allowedFallbackModels?: AnthropicRefusalFallbackTarget[]; /** * Whether the provider supports deferred tools loaded by `tool_reference` * blocks in tool results. Default: true for first-party Anthropic models diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index a2a1c063f1b..7200da96f76 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -33,7 +33,7 @@ import { serializeConversation, } from "./utils.ts"; -function getAnthropicSummarizationFallback(model: Model): readonly { model: string }[] | undefined { +function getAnthropicSummarizationFallback(model: Model): SimpleStreamOptions["refusalFallbacks"] { if (model.provider !== "anthropic" || model.api !== "anthropic-messages") { return undefined; } @@ -41,7 +41,7 @@ function getAnthropicSummarizationFallback(model: Model): readonly { model: const allowedFallbackModels = (model as Model<"anthropic-messages">).compat?.allowedFallbackModels; // Use the primary permitted fallback for now. If future Anthropic models expose // broader fallback behavior, this can become a user/config pick or a full chain. - return allowedFallbackModels && allowedFallbackModels.length > 0 ? [{ model: allowedFallbackModels[0] }] : undefined; + return allowedFallbackModels && allowedFallbackModels.length > 0 ? [allowedFallbackModels[0]] : undefined; } // ============================================================================ diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 9bafc9d5d7f..462d48c7e50 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -437,16 +437,22 @@ describe("generateSummary reasoning options", () => { }); it("sets Anthropic refusal fallback from model metadata", async () => { + const fallbackCost = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }; await generateSummary( messages, - createModel(true, 8192, { allowedFallbackModels: ["claude-opus-4-8", "claude-opus-5"] }), + createModel(true, 8192, { + allowedFallbackModels: [ + { model: "claude-opus-4-8", cost: fallbackCost }, + { model: "claude-opus-5", cost: fallbackCost }, + ], + }), 2000, "test-key", ); expect(completeSimpleMock).toHaveBeenCalledTimes(1); expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ - refusalFallbacks: [{ model: "claude-opus-4-8" }], + refusalFallbacks: [{ model: "claude-opus-4-8", cost: fallbackCost }], }); }); From 55b0db4d3e9075345b6509a5b488250d79e026d8 Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Wed, 19 Aug 2026 09:40:18 +0200 Subject: [PATCH 215/284] fix(ai): prevent copilot policy login rate limits (#8254) - fixes #7850 - fetch the account model catalog before policy updates - update only known, tool-capable, unconfigured models - retry throttled login requests within a bounded delay - preserve the existing non-retrying refresh behavior --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth/oauth/github-copilot.ts | 194 +++++++++---- packages/ai/src/auth/oauth/kimi-coding.ts | 16 +- packages/ai/src/utils/sleep.ts | 14 + packages/ai/test/github-copilot-oauth.test.ts | 269 ++++++++++++------ 5 files changed, 331 insertions(+), 163 deletions(-) create mode 100644 packages/ai/src/utils/sleep.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e15dab0cfea..324a0c6d89d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -38,6 +38,7 @@ ### Fixed - Fixed GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)). +- Fixed GitHub Copilot login still triggering API rate limits by updating only account models with unconfigured policies and honoring server retry delays ([#7850](https://github.com/earendil-works/pi/issues/7850)). - Fixed upstream request buffer limit failures to trigger automatic assistant retries. - Fixed OpenAI Responses function and custom tool calls to preserve namespaces during streaming, proxying, and replay ([#7709](https://github.com/earendil-works/pi/issues/7709)). - Fixed built-in and custom DeepSeek API models to send output limits through the supported `max_tokens` field. diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index 8bd6342692c..5a17d07f059 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -3,8 +3,9 @@ */ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts"; +import { sleep } from "../../utils/sleep.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; -import { abortableSleep, pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; const decode = (s: string) => atob(s); const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg="); @@ -16,8 +17,6 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; const COPILOT_API_VERSION = "2026-06-01"; -const MAX_RETRY_AFTER_MS = 10_000; -const DEFAULT_RETRY_AFTER_MS = 1_000; type DeviceCodeResponse = { device_code: string; @@ -91,66 +90,108 @@ function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined; } -function parseAvailableCopilotModelIds(raw: unknown, allowPolicyFallback: boolean): string[] { +function parseGitHubCopilotModelCatalog(raw: unknown, allowPolicyFallback: boolean) { const data = asRecord(raw)?.data; if (!Array.isArray(data)) { throw new Error("Invalid Copilot models response"); } - const pickerIds: string[] = []; - const policyEnabledIds: string[] = []; - for (const rawItem of data) { + const accountModels = data.flatMap((rawItem) => { const item = asRecord(rawItem); const id = item?.id; - if (!item || typeof id !== "string") continue; + if (!item || typeof id !== "string") return []; const capabilities = asRecord(item.capabilities); const supports = asRecord(capabilities?.supports); - if (supports?.tool_calls === false) continue; - const policy = asRecord(item.policy); - if (item.model_picker_enabled === true && policy?.state !== "disabled") pickerIds.push(id); - if (policy?.state === "enabled") policyEnabledIds.push(id); + if (supports?.tool_calls === false) return []; + + return [ + { + id, + pickerEnabled: item.model_picker_enabled === true, + policyState: asRecord(item.policy)?.state, + }, + ]; + }); + const pickerModelIds = accountModels + .filter((model) => model.pickerEnabled && model.policyState !== "disabled") + .map((model) => model.id); + const usePolicyFallback = allowPolicyFallback && pickerModelIds.length === 0; + const availableModelIds = + pickerModelIds.length > 0 || !allowPolicyFallback + ? pickerModelIds + : accountModels.filter((model) => model.policyState === "enabled").map((model) => model.id); + const policyModelIds = accountModels + .filter( + (model) => + model.policyState === "unconfigured" && + Object.hasOwn(GITHUB_COPILOT_MODELS, model.id) && + (model.pickerEnabled || usePolicyFallback), + ) + .map((model) => model.id); + return { availableModelIds, policyModelIds }; +} + +async function fetchWithRateLimitRetry( + url: string, + init: RequestInit, + signal: AbortSignal, + retryPolicy: { maxRetries: number; maxElapsedMs: number }, +): Promise { + const retryBudgetSignal = + retryPolicy.maxRetries > 0 && retryPolicy.maxElapsedMs > 0 + ? AbortSignal.timeout(retryPolicy.maxElapsedMs) + : undefined; + const requestSignal = retryBudgetSignal ? AbortSignal.any([signal, retryBudgetSignal]) : signal; + const retryDeadline = retryBudgetSignal ? Date.now() + retryPolicy.maxElapsedMs : undefined; + for (let retry = 0; ; retry++) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.any([requestSignal, AbortSignal.timeout(5000)]), + }); + if (response.status !== 429 || retry === retryPolicy.maxRetries) return response; + + const retryAfter = response.headers.get("retry-after"); + let delayMs = 500 * 2 ** retry; + if (retryAfter) { + const seconds = Number.parseFloat(retryAfter); + delayMs = Number.isNaN(seconds) ? Date.parse(retryAfter) - Date.now() : seconds * 1000; + if (!Number.isFinite(delayMs)) return response; + } + delayMs = Math.max(0, delayMs); + if (retryDeadline !== undefined && delayMs >= retryDeadline - Date.now()) return response; + await response.body?.cancel(); + await sleep(delayMs, requestSignal); } - return pickerIds.length > 0 || !allowPolicyFallback ? pickerIds : policyEnabledIds; } -async function fetchAvailableGitHubCopilotModelIds( +async function fetchGitHubCopilotModels( copilotToken: string, enterpriseDomain: string | undefined, signal: AbortSignal, -): Promise { + retryPolicy: { maxRetries: number; maxElapsedMs: number }, +) { const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain); // Some Individual accounts return false for every picker flag despite explicit enabled policies. // Limit the fallback to that endpoint so other account types keep strict picker semantics. const allowPolicyFallback = baseUrl === "https://api.individual.githubcopilot.com"; - const request = () => - fetch(`${baseUrl}/models`, { + const response = await fetchWithRateLimitRetry( + `${baseUrl}/models`, + { headers: { Accept: "application/json", Authorization: `Bearer ${copilotToken}`, ...COPILOT_HEADERS, "X-GitHub-Api-Version": COPILOT_API_VERSION, }, - signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), - }); - - // The login-time policy updates can drain the Copilot API rate-limit bucket, in which case - // this request is rejected with 429. Honor Retry-After and retry once instead of failing. - let response = await request(); - if (response.status === 429) { - const retryAfterSeconds = Number(response.headers.get("retry-after")); - const waitMs = - Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 - ? Math.min(retryAfterSeconds * 1000, MAX_RETRY_AFTER_MS) - : DEFAULT_RETRY_AFTER_MS; - await abortableSleep(waitMs, signal, "Login cancelled"); - response = await request(); - } + }, + signal, + retryPolicy, + ); if (!response.ok) { - const text = await response.text(); - throw new Error(`${response.status} ${response.statusText}: ${text}`); + throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); } - return parseAvailableCopilotModelIds(await response.json(), allowPolicyFallback); + return parseGitHubCopilotModelCatalog(await response.json(), allowPolicyFallback); } async function fetchJson(url: string, init: RequestInit): Promise { @@ -315,9 +356,13 @@ async function refreshGitHubCopilotToken( signal: AbortSignal, ): Promise { const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain, signal); + const { availableModelIds } = await fetchGitHubCopilotModels(credentials.access, enterpriseDomain, signal, { + maxRetries: 0, + maxElapsedMs: 0, + }); return { ...credentials, - availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain, signal), + availableModelIds, }; } @@ -334,38 +379,56 @@ async function enableGitHubCopilotModel( const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain); const url = `${baseUrl}/models/${modelId}/policy`; + let response: Response; try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...COPILOT_HEADERS, - "openai-intent": "chat-policy", - "x-interaction-type": "chat-policy", + response = await fetchWithRateLimitRetry( + url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...COPILOT_HEADERS, + "openai-intent": "chat-policy", + "x-interaction-type": "chat-policy", + }, + body: JSON.stringify({ state: "enabled" }), }, - body: JSON.stringify({ state: "enabled" }), signal, - }); - return response.ok; + { maxRetries: 2, maxElapsedMs: 5000 }, + ); } catch (error) { if (signal.aborted) throw error; return false; } + if (response.status === 429) { + throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return response.ok; } /** - * Enable all known GitHub Copilot models that may require policy acceptance. - * Called after successful login to ensure all models are available. + * Enable the requested GitHub Copilot models and return the successful IDs. + * Policy updates are best effort; exhausted rate limiting stops the batch. */ -async function enableAllGitHubCopilotModels( +async function enableGitHubCopilotModels( token: string, + modelIds: readonly string[], enterpriseDomain: string | undefined, signal: AbortSignal, -): Promise { - for (const model of Object.values(GITHUB_COPILOT_MODELS)) { - await enableGitHubCopilotModel(token, model.id, enterpriseDomain, signal); +): Promise { + const enabledModelIds: string[] = []; + for (const modelId of modelIds) { + try { + if (await enableGitHubCopilotModel(token, modelId, enterpriseDomain, signal)) { + enabledModelIds.push(modelId); + } + } catch (error) { + if (signal.aborted) throw error; + break; + } } + return enabledModelIds; } async function loginGitHubCopilot(interaction: ProviderAuthInteraction): Promise { @@ -396,15 +459,28 @@ async function loginGitHubCopilot(interaction: ProviderAuthInteraction): Promise enterpriseDomain ?? undefined, interaction.signal, ); - interaction.notify({ type: "progress", message: "Enabling models..." }); - await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined, interaction.signal); - return { - ...credentials, - availableModelIds: await fetchAvailableGitHubCopilotModelIds( + const models = await fetchGitHubCopilotModels( + credentials.access, + enterpriseDomain ?? undefined, + interaction.signal, + { + maxRetries: 2, + maxElapsedMs: 5000, + }, + ); + let enabledModelIds: string[] = []; + if (models.policyModelIds.length > 0) { + interaction.notify({ type: "progress", message: "Enabling models..." }); + enabledModelIds = await enableGitHubCopilotModels( credentials.access, + models.policyModelIds, enterpriseDomain ?? undefined, interaction.signal, - ), + ); + } + return { + ...credentials, + availableModelIds: [...new Set([...models.availableModelIds, ...enabledModelIds])], }; } diff --git a/packages/ai/src/auth/oauth/kimi-coding.ts b/packages/ai/src/auth/oauth/kimi-coding.ts index 349b5a14129..6c5bd49532f 100644 --- a/packages/ai/src/auth/oauth/kimi-coding.ts +++ b/packages/ai/src/auth/oauth/kimi-coding.ts @@ -7,6 +7,7 @@ */ import { getProviderEnvValue } from "../../utils/provider-env.ts"; +import { sleep } from "../../utils/sleep.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; @@ -206,21 +207,6 @@ async function pollForToken( }); } -function sleep(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - signal.throwIfAborted(); - const onAbort = () => { - clearTimeout(timeout); - reject(signal.reason); - }; - const timeout = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal.addEventListener("abort", onAbort, { once: true }); - }); -} - function isRetryableRefreshFailure(response: Response): boolean { return response.status === 429 || response.status >= 500; } diff --git a/packages/ai/src/utils/sleep.ts b/packages/ai/src/utils/sleep.ts new file mode 100644 index 00000000000..67b97aef90a --- /dev/null +++ b/packages/ai/src/utils/sleep.ts @@ -0,0 +1,14 @@ +export function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + const onAbort = () => { + clearTimeout(timeout); + reject(signal.reason); + }; + const timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 61ceea3e570..267216e4f3f 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -6,11 +6,15 @@ import { githubCopilotProvider } from "../src/providers/github-copilot.ts"; const neverAbortedSignal = new AbortController().signal; -function jsonResponse(body: unknown, status: number = 200): Response { +const testCopilotAccessToken = "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;"; +const testCopilotModelsUrl = "https://api.individual.githubcopilot.com/models"; + +function jsonResponse(body: unknown, status: number = 200, headers?: Record): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", + ...headers, }, }); } @@ -28,6 +32,37 @@ function getUrl(input: unknown): string { throw new Error(`Unsupported fetch input: ${String(input)}`); } +function stubGitHubCopilotLoginFetch(options: { + models: () => Response; + policy?: (modelId: string) => Response; +}): void { + const fetchMock = vi.fn(async (input: string | URL | Request): Promise => { + const url = getUrl(input); + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 1, + expires_in: 900, + }); + } + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ token: testCopilotAccessToken, expires_at: 9999999999 }); + } + if (url === testCopilotModelsUrl) return options.models(); + if (url.startsWith(`${testCopilotModelsUrl}/`) && url.endsWith("/policy")) { + if (!options.policy) throw new Error(`Unexpected policy request: ${url}`); + return options.policy(url.slice(`${testCopilotModelsUrl}/`.length, -"/policy".length)); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +} + function loginGitHubCopilotForTest(options: { onDeviceCode(info: { userCode: string; @@ -180,6 +215,37 @@ describe("GitHub Copilot OAuth device flow", () => { expect(credentials.availableModelIds).toEqual([]); }); + it("does not retry model catalog throttling during credential refresh", async () => { + let catalogRequestCount = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ token: testCopilotAccessToken, expires_at: 9999999999 }); + } + if (url === testCopilotModelsUrl) { + catalogRequestCount += 1; + return jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "0" }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + await expect( + githubCopilotOAuth.refresh( + { + type: "oauth", + access: "old-access-token", + refresh: "ghu_refresh_token", + expires: 0, + }, + neverAbortedSignal, + ), + ).rejects.toThrow("429"); + expect(catalogRequestCount).toBe(1); + }); + it("reports device-code details through onDeviceCode", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); @@ -239,125 +305,150 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); - it("enables model policies sequentially during login", async () => { + it("updates only known, tool-capable, unconfigured account model policies", async () => { vi.useFakeTimers(); - let activePolicyRequests = 0; - let maxActivePolicyRequests = 0; - let policyRequestCount = 0; - const fetchMock = vi.fn(async (input: unknown): Promise => { - const url = getUrl(input); - - if (url.endsWith("/login/device/code")) { + let catalogRequestCount = 0; + const policyModelIds: string[] = []; + stubGitHubCopilotLoginFetch({ + models: () => { + catalogRequestCount += 1; return jsonResponse({ - device_code: "device-code", - user_code: "ABCD-EFGH", - verification_uri: "https://github.com/login/device", - interval: 1, - expires_in: 900, + data: [ + { + id: "gpt-4.1", + model_picker_enabled: true, + policy: { state: "enabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "claude-sonnet-4.5", + model_picker_enabled: true, + policy: { state: "unconfigured" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "remote-only-model", + model_picker_enabled: true, + policy: { state: "unconfigured" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "gpt-5.4", + model_picker_enabled: true, + policy: { state: "unconfigured" }, + capabilities: { supports: { tool_calls: false } }, + }, + ], }); - } - - if (url.endsWith("/login/oauth/access_token")) { - return jsonResponse({ access_token: "ghu_refresh_token" }); - } - - if (url.includes("/copilot_internal/v2/token")) { - return jsonResponse({ - token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", - expires_at: 9999999999, - }); - } - - if (url.endsWith("/models")) { - return jsonResponse({ data: [] }); - } - - if (url.includes("/models/") && url.endsWith("/policy")) { - policyRequestCount += 1; - activePolicyRequests += 1; - maxActivePolicyRequests = Math.max(maxActivePolicyRequests, activePolicyRequests); - await new Promise((resolve) => setTimeout(resolve, 10)); - activePolicyRequests -= 1; + }, + policy: (modelId) => { + policyModelIds.push(modelId); return new Response("", { status: 200 }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); + }, }); - vi.stubGlobal("fetch", fetchMock); - const loginPromise = loginGitHubCopilotForTest({ onDeviceCode: () => {}, onPrompt: async () => "", }); - - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000); await loginPromise; - expect(policyRequestCount).toBeGreaterThan(1); - expect(maxActivePolicyRequests).toBe(1); + expect(catalogRequestCount).toBe(1); + expect(policyModelIds).toEqual(["claude-sonnet-4.5"]); }); - it("retries GET /models once after a 429, honoring Retry-After", async () => { + it("retries a throttled policy update after Retry-After", async () => { vi.useFakeTimers(); - let modelsRequestCount = 0; - const fetchMock = vi.fn(async (input: unknown): Promise => { - const url = getUrl(input); - - if (url.endsWith("/login/device/code")) { - return jsonResponse({ - device_code: "device-code", - user_code: "ABCD-EFGH", - verification_uri: "https://github.com/login/device", - interval: 1, - expires_in: 900, - }); - } + let policyRequestCount = 0; + stubGitHubCopilotLoginFetch({ + models: () => + jsonResponse({ + data: [{ id: "claude-sonnet-4.5", model_picker_enabled: true, policy: { state: "unconfigured" } }], + }), + policy: () => { + policyRequestCount += 1; + return policyRequestCount === 1 + ? jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "1" }) + : new Response("", { status: 200 }); + }, + }); - if (url.endsWith("/login/oauth/access_token")) { - return jsonResponse({ access_token: "ghu_refresh_token" }); - } + const loginPromise = loginGitHubCopilotForTest({ + onDeviceCode: () => {}, + onPrompt: async () => "", + }); + await vi.advanceTimersByTimeAsync(1000); + expect(policyRequestCount).toBe(1); + await vi.advanceTimersByTimeAsync(999); + expect(policyRequestCount).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await loginPromise; - if (url.includes("/copilot_internal/v2/token")) { - return jsonResponse({ - token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", - expires_at: 9999999999, - }); - } + expect(policyRequestCount).toBe(2); + }); - if (url.endsWith("/models")) { - modelsRequestCount += 1; - if (modelsRequestCount === 1) { - return new Response("too many requests", { status: 429, headers: { "retry-after": "1" } }); - } - return jsonResponse({ data: [{ id: "gpt-5.4", model_picker_enabled: true }] }); - } + it("continues policy updates after a transport failure", async () => { + vi.useFakeTimers(); - if (url.includes("/models/") && url.endsWith("/policy")) { + const modelIds = ["gpt-4.1", "claude-sonnet-4.5"]; + const policyModelIds: string[] = []; + stubGitHubCopilotLoginFetch({ + models: () => + jsonResponse({ + data: modelIds.map((id) => ({ id, model_picker_enabled: true, policy: { state: "unconfigured" } })), + }), + policy: (modelId) => { + policyModelIds.push(modelId); + if (policyModelIds.length === 1) throw new Error("fetch failed"); return new Response("", { status: 200 }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); + }, }); - vi.stubGlobal("fetch", fetchMock); - const loginPromise = loginGitHubCopilotForTest({ onDeviceCode: () => {}, onPrompt: async () => "", }); + await vi.advanceTimersByTimeAsync(1000); + await loginPromise; - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(1000); // device poll - await vi.advanceTimersByTimeAsync(1000); // Retry-After wait - const credentials = await loginPromise; + expect(policyModelIds).toEqual(modelIds); + }); - expect(modelsRequestCount).toBe(2); - expect(credentials.availableModelIds).toEqual(["gpt-5.4"]); + it("stops policy updates and persists authentication when the retry delay exceeds the login budget", async () => { + vi.useFakeTimers(); + + const policyModelIds: string[] = []; + stubGitHubCopilotLoginFetch({ + models: () => + jsonResponse({ + data: [ + { id: "gpt-4.1", model_picker_enabled: true, policy: { state: "unconfigured" } }, + { id: "claude-sonnet-4.5", model_picker_enabled: true, policy: { state: "unconfigured" } }, + ], + }), + policy: (modelId) => { + policyModelIds.push(modelId); + return jsonResponse({ error: "too many requests" }, 429, { "Retry-After": "5" }); + }, + }); + + const store = new InMemoryCredentialStore(); + const models = createModels({ credentials: store }); + models.setProvider(githubCopilotProvider()); + const loginPromise = models.login("github-copilot", "oauth", { + signal: neverAbortedSignal, + prompt: async () => "", + notify: () => {}, + }); + + await vi.advanceTimersByTimeAsync(1000); + const credential = await loginPromise; + expect(credential).toMatchObject({ type: "oauth", access: testCopilotAccessToken }); + expect(policyModelIds).toEqual(["gpt-4.1"]); + expect(await store.read("github-copilot")).toEqual(credential); }); it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { From 8dab70281bc03ad70407afc87da4818353d94707 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 09:52:26 +0200 Subject: [PATCH 216/284] Revert "feat(coding-agent): add cache-friendly compaction primitives" This reverts commit cff1cf52c6c73ef873dcb6148238ed68f69e1ead. --- packages/ai/src/index.ts | 1 - packages/coding-agent/docs/compaction.md | 2 - .../coding-agent/src/core/agent-session.ts | 2 +- .../src/core/compaction/compaction.ts | 257 +++--------------- packages/coding-agent/src/index.ts | 1 - .../test/compaction-summary-reasoning.test.ts | 227 +--------------- packages/coding-agent/test/compaction.test.ts | 85 +----- 7 files changed, 46 insertions(+), 529 deletions(-) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 64142c999db..82bac973852 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -37,7 +37,6 @@ export * from "./providers/faux.ts"; export * from "./session-resources.ts"; export * from "./types.ts"; export * from "./utils/diagnostics.ts"; -export { estimateContextTokens } from "./utils/estimate.ts"; export * from "./utils/event-stream.ts"; export * from "./utils/json-parse.ts"; export * from "./utils/overflow.ts"; diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 1ed15fcd62f..3a9dff1c63c 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -281,9 +281,7 @@ pi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event; // preparation.messagesToSummarize - messages to summarize - // preparation.sourceMessages - optional active-context history prefix, including any previous summary // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn) - // preparation.turnPrefixSourceMessages - optional active-context prefix through the split turn // preparation.previousSummary - previous compaction summary // preparation.fileOps - extracted file operations // preparation.tokensBefore - context tokens before compaction diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4d02d194381..74280d56411 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1813,7 +1813,7 @@ export class AgentSession { env, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), - undefined, // cacheFriendly + undefined, // sessionId ); } diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 7200da96f76..a532e774d41 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,14 +6,7 @@ */ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { - contentText, - estimateContextTokens as estimateProviderContextTokens, - type RetryCallbacks, - type RetryPolicy, - retryAssistantCall, - uuidv7, -} from "@earendil-works/pi-ai"; +import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall, uuidv7 } from "@earendil-works/pi-ai"; import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; import { completeSimple } from "@earendil-works/pi-ai/compat"; import { convertToLlm } from "../messages.ts"; @@ -102,27 +95,6 @@ function getMessageFromEntryForCompaction(entry: SessionEntry): AgentMessage | u return sessionEntryToContextMessages(entry)[0]; } -/** Build an active-context prefix, placing the previous compaction summary before its retained messages. */ -function collectSourceMessages( - entries: SessionEntry[], - startIndex: number, - endIndex: number, - previousCompactionIndex: number, -): AgentMessage[] { - const messages: AgentMessage[] = []; - if (previousCompactionIndex >= 0) { - messages.push(...sessionEntryToContextMessages(entries[previousCompactionIndex])); - } - for (let i = startIndex; i < endIndex; i++) { - // The latest compaction was moved to the front above. Keep older compaction - // entries because buildSessionContext retains them in the active provider prefix. - if (i !== previousCompactionIndex) { - messages.push(...sessionEntryToContextMessages(entries[i])); - } - } - return messages; -} - /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ export interface CompactionResult { summary: string; @@ -168,19 +140,6 @@ export interface CompactionSettings { keepRecentTokens: number; } -/** Active provider contexts and request settings used to preserve cacheable compaction prefixes. */ -export interface CacheFriendlySummaryOptions { - /** Exact provider context prefix containing the history to summarize. */ - sourceContext?: Context; - /** Exact provider context prefix containing a split turn's prefix. */ - turnPrefixSourceContext?: Context; - /** Provider request settings copied from the active agent request path. */ - requestOptions?: Pick< - SimpleStreamOptions, - "sessionId" | "onPayload" | "onResponse" | "transport" | "thinkingBudgets" | "maxRetryDelayMs" - >; -} - export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { enabled: true, reserveTokens: 16384, @@ -590,10 +549,6 @@ const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation mes ${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; -const SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT = `The messages above contain an existing structured summary of earlier conversation history followed by NEW conversation messages. - -${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; - function createSummarizationOptions( model: Model, maxTokens: number, @@ -602,18 +557,9 @@ function createSummarizationOptions( env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, - requestOptions: CacheFriendlySummaryOptions["requestOptions"] | undefined, - cacheRetention: SimpleStreamOptions["cacheRetention"] | undefined, + sessionId: string | undefined, ): SimpleStreamOptions { - const options: SimpleStreamOptions = { - ...requestOptions, - maxTokens, - signal, - apiKey, - headers, - env, - cacheRetention, - }; + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; const refusalFallbacks = getAnthropicSummarizationFallback(model); if (refusalFallbacks) { options.refusalFallbacks = refusalFallbacks; @@ -639,15 +585,12 @@ export async function completeSummarization( retry?: RetryPolicy, callbacks?: RetryCallbacks, ): Promise { - // Standalone one-off summaries default to no prompt caching. Cache-friendly callers - // explicitly request short retention so providers can reuse the active prefix. - // Callers without a session ID, including standalone branch summaries, receive a fresh routing ID. + // Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available; + // callers without a session ID, including branch summaries, receive a fresh routing ID. const requestOptions: SimpleStreamOptions = { ...options, - cacheRetention: options.cacheRetention ?? "none", + cacheRetention: "none", sessionId: options.sessionId ?? uuidv7(), - // Anthropic invalidates the messages cache when tool_choice changes. Its 20-content-block lookup - // can still reuse an earlier user-message checkpoint, but long tool-heavy turns may need reprocessing. toolChoice: "none", }; const produce = async (): Promise => @@ -675,7 +618,7 @@ export async function generateSummary( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - cacheFriendly?: Pick, + sessionId?: string, ): Promise { return ( await generateSummaryWithUsage( @@ -692,47 +635,25 @@ export async function generateSummary( env, retry, callbacks, - cacheFriendly, + sessionId, ) ).text; } -/** Build a standalone summary request or append its instruction to an existing provider context. */ -function buildSummarizationContext(promptText: string, sourceContext?: Context): Context { - const instructionMessage = { - role: "user" as const, - content: [{ type: "text" as const, text: promptText }], - timestamp: Date.now(), - }; - - if (sourceContext) { - return { - ...sourceContext, - messages: [...sourceContext.messages, instructionMessage], - }; - } - +/** Build the provider context for a standalone summary request. */ +function buildSummarizationContext(promptText: string): Context { return { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, - messages: [instructionMessage], + messages: [ + { + role: "user", + content: [{ type: "text", text: promptText }], + timestamp: Date.now(), + }, + ], }; } -/** - * Extra room for provider framing and tokenizer variance omitted by the heuristic context estimate. - * This matches the 4096-token margin used when normal simple requests clamp maxTokens to their context window. - */ -const CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS = 4096; - -/** Whether the source context leaves room for the requested summary output and provider safety margin. */ -function cacheFriendlyContextFits(model: Model, context: Context, maxTokens: number): boolean { - return ( - model.contextWindow <= 0 || - estimateProviderContextTokens(context).tokens + maxTokens + CACHE_FRIENDLY_CONTEXT_SAFETY_TOKENS <= - model.contextWindow - ); -} - /** Generate or update a conversation summary and return its provider usage. */ export async function generateSummaryWithUsage( currentMessages: AgentMessage[], @@ -748,55 +669,31 @@ export async function generateSummaryWithUsage( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - cacheFriendly?: Pick, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); - // Provider-visible history prefix to reuse instead of serializing messages into a standalone prompt. - let sourceContext = cacheFriendly?.sourceContext; - - // Cache-friendly source contexts already contain the previous compaction summary, - // but still need iterative-update instructions so prior information is preserved. - let basePrompt = previousSummary - ? sourceContext - ? SOURCE_CONTEXT_UPDATE_SUMMARIZATION_PROMPT - : UPDATE_SUMMARIZATION_PROMPT - : SUMMARIZATION_PROMPT; + + // Use update prompt if we have a previous summary, otherwise initial prompt + let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; if (customInstructions) { basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; } - // A valid prefix can still be too large to leave the intended summary budget, - // especially during overflow recovery or after switching to a smaller-context model. - // In that case, use standalone serialization, which truncates large tool results. - if ( - sourceContext && - !cacheFriendlyContextFits(model, buildSummarizationContext(basePrompt, sourceContext), maxTokens) - ) { - sourceContext = undefined; - basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; - if (customInstructions) { - basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; - } - } + // Serialize conversation to text so model doesn't try to continue it + // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.) + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); - // Source contexts already contain the conversation. Standalone requests serialize it - // so the model treats those messages as data rather than continuing the conversation. - let promptText = ""; - if (!sourceContext) { - const llmMessages = convertToLlm(currentMessages); - const conversationText = serializeConversation(llmMessages); - promptText = `\n${conversationText}\n\n\n`; - } - if (previousSummary && !sourceContext) { + // Build the prompt with conversation wrapped in tags + let promptText = `\n${conversationText}\n\n\n`; + if (previousSummary) { promptText += `\n${previousSummary}\n\n\n`; } promptText += basePrompt; - const sourceRequestOptions = sourceContext ? cacheFriendly?.requestOptions : undefined; - const sourceCacheRetention = sourceContext ? "short" : undefined; const completionOptions = createSummarizationOptions( model, maxTokens, @@ -805,13 +702,12 @@ export async function generateSummaryWithUsage( env, signal, thinkingLevel, - sourceRequestOptions, - sourceCacheRetention, + sessionId, ); const response = await completeSummarization( model, - buildSummarizationContext(promptText, sourceContext), + buildSummarizationContext(promptText), completionOptions, streamFn, retry, @@ -839,15 +735,8 @@ export interface CompactionPreparation { firstKeptEntryId: string; /** Messages that will be summarized and discarded */ messagesToSummarize: AgentMessage[]; - /** - * Active-context prefix for the history summary. - * Includes the previous compaction summary before messages retained by that compaction. - */ - sourceMessages?: AgentMessage[]; /** Messages that will be turned into turn prefix summary (if splitting) */ turnPrefixMessages: AgentMessage[]; - /** Active-context prefix through the split-turn prefix, or empty when not splitting. */ - turnPrefixSourceMessages?: AgentMessage[]; /** Whether this is a split turn (cut point in middle of turn) */ isSplitTurn: boolean; tokensBefore: number; @@ -905,8 +794,6 @@ export function prepareCompaction( if (msg) messagesToSummarize.push(msg); } - const sourceMessages = collectSourceMessages(pathEntries, boundaryStart, historyEnd, prevCompactionIndex); - // Messages for turn prefix summary (if splitting a turn) const turnPrefixMessages: AgentMessage[] = []; if (cutPoint.isSplitTurn) { @@ -915,9 +802,6 @@ export function prepareCompaction( if (msg) turnPrefixMessages.push(msg); } } - const turnPrefixSourceMessages = cutPoint.isSplitTurn - ? collectSourceMessages(pathEntries, boundaryStart, cutPoint.firstKeptEntryIndex, prevCompactionIndex) - : []; if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { return undefined; @@ -936,9 +820,7 @@ export function prepareCompaction( return { firstKeptEntryId, messagesToSummarize, - sourceMessages, turnPrefixMessages, - turnPrefixSourceMessages, isSplitTurn: cutPoint.isSplitTurn, tokensBefore, previousSummary, @@ -966,30 +848,13 @@ Summarize the prefix to provide context for the retained suffix: Be concise. Focus on what's needed to understand the kept suffix.`; -const SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT = `The final turn in the source conversation was too large to keep in full. Its SUFFIX (recent work) is retained. - -The source conversation may also contain complete earlier turns for background. Summarize only the final, incomplete turn. It begins with the last user-role request before this instruction. Do not summarize earlier turns except for details needed to understand this final turn's prefix. - -Summarize the prefix to provide context for the retained suffix: - -## Original Request -[What did the user ask for in this turn?] - -## Early Progress -- [Key decisions and work done in the prefix] - -## Context for Suffix -- [Information needed to understand the retained recent work] - -Be concise. Focus on what's needed to understand the kept suffix.`; - /** * Generate summaries for compaction using prepared data. * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. * * @param preparation - Pre-calculated preparation from prepareCompaction() * @param customInstructions - Optional custom focus for the summary - * @param cacheFriendly - Active provider contexts and request settings for cache-friendly summarization + * @param sessionId - Optional routing session ID forwarded without enabling prompt caching */ export async function compact( preparation: CompactionPreparation, @@ -1003,7 +868,7 @@ export async function compact( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, - cacheFriendly?: CacheFriendlySummaryOptions, + sessionId?: string, ): Promise { const { firstKeptEntryId, @@ -1038,10 +903,7 @@ export async function compact( env, retry, callbacks, - { - sourceContext: cacheFriendly?.sourceContext, - requestOptions: cacheFriendly?.requestOptions, - }, + sessionId, ); historyText = historyResult.text; historyUsage = historyResult.usage; @@ -1058,10 +920,7 @@ export async function compact( streamFn, retry, callbacks, - { - sourceContext: cacheFriendly?.turnPrefixSourceContext, - requestOptions: cacheFriendly?.requestOptions, - }, + sessionId, ); // Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; @@ -1082,10 +941,7 @@ export async function compact( env, retry, callbacks, - { - sourceContext: cacheFriendly?.sourceContext, - requestOptions: cacheFriendly?.requestOptions, - }, + sessionId, ); summary = result.text; summaryUsage = result.usage; @@ -1123,51 +979,20 @@ async function generateTurnPrefixSummary( streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks, - cacheFriendly?: Pick, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, ); // Smaller budget for turn prefix + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; - // Reuse the provider-visible split-turn prefix only when it leaves room for the summary; - // otherwise serialize and truncate the messages in a standalone prompt. - let sourceContext = cacheFriendly?.sourceContext; - if ( - sourceContext && - !cacheFriendlyContextFits( - model, - buildSummarizationContext(SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT, sourceContext), - maxTokens, - ) - ) { - sourceContext = undefined; - } - let promptText: string; - if (sourceContext) { - promptText = SOURCE_CONTEXT_TURN_PREFIX_SUMMARIZATION_PROMPT; - } else { - const llmMessages = convertToLlm(messages); - const conversationText = serializeConversation(llmMessages); - promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; - } - - const sourceRequestOptions = sourceContext ? cacheFriendly?.requestOptions : undefined; - const sourceCacheRetention = sourceContext ? "short" : undefined; const response = await completeSummarization( model, - buildSummarizationContext(promptText, sourceContext), - createSummarizationOptions( - model, - maxTokens, - apiKey, - headers, - env, - signal, - thinkingLevel, - sourceRequestOptions, - sourceCacheRetention, - ), + buildSummarizationContext(promptText), + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId), streamFn, retry, callbacks, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7c9c596a4af..6d64d70c87c 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -28,7 +28,6 @@ export { readStoredCredential } from "./core/auth-storage.ts"; export { type BranchPreparation, type BranchSummaryResult, - type CacheFriendlySummaryOptions, type CollectEntriesResult, type CompactionResult, type CutPointResult, diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 462d48c7e50..e45a69bfadc 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -115,7 +115,7 @@ describe("generateSummary reasoning options", () => { expect(sessionIds[0]).not.toBe(sessionIds[1]); }); - it("honors caller-supplied cache retention and routing", async () => { + it("honors a caller-supplied routing session without prompt caching", async () => { await completeSummarization( createModel(false), { systemPrompt: "Summarize", messages: [] }, @@ -124,7 +124,7 @@ describe("generateSummary reasoning options", () => { expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ sessionId: "current-routing-session", - cacheRetention: "long", + cacheRetention: "none", toolChoice: "none", }); }); @@ -148,229 +148,6 @@ describe("generateSummary reasoning options", () => { expect(prompt).toContain(""); }); - it("appends instructions to a cache-friendly source context", async () => { - const sourceContext: Context = { - systemPrompt: "You are a coding agent.", - messages: [{ role: "user", content: "Previous summary and original request", timestamp: 1 }], - tools: [], - }; - const onPayload = async (payload: unknown) => payload; - const onResponse = async () => {}; - const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", - messagesToSummarize: messages, - turnPrefixMessages: [], - isSplitTurn: false, - tokensBefore: 100, - previousSummary: "Previous summary", - fileOps: { read: new Set(), written: new Set(), edited: new Set() }, - settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, - }; - - await compact( - preparation, - createModel(false), - "test-key", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { - sourceContext, - requestOptions: { - sessionId: "routing-session", - onPayload, - onResponse, - transport: "websocket", - thinkingBudgets: { low: 1234 }, - maxRetryDelayMs: 4321, - }, - }, - ); - - const requestContext = completeSimpleMock.mock.calls[0][1] as Context; - expect(requestContext.systemPrompt).toBe(sourceContext.systemPrompt); - expect(requestContext.tools).toBe(sourceContext.tools); - expect(requestContext.messages.slice(0, -1)).toEqual(sourceContext.messages); - const instruction = JSON.stringify(requestContext.messages.at(-1)); - expect(instruction).toContain("existing structured summary of earlier conversation history"); - expect(instruction).toContain("PRESERVE all existing information from the previous summary"); - expect(instruction).not.toContain(""); - expect(instruction).not.toContain(""); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ - cacheRetention: "short", - sessionId: "routing-session", - toolChoice: "none", - transport: "websocket", - thinkingBudgets: { low: 1234 }, - maxRetryDelayMs: 4321, - }); - expect(completeSimpleMock.mock.calls[0][2]?.onPayload).toBe(onPayload); - expect(completeSimpleMock.mock.calls[0][2]?.onResponse).toBe(onResponse); - expect(sourceContext.messages).toHaveLength(1); - }); - - it("falls back to standalone summarization when a cache-friendly source cannot leave the summary budget", async () => { - const oversizedToolResult: AgentMessage = { - role: "toolResult", - toolCallId: "tool-call-1", - toolName: "read", - content: [{ type: "text", text: "x".repeat(800_000) }], - isError: false, - timestamp: 1, - }; - const sourceContext: Context = { - systemPrompt: "You are a coding agent.", - messages: [oversizedToolResult], - tools: [], - }; - const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", - messagesToSummarize: [oversizedToolResult], - turnPrefixMessages: [], - isSplitTurn: false, - tokensBefore: 250_000, - fileOps: { read: new Set(), written: new Set(), edited: new Set() }, - settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, - }; - - await compact( - preparation, - createModel(false), - "test-key", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { sourceContext, requestOptions: { sessionId: "routing-session" } }, - ); - - const requestContext = completeSimpleMock.mock.calls[0][1] as Context; - const prompt = JSON.stringify(requestContext.messages); - expect(requestContext.systemPrompt).not.toBe(sourceContext.systemPrompt); - expect(requestContext.tools).toBeUndefined(); - expect(prompt).toContain(""); - expect(prompt).toContain("more characters truncated"); - expect(prompt).not.toContain("x".repeat(3000)); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ cacheRetention: "none", toolChoice: "none" }); - expect(completeSimpleMock.mock.calls[0][2]?.sessionId).not.toBe("routing-session"); - }); - - it("falls back to standalone summarization for an oversized split-turn source", async () => { - const splitUser = { role: "user" as const, content: "Large final request", timestamp: 1 }; - const oversizedToolResult: AgentMessage = { - role: "toolResult", - toolCallId: "tool-call-1", - toolName: "read", - content: [{ type: "text", text: "x".repeat(800_000) }], - isError: false, - timestamp: 2, - }; - const turnPrefixSourceContext: Context = { - systemPrompt: "You are a coding agent.", - messages: [splitUser, oversizedToolResult], - tools: [], - }; - const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", - messagesToSummarize: [], - turnPrefixMessages: [splitUser, oversizedToolResult], - isSplitTurn: true, - tokensBefore: 250_000, - fileOps: { read: new Set(), written: new Set(), edited: new Set() }, - settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, - }; - - await compact( - preparation, - createModel(false), - "test-key", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { turnPrefixSourceContext, requestOptions: { sessionId: "routing-session" } }, - ); - - const requestContext = completeSimpleMock.mock.calls[0][1] as Context; - const prompt = JSON.stringify(requestContext.messages); - expect(requestContext.systemPrompt).not.toBe(turnPrefixSourceContext.systemPrompt); - expect(requestContext.tools).toBeUndefined(); - expect(prompt).toContain("This is the PREFIX of a turn that was too large to keep"); - expect(prompt).toContain("more characters truncated"); - expect(prompt).not.toContain("source conversation may also contain complete earlier turns"); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ cacheRetention: "none", toolChoice: "none" }); - expect(completeSimpleMock.mock.calls[0][2]?.sessionId).not.toBe("routing-session"); - }); - - it("limits a cache-friendly split-turn summary to the final incomplete turn", async () => { - const earlierUser = { role: "user" as const, content: "Earlier request", timestamp: 1 }; - const earlierAssistant: AssistantMessage = { - ...mockSummaryResponse, - content: [{ type: "text", text: "Earlier work completed" }], - timestamp: 2, - }; - const splitUser = { role: "user" as const, content: "Large final request", timestamp: 3 }; - const earlyAssistant: AssistantMessage = { - ...mockSummaryResponse, - content: [{ type: "text", text: "Early work in final turn" }], - timestamp: 4, - }; - const turnPrefixSourceContext: Context = { - systemPrompt: "You are a coding agent.", - messages: [earlierUser, earlierAssistant, splitUser, earlyAssistant], - tools: [], - }; - const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", - messagesToSummarize: [], - turnPrefixMessages: [splitUser, earlyAssistant], - isSplitTurn: true, - tokensBefore: 100, - fileOps: { read: new Set(), written: new Set(), edited: new Set() }, - settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, - }; - - await compact( - preparation, - createModel(false), - "test-key", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { turnPrefixSourceContext }, - ); - - const requestContext = completeSimpleMock.mock.calls[0][1] as Context; - expect(requestContext.messages.slice(0, -1)).toEqual(turnPrefixSourceContext.messages); - const instruction = JSON.stringify(requestContext.messages.at(-1)); - expect(instruction).toContain("Summarize only the final, incomplete turn"); - expect(instruction).toContain("last user-role request before this instruction"); - expect(instruction).toContain("Do not summarize earlier turns"); - expect(instruction).not.toContain("Earlier request"); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ - cacheRetention: "short", - toolChoice: "none", - }); - }); - it("rejects tool calls from conversation summaries", async () => { completeSimpleMock.mockResolvedValueOnce(mockToolCallResponse); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 8fe754ce6a7..12c9d0be220 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -5,7 +5,6 @@ import { readFileSync } from "fs"; import { join } from "path"; import { beforeEach, describe, expect, it } from "vitest"; import { - type CompactionPreparation, type CompactionSettings, calculateContextTokens, compact, @@ -151,17 +150,6 @@ function createCustomMessageEntry(content: string): CustomMessageEntry { return entry; } -function requireSourceMessages( - preparation: CompactionPreparation | undefined, -): asserts preparation is CompactionPreparation & { - sourceMessages: AgentMessage[]; - turnPrefixSourceMessages: AgentMessage[]; -} { - if (!preparation?.sourceMessages || !preparation.turnPrefixSourceMessages) { - throw new Error("Expected compaction source messages"); - } -} - function extractText(messages: AgentMessage[]): string { return messages .map((message) => { @@ -473,44 +461,6 @@ describe("buildSessionContext", () => { }); }); -describe("prepareCompaction source messages", () => { - it("provides the active history prefix for a normal compaction", () => { - const u1 = createMessageEntry(createUserMessage("old user ".repeat(20))); - const a1 = createMessageEntry(createAssistantMessage("old assistant ".repeat(20))); - const u2 = createMessageEntry(createUserMessage("recent user")); - const a2 = createMessageEntry(createAssistantMessage("ok")); - const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 3 }; - - const preparation = prepareCompaction([u1, a1, u2, a2], settings); - - expect(preparation).toBeDefined(); - requireSourceMessages(preparation); - expect(preparation.isSplitTurn).toBe(false); - expect(preparation.sourceMessages).toEqual(preparation.messagesToSummarize); - expect(extractText(preparation.sourceMessages)).toContain("old user"); - expect(extractText(preparation.sourceMessages)).not.toContain("recent user"); - expect(preparation.turnPrefixSourceMessages).toEqual([]); - }); - - it("provides the active prefix through a split turn", () => { - const user = createMessageEntry(createUserMessage("large request ".repeat(20))); - const earlyAssistant = createMessageEntry(createAssistantMessage("early work ".repeat(20))); - const keptAssistant = createMessageEntry(createAssistantMessage("kept")); - const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 }; - - const preparation = prepareCompaction([user, earlyAssistant, keptAssistant], settings); - - expect(preparation).toBeDefined(); - requireSourceMessages(preparation); - expect(preparation.isSplitTurn).toBe(true); - expect(preparation.messagesToSummarize).toEqual([]); - expect(preparation.sourceMessages).toEqual([]); - expect(preparation.turnPrefixSourceMessages).toEqual(preparation.turnPrefixMessages); - expect(extractText(preparation.turnPrefixSourceMessages)).toContain("large request"); - expect(extractText(preparation.turnPrefixSourceMessages)).not.toContain("kept"); - }); -}); - describe("prepareCompaction with previous compaction", () => { it("should skip repeated compactions when kept messages still fit", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)")); @@ -529,33 +479,6 @@ describe("prepareCompaction with previous compaction", () => { expect(preparation).toBeUndefined(); }); - it("preserves retained older summaries in the active source prefix", () => { - const u1 = createMessageEntry(createUserMessage("user 1")); - const a1 = createMessageEntry(createAssistantMessage("assistant 1")); - const u2 = createMessageEntry(createUserMessage("user 2 ".repeat(20))); - const a2 = createMessageEntry(createAssistantMessage("assistant 2 ".repeat(20))); - const compaction1 = createCompactionEntry("First summary", u1.id); - const u3 = createMessageEntry(createUserMessage("user 3 ".repeat(20))); - const a3 = createMessageEntry(createAssistantMessage("assistant 3 ".repeat(20))); - const compaction2 = createCompactionEntry("Second summary", u2.id); - const u4 = createMessageEntry(createUserMessage("user 4 ".repeat(20))); - const a4 = createMessageEntry(createAssistantMessage("kept")); - const settings: CompactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 }; - const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3, compaction2, u4, a4]; - - const preparation = prepareCompaction(pathEntries, settings); - - expect(preparation).toBeDefined(); - requireSourceMessages(preparation); - const activeMessages = buildSessionContext(pathEntries).messages; - expect(preparation.sourceMessages).toEqual(activeMessages.slice(0, preparation.sourceMessages.length)); - expect(preparation.turnPrefixSourceMessages).toEqual( - activeMessages.slice(0, preparation.turnPrefixSourceMessages.length), - ); - const sourceSummaries = preparation.sourceMessages.filter((message) => message.role === "compactionSummary"); - expect(sourceSummaries.map((message) => message.summary)).toEqual(["Second summary", "First summary"]); - }); - it("should re-summarize previously kept messages when the recent window moves past them", () => { const u1 = createMessageEntry(createUserMessage("user msg 1 (summarized by compaction1)".repeat(4))); const a1 = createMessageEntry(createAssistantMessage("assistant msg 1".repeat(4))); @@ -574,15 +497,11 @@ describe("prepareCompaction with previous compaction", () => { const preparation = prepareCompaction([u1, a1, u2, a2, u3, a3, compaction1, u4, a4], settings); expect(preparation).toBeDefined(); - requireSourceMessages(preparation); - const summarizedText = extractText(preparation.messagesToSummarize); + const summarizedText = extractText(preparation!.messagesToSummarize); expect(summarizedText).toContain("user msg 2 - kept by compaction1"); expect(summarizedText).toContain("user msg 3 - kept by compaction1"); expect(summarizedText).not.toContain("First summary"); - expect(preparation.previousSummary).toBe("First summary"); - expect(preparation.sourceMessages[0]?.role).toBe("compactionSummary"); - expect(extractText(preparation.sourceMessages)).toContain("First summary"); - expect(preparation.sourceMessages.slice(1)).toEqual(preparation.messagesToSummarize); + expect(preparation!.previousSummary).toBe("First summary"); }); }); From 3a0b9a3eeee280f750edb3b0fc8a1b9093e88137 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 09:53:15 +0200 Subject: [PATCH 217/284] Revert "feat(agent): expose provider context construction" This reverts commit 2509b5c037d366979f2febfce4174b88aeaadc6a. --- packages/agent/src/agent-loop.ts | 31 ++++++----------- packages/agent/src/agent.ts | 16 +-------- packages/agent/test/agent.test.ts | 57 +------------------------------ 3 files changed, 13 insertions(+), 91 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 1f9d44599a2..a251fede0a9 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -274,12 +274,17 @@ async function runLoop( await emit({ type: "agent_end", messages: newMessages }); } -/** Build the provider context using the same transform and conversion pipeline as an agent request. */ -export async function buildProviderContext( +/** + * Stream an assistant response from the LLM. + * This is where AgentMessage[] gets transformed to Message[] for the LLM. + */ +async function streamAssistantResponse( context: AgentContext, - config: Pick, - signal?: AbortSignal, -): Promise { + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFunction: StreamFn, +): Promise { // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; if (config.transformContext) { @@ -290,25 +295,11 @@ export async function buildProviderContext( const llmMessages = await config.convertToLlm(messages); // Build LLM context - return { + const llmContext: Context = { systemPrompt: context.systemPrompt, messages: llmMessages, tools: context.tools, }; -} - -/** - * Stream an assistant response from the LLM. - * This is where AgentMessage[] gets transformed to Message[] for the LLM. - */ -async function streamAssistantResponse( - context: AgentContext, - config: AgentLoopConfig, - signal: AbortSignal | undefined, - emit: AgentEventSink, - streamFunction: StreamFn, -): Promise { - const llmContext = await buildProviderContext(context, config, signal); // Resolve API key (important for expiring tokens) const resolvedApiKey = diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index dfd4f93915e..0de7edd8302 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,5 +1,4 @@ import type { - Context, ImageContent, Message, Model, @@ -8,11 +7,7 @@ import type { ThinkingBudgets, Transport, } from "@earendil-works/pi-ai"; -import { - buildProviderContext as buildProviderContextFromAgentContext, - runAgentLoop, - runAgentLoopContinue, -} from "./agent-loop.ts"; +import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import { getDefaultStreamFn } from "./stream-fn.ts"; import type { AfterToolCallContext, @@ -266,15 +261,6 @@ export class Agent { return this._state; } - /** Build a provider context through the same transform and conversion pipeline used by agent requests. */ - async buildProviderContext(context: AgentContext, signal?: AbortSignal): Promise { - return buildProviderContextFromAgentContext( - context, - { convertToLlm: this.convertToLlm, transformContext: this.transformContext }, - signal, - ); - } - /** Controls how queued steering messages are drained. */ set steeringMode(mode: QueueMode) { this.steeringQueue.mode = mode; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 7642374b388..672c4a6789c 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,16 +1,9 @@ -import { - type AssistantMessage, - type AssistantMessageEvent, - EventStream, - getModel, - type Message, -} from "@earendil-works/pi-ai/compat"; +import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { Agent, type AgentEvent, - type AgentMessage, type AgentTool, type AgentToolUpdateCallback, type StreamFn, @@ -141,54 +134,6 @@ describe("Agent", () => { expect(agent.state.thinkingLevel).toBe("low"); }); - it("builds provider context through configured transforms", async () => { - const sourceMessages: AgentMessage[] = [ - { role: "user", content: "discard", timestamp: 1 }, - { role: "user", content: "keep", timestamp: 2 }, - ]; - const transformedMessages: AgentMessage[] = [sourceMessages[1]]; - const callOrder: string[] = []; - const abortController = new AbortController(); - let transformInput: AgentMessage[] | undefined; - let convertInput: AgentMessage[] | undefined; - const agent = new Agent({ - streamFn: unusedStreamFunction, - transformContext: async (messages, signal) => { - callOrder.push("transform"); - transformInput = messages; - expect(signal).toBe(abortController.signal); - return transformedMessages; - }, - convertToLlm: async (messages) => { - callOrder.push("convert"); - convertInput = messages; - return messages.filter( - (message): message is Message => - message.role === "user" || message.role === "assistant" || message.role === "toolResult", - ); - }, - }); - const tools: AgentTool[] = []; - - const context = await agent.buildProviderContext( - { - systemPrompt: "System prompt", - messages: sourceMessages, - tools, - }, - abortController.signal, - ); - - expect(callOrder).toEqual(["transform", "convert"]); - expect(transformInput).toBe(sourceMessages); - expect(convertInput).toBe(transformedMessages); - expect(context).toEqual({ - systemPrompt: "System prompt", - messages: transformedMessages, - tools, - }); - }); - it("should subscribe to events", () => { const agent = new Agent({ streamFn: unusedStreamFunction }); From ed867e90947910c907d7d4b9d1b7a8586448f648 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:30:26 +0200 Subject: [PATCH 218/284] fix(ai): fallback cost not via stream options (#8352) * fix(ai): fallback cost not via stream options * fix: always pass beta header, remove fallback from stream options --- packages/ai/scripts/generate-models.ts | 24 ++++++-------- packages/ai/src/api/anthropic-messages.ts | 33 +++++++------------ packages/ai/src/types.ts | 19 +++++------ .../src/core/compaction/compaction.ts | 15 --------- .../test/compaction-summary-reasoning.test.ts | 14 ++++---- 5 files changed, 37 insertions(+), 68 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d376149dcb0..bf732af931b 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -752,18 +752,20 @@ function isAnthropicFallbackMetadataModel(model: Model): model is Model<"an ); } -function applyAnthropicFallbackCostMetadata(models: readonly Model<"anthropic-messages">[]): void { +function applyAnthropicAllowedFallbackModelMetadata(models: readonly Model<"anthropic-messages">[]): void { const modelsById = new Map(models.map((model) => [model.id, model])); for (const [modelId, fallbackModelIds] of Object.entries(ANTHROPIC_ALLOWED_FALLBACK_MODELS)) { const model = modelsById.get(modelId); - if (!model?.compat?.allowedFallbackModels) continue; + if (!model) continue; - for (const fallbackModelId of fallbackModelIds) { - const fallback = model.compat.allowedFallbackModels.find((target) => target.model === fallbackModelId); + const allowedFallbackModels = fallbackModelIds.flatMap((fallbackModelId) => { const fallbackModel = modelsById.get(fallbackModelId); - if (fallback && fallbackModel) { - fallback.cost = fallbackModel.cost; - } + return fallbackModel + ? [{ provider: fallbackModel.provider, model: fallbackModel.id, cost: fallbackModel.cost }] + : []; + }); + if (allowedFallbackModels.length > 0) { + mergeAnthropicMessagesCompat(model, { allowedFallbackModels }); } } } @@ -985,12 +987,6 @@ function getAnthropicMessagesCompat(provider: string, modelId: string): Anthropi if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) { compat.supportsEagerToolInputStreaming = false; } - if (provider === "anthropic") { - const allowedFallbackModels = ANTHROPIC_ALLOWED_FALLBACK_MODELS[modelId]; - if (allowedFallbackModels) { - compat.allowedFallbackModels = allowedFallbackModels.map((fallbackModel) => ({ model: fallbackModel })); - } - } if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) { compat.allowEmptySignature = true; } @@ -2809,7 +2805,7 @@ async function generateModels() { applyOpenAIToolSearchMetadata(model); applyOpenAIExplicitPromptCacheMetadata(model); } - applyAnthropicFallbackCostMetadata(allModels.filter(isAnthropicFallbackMetadataModel)); + applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel)); // Group by provider and deduplicate by model ID const providers: Record>> = {}; diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index b224e52d204..f36b54755e4 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -10,7 +10,6 @@ import type { import { calculateCost } from "../models.ts"; import type { AnthropicMessagesCompat, - AnthropicRefusalFallback, Api, AssistantMessage, CacheRetention, @@ -170,13 +169,17 @@ export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"; export type AnthropicThinkingDisplay = "summarized" | "omitted"; type MessageCreateParamsStreamingWithFallbacks = MessageCreateParamsStreaming & { - fallbacks?: AnthropicRefusalFallback; + fallbacks?: readonly { model: string }[]; }; const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01"; +function shouldUseServerSideFallbackBeta(model: Model<"anthropic-messages">): boolean { + return (model.compat?.allowedFallbackModels?.length ?? 0) > 0; +} + function getAnthropicCompat( model: Model<"anthropic-messages">, ): Required> { @@ -254,12 +257,6 @@ export interface AnthropicOptions extends StreamOptions { * Default: true. */ interleavedThinking?: boolean; - /** - * Anthropic refusal fallback. When set, the request includes the server-side - * fallback beta and Anthropic retries eligible refusals on the configured - * fallback target before returning a final response. - */ - refusalFallbacks?: AnthropicRefusalFallback; /** * Anthropic tool choice behavior. String values map to Anthropic's built-in * choices; `{ type: "tool", name }` forces a specific tool. @@ -566,7 +563,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, context), - options?.refusalFallbacks !== undefined, + shouldUseServerSideFallbackBeta(model), options?.headers, options?.fetch, copilotDynamicHeaders, @@ -606,10 +603,9 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( const fallbackCost = output.model === model.id ? undefined - : ((Array.isArray(options?.refusalFallbacks) - ? options.refusalFallbacks.find((fallback) => fallback.model === output.model)?.cost - : undefined) ?? - model.compat?.allowedFallbackModels?.find((fallback) => fallback.model === output.model)?.cost); + : model.compat?.allowedFallbackModels?.find( + (fallback) => fallback.provider === model.provider && fallback.model === output.model, + )?.cost; usageModel = fallbackCost ? { ...model, id: output.model, cost: fallbackCost } : model; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early @@ -850,7 +846,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti if (!options?.reasoning) { return stream(model, context, { ...base, - refusalFallbacks: options?.refusalFallbacks, thinkingEnabled: false, } satisfies AnthropicOptions); } @@ -861,7 +856,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti const effort = mapThinkingLevelToEffort(model, options.reasoning); return stream(model, context, { ...base, - refusalFallbacks: options?.refusalFallbacks, thinkingEnabled: true, effort, } satisfies AnthropicOptions); @@ -880,7 +874,6 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti return stream(model, context, { ...base, - refusalFallbacks: options?.refusalFallbacks, maxTokens, thinkingEnabled: true, thinkingBudgetTokens: Math.min(adjusted.thinkingBudget, Math.max(0, maxTokens - 1024)), @@ -1124,11 +1117,9 @@ function buildParams( } } - if (options?.refusalFallbacks !== undefined) { - params.fallbacks = - options.refusalFallbacks === "default" - ? "default" - : options.refusalFallbacks.map((fallback) => ({ model: fallback.model })); + const allowedFallbackModels = model.compat?.allowedFallbackModels; + if (allowedFallbackModels && allowedFallbackModels.length > 0) { + params.fallbacks = allowedFallbackModels.map((fallback) => ({ model: fallback.model })); } return params; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 271873099a7..b241038199d 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -304,21 +304,17 @@ export interface ImagesOptions extends ProviderRequestOptions; -export interface AnthropicRefusalFallbackTarget { +export interface AnthropicAllowedFallbackModel { + provider: ProviderId; model: string; - /** Local pricing for this fallback target. Stripped before sending the provider request. @internal */ - cost?: ModelCost; + cost: ModelCost; } -export type AnthropicRefusalFallback = "default" | readonly AnthropicRefusalFallbackTarget[]; - // Unified options with reasoning passed to streamSimple() and completeSimple() export interface SimpleStreamOptions extends StreamOptions { /** Provider-neutral tool selection for simple requests. Default: "auto". */ toolChoice?: ToolChoice; reasoning?: ThinkingLevel; - /** Anthropic server-side fallback for eligible refusal stop reasons. Anthropic providers only. */ - refusalFallbacks?: AnthropicRefusalFallback; /** Ask a capable provider to return a durable handle and continue the request asynchronously. */ deferred?: boolean | { window?: "15m" | "1h" | "24h" }; /** Custom token budgets for thinking levels (token-based providers only) */ @@ -697,11 +693,12 @@ export interface AnthropicMessagesCompat { /** Whether the provider supports Anthropic strict tool schemas. Default: false; generated Anthropic models enable it explicitly. */ supportsStrictTools?: boolean; /** - * Model ids Anthropic accepts in `fallbacks` for server-side refusal fallback. - * When absent or empty, callers must omit `fallbacks`; Anthropic rejects the - * field for models with no permitted fallback targets. + * Models Anthropic accepts in `fallbacks` for server-side refusal fallback, + * with local pricing metadata for returned fallback responses. When absent or + * empty, callers must omit `fallbacks`; Anthropic rejects the field for models + * with no permitted fallback targets. */ - allowedFallbackModels?: AnthropicRefusalFallbackTarget[]; + allowedFallbackModels?: AnthropicAllowedFallbackModel[]; /** * Whether the provider supports deferred tools loaded by `tool_reference` * blocks in tool results. Default: true for first-party Anthropic models diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index a532e774d41..b7df9da34e1 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -26,17 +26,6 @@ import { serializeConversation, } from "./utils.ts"; -function getAnthropicSummarizationFallback(model: Model): SimpleStreamOptions["refusalFallbacks"] { - if (model.provider !== "anthropic" || model.api !== "anthropic-messages") { - return undefined; - } - - const allowedFallbackModels = (model as Model<"anthropic-messages">).compat?.allowedFallbackModels; - // Use the primary permitted fallback for now. If future Anthropic models expose - // broader fallback behavior, this can become a user/config pick or a full chain. - return allowedFallbackModels && allowedFallbackModels.length > 0 ? [allowedFallbackModels[0]] : undefined; -} - // ============================================================================ // File Operation Tracking // ============================================================================ @@ -560,10 +549,6 @@ function createSummarizationOptions( sessionId: string | undefined, ): SimpleStreamOptions { const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; - const refusalFallbacks = getAnthropicSummarizationFallback(model); - if (refusalFallbacks) { - options.refusalFallbacks = refusalFallbacks; - } if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index e45a69bfadc..037469cabc7 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -213,14 +213,16 @@ describe("generateSummary reasoning options", () => { expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning"); }); - it("sets Anthropic refusal fallback from model metadata", async () => { - const fallbackCost = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }; + it("leaves Anthropic refusal fallback handling to pi-ai model metadata", async () => { await generateSummary( messages, createModel(true, 8192, { allowedFallbackModels: [ - { model: "claude-opus-4-8", cost: fallbackCost }, - { model: "claude-opus-5", cost: fallbackCost }, + { + provider: "anthropic", + model: "claude-opus-4-8", + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }, ], }), 2000, @@ -228,9 +230,7 @@ describe("generateSummary reasoning options", () => { ); expect(completeSimpleMock).toHaveBeenCalledTimes(1); - expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ - refusalFallbacks: [{ model: "claude-opus-4-8", cost: fallbackCost }], - }); + expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("refusalFallbacks"); }); it("does not set Anthropic refusal fallback for models without allowed fallback targets", async () => { From d711bd5f0a5fca7f10f8a681763b5ed5e63d9400 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 19 Aug 2026 12:02:37 +0200 Subject: [PATCH 219/284] fix(coding-agent): preserve branch summary source leaf Record the pre-navigation leaf in branch summary fromId instead of the destination node. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/session-manager.ts | 3 ++- packages/coding-agent/test/branch-summary-extensions.test.ts | 4 +++- .../coding-agent/test/session-manager/tree-traversal.test.ts | 5 +++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e857000f5eb..84eecd8209a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -19,6 +19,7 @@ - Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). - Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). - Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). +- Fixed branch summary entries recording the navigation destination in `fromId` instead of the pre-navigation source leaf. ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index cd9d2f437ae..f8db9b0e5b5 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1388,13 +1388,14 @@ export class SessionManager { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } + const fromId = this.leafId ?? "root"; this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), - fromId: branchFromId ?? "root", + fromId, summary, details, usage, diff --git a/packages/coding-agent/test/branch-summary-extensions.test.ts b/packages/coding-agent/test/branch-summary-extensions.test.ts index 9c47cb81aac..ecdc0179785 100644 --- a/packages/coding-agent/test/branch-summary-extensions.test.ts +++ b/packages/coding-agent/test/branch-summary-extensions.test.ts @@ -38,12 +38,14 @@ describe("Branch summary extensions", () => { const targetId = harness.sessionManager.appendMessage(userMsg("first branch")); harness.sessionManager.appendMessage(assistantMsg("first reply")); harness.sessionManager.appendMessage(userMsg("abandoned branch work")); - harness.sessionManager.appendMessage(assistantMsg("abandoned reply")); + const sourceId = harness.sessionManager.appendMessage(assistantMsg("abandoned reply")); const result = await harness.session.navigateTree(targetId, { summarize: true }); const summaryEntry = result.summaryEntry; expect(summaryEntry?.type).toBe("branch_summary"); + expect(summaryEntry?.parentId).toBeNull(); + expect(summaryEntry?.fromId).toBe(sourceId); expect(summaryEntry?.fromHook).toBe(true); expect(summaryEntry?.summary).toBe("Summary provided by extension"); expect(summaryEntry?.usage).toEqual(usage); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index d6e20f0430f..123dc65cd6e 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -321,12 +321,12 @@ describe("SessionManager append and tree traversal", () => { }); describe("branchWithSummary", () => { - it("inserts branch summary and advances leaf", () => { + it("inserts branch summary with the source and destination and advances leaf", () => { const session = SessionManager.inMemory(); const id1 = session.appendMessage(userMsg("1")); const _id2 = session.appendMessage(assistantMsg("2")); - const _id3 = session.appendMessage(userMsg("3")); + const id3 = session.appendMessage(userMsg("3")); const usage = { input: 10, @@ -345,6 +345,7 @@ describe("SessionManager append and tree traversal", () => { expect(summaryEntry).toBeDefined(); expect(summaryEntry?.parentId).toBe(id1); if (summaryEntry?.type === "branch_summary") { + expect(summaryEntry.fromId).toBe(id3); expect(summaryEntry.summary).toBe("Summary of abandoned work"); expect(summaryEntry.usage).toEqual(usage); } From 830a0a59e975ed3a4e551be18dc60d45479f5118 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Wed, 19 Aug 2026 12:24:30 +0200 Subject: [PATCH 220/284] fix(coding-agent): expose tool metadata at stream start (#7953) * fix(coding-agent): expose tool metadata at stream start * fix(coding-agent): centralize message update serialization --- packages/coding-agent/docs/json.md | 9 ++- packages/coding-agent/docs/rpc.md | 12 +++- packages/coding-agent/src/modes/json-event.ts | 57 ++++++++++++------- .../7925-toolcall-start-metadata.test.ts | 43 ++++++++++++++ 4 files changed, 95 insertions(+), 26 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts diff --git a/packages/coding-agent/docs/json.md b/packages/coding-agent/docs/json.md index ddb9cb3054d..2d538802e7c 100644 --- a/packages/coding-agent/docs/json.md +++ b/packages/coding-agent/docs/json.md @@ -15,12 +15,16 @@ except that streaming message updates omit cumulative snapshots: ```typescript type WithoutPartial = T extends { partial: unknown } ? Omit : T; +type JsonAssistantMessageEvent = T extends { type: "toolcall_start"; partial: unknown } + ? WithoutPartial & { id: string; toolName: string } + : WithoutPartial; + type JsonAgentSessionEvent = | Exclude | { type: "message_update"; usage: Usage; - assistantMessageEvent: WithoutPartial; + assistantMessageEvent: JsonAssistantMessageEvent; }; ``` @@ -84,7 +88,8 @@ Followed by events as they occur: `assistantMessageEvent.partial` to keep stream size linear. The top-level `usage` field contains the latest cumulative provider-reported usage and may remain zero when a provider only reports usage at completion. Use `contentIndex` and `delta` to assemble live text, thinking, or tool-call -arguments if needed. `message_end` contains the final authoritative message. +arguments if needed. A `toolcall_start` event also includes the constant-sized `id` and `toolName` +fields. `message_end` contains the final authoritative message. ## Example diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index fc4c69867e5..f92c12e8e62 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -945,7 +945,7 @@ The `assistantMessageEvent` field contains one of these delta types: | `thinking_start` | Thinking block started | | `thinking_delta` | Thinking content chunk | | `thinking_end` | Thinking block ended | -| `toolcall_start` | Tool call started | +| `toolcall_start` | Tool call started (includes `id` and `toolName`) | | `toolcall_delta` | Tool call arguments chunk | | `toolcall_end` | Tool call ended (includes full `toolCall` object) | @@ -960,11 +960,17 @@ Example streaming a text response: The top-level `usage` field contains the latest cumulative provider-reported usage. It may remain zero until completion when a provider does not report usage during streaming. +Example starting a tool call: +```json +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":1,"id":"call_abc123","toolName":"write"}} +``` + `message_update` intentionally omits the former cumulative `message` field and `assistantMessageEvent.partial`. Clients that need a live partial message must assemble it from `message_start` and subsequent events using `contentIndex`. Treat `message_end.message` -as authoritative. For tool calls, buffer `toolcall_delta.delta`; `toolcall_end.toolCall` -contains the completed call. +as authoritative. For tool calls, `toolcall_start` provides the call `id` and `toolName`; +buffer `toolcall_delta.delta` for arguments. `toolcall_end.toolCall` contains the completed +call. ### bash_execution_update diff --git a/packages/coding-agent/src/modes/json-event.ts b/packages/coding-agent/src/modes/json-event.ts index f300b17a91c..c0c04fde0d7 100644 --- a/packages/coding-agent/src/modes/json-event.ts +++ b/packages/coding-agent/src/modes/json-event.ts @@ -3,28 +3,45 @@ import type { AgentSessionEvent } from "../core/agent-session.ts"; type WithoutPartial = T extends { partial: unknown } ? Omit : T; -type ToJsonEvent = T extends { +type ToJsonAssistantMessageEvent = T extends { type: "toolcall_start"; partial: unknown } + ? WithoutPartial & { id: string; toolName: string } + : WithoutPartial; + +type MessageUpdateEvent = Extract; +type JsonMessageUpdateEvent = { type: "message_update"; - assistantMessageEvent: infer TAssistantMessageEvent; -} - ? { - type: "message_update"; - usage: Usage; - assistantMessageEvent: WithoutPartial; - } - : T; + usage: Usage; + assistantMessageEvent: ToJsonAssistantMessageEvent; +}; /** Session event shape emitted by the JSON and RPC stdout protocols. */ -export type JsonAgentSessionEvent = ToJsonEvent; +export type JsonAgentSessionEvent = Exclude | JsonMessageUpdateEvent; -type MessageUpdateEvent = Extract; -type JsonMessageUpdateEvent = Extract; +function toJsonAssistantMessageEvent( + event: MessageUpdateEvent["assistantMessageEvent"], +): JsonMessageUpdateEvent["assistantMessageEvent"] { + if (event.type === "toolcall_start") { + const toolCall = event.partial.content[event.contentIndex]; + if (toolCall?.type !== "toolCall") { + throw new Error(`toolcall_start content at index ${event.contentIndex} is not a tool call`); + } + const { partial: _partial, ...deltaEvent } = event; + return { ...deltaEvent, id: toolCall.id, toolName: toolCall.name }; + } + + if (!("partial" in event)) { + return event; + } + + const { partial: _partial, ...deltaEvent } = event; + return deltaEvent; +} /** * Remove cumulative assistant snapshots from streaming wire events. * `message_start` provides the initial message, deltas build it, and - * `message_end` provides the final authoritative message. Cumulative usage - * remains available because its size is constant. + * `message_end` provides the final authoritative message. Cumulative usage, + * tool-call ids, and tool names remain available because their size is constant. */ export function toJsonEvent(event: MessageUpdateEvent): JsonMessageUpdateEvent; export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent; @@ -36,11 +53,9 @@ export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent { throw new Error("message_update message is not an assistant message"); } - const assistantMessageEvent = event.assistantMessageEvent; - if (!("partial" in assistantMessageEvent)) { - return { type: "message_update", usage: event.message.usage, assistantMessageEvent }; - } - - const { partial: _partial, ...deltaEvent } = assistantMessageEvent; - return { type: "message_update", usage: event.message.usage, assistantMessageEvent: deltaEvent }; + return { + type: "message_update", + usage: event.message.usage, + assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent), + }; } diff --git a/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts b/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts new file mode 100644 index 00000000000..e58de9e9756 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts @@ -0,0 +1,43 @@ +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { toJsonEvent } from "../../../src/modes/json-event.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +describe("regression #7925: tool-call metadata is available when streaming starts", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + }); + + it("includes the tool call id and name without cumulative snapshots", async () => { + harness = await createHarness(); + harness.setResponses([ + fauxAssistantMessage( + fauxToolCall("write", { path: "output.txt", content: "x".repeat(100) }, { id: "call_7925" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("write a file"); + + const update = harness + .eventsOfType("message_update") + .find((event) => event.assistantMessageEvent.type === "toolcall_start"); + if (!update || update.message.role !== "assistant") { + throw new Error("Expected toolcall_start assistant update"); + } + + expect(toJsonEvent(update)).toEqual({ + type: "message_update", + usage: update.message.usage, + assistantMessageEvent: { + type: "toolcall_start", + contentIndex: 0, + id: "call_7925", + toolName: "write", + }, + }); + }); +}); From d57e531f5dc57974e2f9ce7a618730e5358a45db Mon Sep 17 00:00:00 2001 From: Seiji Toyama Date: Wed, 19 Aug 2026 19:42:27 +0900 Subject: [PATCH 221/284] fix(ai): round-trip Bedrock redacted reasoning (#8314) Bedrock Converse returns encrypted reasoning from non-Anthropic models (e.g. OpenAI GPT-5.6) as the opaque `reasoningContent.redactedContent` member. The stream handler only read `text` and `signature`, so the payload was dropped and replayed history lost the reasoning block. Buffer the delta bytes, encode them into `thinkingSignature` with `redacted: true` (the representation the Anthropic path already uses), and lower them back to `reasoningContent.redactedContent` on the next turn. Scratch state is stripped from every terminal path, since a stream can settle without stopping each block. --- .../ai/src/api/bedrock-converse-stream.ts | 104 ++++++- .../test/bedrock-redacted-reasoning.test.ts | 274 ++++++++++++++++++ 2 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 packages/ai/test/bedrock-redacted-reasoning.test.ts diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 57aab387f44..2e944fcd246 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -101,10 +101,18 @@ export interface BedrockOptions extends StreamOptions { bearerToken?: string; } -type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; +type Block = (TextContent | ThinkingContent | ToolCall) & { + index?: number; + partialJson?: string; + /** Scratch buffer for encrypted reasoning deltas, joined into `thinkingSignature`. */ + redactedChunks?: Uint8Array[]; +}; const EMPTY_TEXT_PLACEHOLDER = ""; +/** Matches the placeholder the Anthropic API path uses for redacted thinking. */ +const REDACTED_THINKING_PLACEHOLDER = "[Reasoning redacted]"; + export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, @@ -313,13 +321,13 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = throw new Error(output.errorMessage || "An unknown error occurred"); } + // A stream can settle without stopping every block, so finalize here too. + for (const block of output.content) finalizeStreamingBlock(block as Block); stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { for (const block of output.content) { - delete (block as Block).index; - // partialJson is only a streaming scratch buffer; never persist it. - delete (block as Block).partialJson; + finalizeStreamingBlock(block as Block); } output.stopReason = options.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatBedrockError(error); @@ -624,14 +632,56 @@ function handleContentBlockDelta( partial: output, }); } - if (delta.reasoningContent.signature) { + // `thinkingSignature` holds either an Anthropic signature or an opaque redacted + // payload, never both: mixing them would corrupt whichever arrived first. + if (delta.reasoningContent.signature && !thinkingBlock.redacted) { thinkingBlock.thinkingSignature = (thinkingBlock.thinkingSignature || "") + delta.reasoningContent.signature; } + if (delta.reasoningContent.redactedContent?.length) { + // Encrypted reasoning from non-Anthropic models on Bedrock (e.g. OpenAI GPT-5.6). + // The payload is opaque, so keep it verbatim in `thinkingSignature` the way the + // Anthropic path stores redacted thinking, and replay it on the next turn. + if (!thinkingBlock.redacted) { + thinkingBlock.redacted = true; + thinkingBlock.thinkingSignature = ""; + thinkingBlock.thinking += REDACTED_THINKING_PLACEHOLDER; + stream.push({ + type: "thinking_delta", + contentIndex: thinkingIndex, + delta: REDACTED_THINKING_PLACEHOLDER, + partial: output, + }); + } + thinkingBlock.redactedChunks ??= []; + thinkingBlock.redactedChunks.push(delta.reasoningContent.redactedContent); + } } } } +/** + * Encodes buffered encrypted reasoning into `thinkingSignature` and drops the scratch + * buffer, which must never reach a persisted message: `Uint8Array` serializes to an + * index-keyed object roughly ten times the size of the base64 payload. + */ +function flushRedactedContent(block: Block): void { + if (block.type !== "thinking" || !block.redactedChunks) return; + block.thinkingSignature = bytesToBase64(block.redactedChunks); + delete block.redactedChunks; +} + +/** + * Strips every streaming scratch field. Runs from the terminal paths as well as + * `contentBlockStop`, because a stream can settle without stopping each block. + */ +function finalizeStreamingBlock(block: Block): void { + delete block.index; + // partialJson is only a streaming scratch buffer; never persist it. + delete block.partialJson; + flushRedactedContent(block); +} + function handleMetadata( event: ConverseStreamMetadataEvent, model: Model<"bedrock-converse-stream">, @@ -663,6 +713,7 @@ function handleContentBlockStop( stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output }); break; case "thinking": + flushRedactedContent(block); stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output }); break; case "toolCall": @@ -936,6 +987,15 @@ function convertMessages( }); break; case "thinking": { + // Encrypted reasoning is opaque: replay the stored payload as the + // `redactedContent` member instead of lowering it to reasoning text. + if (c.redacted) { + const redactedContent = decodeRedactedContent(c.thinkingSignature); + if (redactedContent?.length) { + contentBlocks.push({ reasoningContent: { redactedContent } }); + } + continue; + } // Skip empty thinking blocks const thinking = sanitizeSurrogates(c.thinking); if (thinking.trim().length === 0) continue; @@ -1223,11 +1283,43 @@ function createImageBlock(mimeType: string, data: string) { throw new Error(`Unknown image type: ${mimeType}`); } + return { source: { bytes: base64ToBytes(data) }, format }; +} + +function base64ToBytes(data: string): Uint8Array { const binaryString = atob(data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } + return bytes; +} + +/** + * Decodes a stored redacted payload. The AWS SDK hands the blob over as bytes, but a + * persisted session carries it as base64. A hand-edited or externally produced session + * can hold a signature that is not base64; drop that block instead of failing the + * whole request. + */ +function decodeRedactedContent(signature: string | undefined): Uint8Array | undefined { + if (!signature) return undefined; + try { + return base64ToBytes(signature); + } catch { + return undefined; + } +} - return { source: { bytes }, format }; +function bytesToBase64(chunks: Uint8Array[]): string { + // Encrypted reasoning runs to tens of KB, so build the binary string in slices + // rather than one concatenation per byte. The window stays under the engine's + // argument-count limit for spread calls. + const WINDOW = 0x8000; + let binary = ""; + for (const chunk of chunks) { + for (let i = 0; i < chunk.length; i += WINDOW) { + binary += String.fromCharCode(...chunk.subarray(i, i + WINDOW)); + } + } + return btoa(binary); } diff --git a/packages/ai/test/bedrock-redacted-reasoning.test.ts b/packages/ai/test/bedrock-redacted-reasoning.test.ts new file mode 100644 index 00000000000..86301e155ae --- /dev/null +++ b/packages/ai/test/bedrock-redacted-reasoning.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * OpenAI models served through Bedrock Converse (e.g. `global.openai.gpt-5.6-terra`) + * return encrypted reasoning as the opaque `redactedContent` member of + * `reasoningContent`, not as `reasoningText`. The AWS SDK decodes the wire blob to + * `Uint8Array`. + * @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlockDelta.html + */ +const bedrockMock = vi.hoisted(() => { + const redactedBase64 = "cnNuXzVaVnJpZjRKMGJYSXFtV2RsZWRqN1FJRmVOaWtSUWJF"; + return { + redactedBase64, + redactedBytes: new Uint8Array(Buffer.from(redactedBase64, "base64")), + streamEvents: undefined as unknown[] | undefined, + }; +}); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + send(): Promise { + if (bedrockMock.streamEvents) { + const events = bedrockMock.streamEvents; + return Promise.resolve({ + $metadata: { httpStatusCode: 200 }, + stream: (async function* () { + yield* events; + })(), + }); + } + return Promise.reject(new Error("mock send")); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; +import type { Context, Message, Model, ThinkingContent } from "../src/types.ts"; + +const gptModel: Model<"bedrock-converse-stream"> = { + id: "global.openai.gpt-5.6-terra", + name: "GPT-5.6 Terra (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.ap-northeast-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, +}; + +const emptyUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +/** Mirrors the ConverseStream frames GPT-5.6 emits: encrypted reasoning, then text. */ +function redactedReasoningEvents(): unknown[] { + return [ + { messageStart: { role: "assistant" } }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { contentBlockDelta: { contentBlockIndex: 1, delta: { text: "done" } } }, + { contentBlockStop: { contentBlockIndex: 1 } }, + { messageStop: { stopReason: "end_turn" } }, + ]; +} + +interface BedrockRequestPayload { + messages: Array<{ role: string; content: Array> }>; +} + +async function capturePayload(context: Context): Promise { + let capturedPayload: BedrockRequestPayload | undefined; + const s = streamBedrock(gptModel, context, { + cacheRetention: "none", + signal: AbortSignal.abort(), + onPayload: (payload) => { + capturedPayload = payload as BedrockRequestPayload; + return payload; + }, + }); + for await (const event of s) { + if (event.type === "error") break; + } + if (!capturedPayload) { + throw new Error("Expected Bedrock payload to be captured before request abort"); + } + return capturedPayload; +} + +describe("Bedrock redacted reasoning", () => { + beforeEach(() => { + bedrockMock.streamEvents = undefined; + }); + + it("does not fail the stream when reasoning arrives as redactedContent", async () => { + bedrockMock.streamEvents = redactedReasoningEvents(); + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + expect(response.stopReason, response.errorMessage).not.toBe("error"); + // Reasoning precedes the answer, matching the order Bedrock streamed it. + expect(response.content.map((c) => c.type)).toEqual(["thinking", "text"]); + expect(response.content[1]).toEqual({ type: "text", text: "done" }); + }); + + it("preserves the encrypted reasoning payload on the assistant message", async () => { + bedrockMock.streamEvents = redactedReasoningEvents(); + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking).toBeDefined(); + // Same representation Anthropic redacted thinking already uses: the opaque + // payload rides in `thinkingSignature` with `redacted: true`. + expect(thinking?.redacted).toBe(true); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // The byte buffer is streaming scratch state: persisting it would bloat the + // session, since a Uint8Array serializes to an index-keyed object. + expect("redactedChunks" in thinking!).toBe(false); + }); + + it("encodes the payload when the stream never sends contentBlockStop", async () => { + bedrockMock.streamEvents = [ + { messageStart: { role: "assistant" } }, + { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + }, + }, + { messageStop: { stopReason: "end_turn" } }, + ]; + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // Streaming scratch state must not survive into the persisted message. + expect("redactedChunks" in thinking!).toBe(false); + expect("index" in thinking!).toBe(false); + }); + + it("joins encrypted reasoning split across deltas", async () => { + const [head, tail] = [bedrockMock.redactedBytes.slice(0, 7), bedrockMock.redactedBytes.slice(7)]; + bedrockMock.streamEvents = [ + { messageStart: { role: "assistant" } }, + { contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: head } } } }, + { contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: tail } } } }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: "end_turn" } }, + ]; + + const response = await streamBedrock(gptModel, { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + }).result(); + + const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking"); + expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64); + // The placeholder marks the block once, not once per delta. + expect(thinking?.thinking).toBe("[Reasoning redacted]"); + }); + + it("replays redacted reasoning as reasoningContent.redactedContent", async () => { + const messages: Message[] = [ + { role: "user", content: "hello", timestamp: Date.now() }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true }, + { type: "text", text: "done" }, + ], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: gptModel.id, + usage: emptyUsage, + stopReason: "stop", + timestamp: Date.now(), + }, + { role: "user", content: "continue", timestamp: Date.now() }, + ]; + + const payload = await capturePayload({ messages }); + + const assistant = payload.messages.find((m: any) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant!.content).toEqual([ + { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + { text: "done" }, + ]); + }); + + it("replays redacted reasoning before the toolUse block it belongs to", async () => { + // Bedrock rejects a tool continuation whose reasoning block is missing or + // reordered, so the opaque payload must land ahead of the matching toolUse. + const messages: Message[] = [ + { role: "user", content: "read the file", timestamp: Date.now() }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true }, + { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "/tmp/a.txt" } }, + ], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: gptModel.id, + usage: emptyUsage, + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + content: [{ type: "text", text: "file body" }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const payload = await capturePayload({ messages }); + + const assistant = payload.messages.find((m: any) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant!.content).toEqual([ + { reasoningContent: { redactedContent: bedrockMock.redactedBytes } }, + { toolUse: { toolUseId: "tool-1", name: "read", input: { path: "/tmp/a.txt" } } }, + ]); + }); +}); From 1e1a6e27be36aa6e4e607137a648bb342a9a2477 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 12:45:44 +0200 Subject: [PATCH 222/284] feat(coding-agent): include paths in settings errors --- .../coding-agent/src/core/settings-manager.ts | 40 ++++++++++++++++--- .../test/settings-manager.test.ts | 8 +++- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 22e72b8df07..8fb8cbaa1a6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -190,9 +190,20 @@ export interface SettingsStorage { export interface SettingsError { scope: SettingsScope; + path?: string; error: Error; } +type SettingsPaths = Partial>; + +function toSettingsError(scope: SettingsScope, error: unknown, path?: string): SettingsError { + return { + scope, + ...(path ? { path } : {}), + error: error instanceof Error ? error : new Error(String(error)), + }; +} + export class FileSettingsStorage implements SettingsStorage { private globalSettingsPath: string; private projectSettingsPath: string; @@ -293,6 +304,7 @@ export class SettingsManager { private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors private writeQueue: Promise = Promise.resolve(); private errors: SettingsError[]; + private settingsPaths: SettingsPaths; private constructor( storage: SettingsStorage, @@ -302,6 +314,7 @@ export class SettingsManager { projectLoadError: Error | null = null, initialErrors: SettingsError[] = [], projectTrusted = true, + settingsPaths: SettingsPaths = {}, ) { this.storage = storage; this.globalSettings = initialGlobal; @@ -310,6 +323,7 @@ export class SettingsManager { this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; + this.settingsPaths = settingsPaths; this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); } @@ -319,21 +333,35 @@ export class SettingsManager { agentDir: string = getAgentDir(), options: SettingsManagerCreateOptions = {}, ): SettingsManager { - const storage = new FileSettingsStorage(cwd, agentDir); - return SettingsManager.fromStorage(storage, options); + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + const storage = new FileSettingsStorage(resolvedCwd, resolvedAgentDir); + return SettingsManager.fromStorageWithPaths(storage, options, { + global: join(resolvedAgentDir, "settings.json"), + project: join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"), + }); } /** Create a SettingsManager from an arbitrary storage backend */ static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager { + return SettingsManager.fromStorageWithPaths(storage, options); + } + + /** Create a manager while retaining optional file paths for reported storage errors. */ + private static fromStorageWithPaths( + storage: SettingsStorage, + options: SettingsManagerCreateOptions, + settingsPaths: SettingsPaths = {}, + ): SettingsManager { const projectTrusted = options.projectTrusted ?? true; const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted); const initialErrors: SettingsError[] = []; if (globalLoad.error) { - initialErrors.push({ scope: "global", error: globalLoad.error }); + initialErrors.push(toSettingsError("global", globalLoad.error, settingsPaths.global)); } if (projectLoad.error) { - initialErrors.push({ scope: "project", error: projectLoad.error }); + initialErrors.push(toSettingsError("project", projectLoad.error, settingsPaths.project)); } return new SettingsManager( @@ -344,6 +372,7 @@ export class SettingsManager { projectLoad.error, initialErrors, projectTrusted, + settingsPaths, ); } @@ -546,8 +575,7 @@ export class SettingsManager { } private recordError(scope: SettingsScope, error: unknown): void { - const normalizedError = error instanceof Error ? error : new Error(String(error)); - this.errors.push({ scope, error: normalizedError }); + this.errors.push(toSettingsError(scope, error, this.settingsPaths[scope])); } private clearModifiedScope(scope: SettingsScope): void { diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 638a70f2329..3a2dbd0f79f 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -185,7 +185,7 @@ describe("SettingsManager", () => { expect(manager.getDefaultModel()).toBe("claude-sonnet"); }); - it("should keep previous settings when file is invalid", async () => { + it("should keep previous settings and report the file path when the file is invalid", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ theme: "dark" })); @@ -195,6 +195,7 @@ describe("SettingsManager", () => { await manager.reload(); expect(manager.getTheme()).toBe("dark"); + expect(manager.drainErrors()).toMatchObject([{ scope: "global", path: settingsPath }]); }); }); @@ -227,7 +228,10 @@ describe("SettingsManager", () => { const errors = manager.drainErrors(); expect(errors).toHaveLength(2); - expect(errors.map((e) => e.scope).sort()).toEqual(["global", "project"]); + expect(errors).toMatchObject([ + { scope: "global", path: globalSettingsPath }, + { scope: "project", path: projectSettingsPath }, + ]); expect(manager.drainErrors()).toEqual([]); }); }); From 913bcf3391be680a41fd6cb9acfe5d0fbfb3f7d9 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 12:55:36 +0200 Subject: [PATCH 223/284] fix(coding-agent): report settings diagnostic paths --- .../src/core/settings-diagnostics.ts | 25 ++++++++++ packages/coding-agent/src/main.ts | 15 ++---- .../test/settings-diagnostics.test.ts | 47 +++++++++++++++++++ 3 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/src/core/settings-diagnostics.ts create mode 100644 packages/coding-agent/test/settings-diagnostics.test.ts diff --git a/packages/coding-agent/src/core/settings-diagnostics.ts b/packages/coding-agent/src/core/settings-diagnostics.ts new file mode 100644 index 00000000000..8dfec3899af --- /dev/null +++ b/packages/coding-agent/src/core/settings-diagnostics.ts @@ -0,0 +1,25 @@ +import type { AgentSessionRuntimeDiagnostic } from "./agent-session-services.ts"; +import type { SettingsManager } from "./settings-manager.ts"; + +export function collectSettingsDiagnostics(settingsManager: SettingsManager): AgentSessionRuntimeDiagnostic[] { + return settingsManager.drainErrors().map(({ scope, path, error }) => ({ + type: "warning", + message: path ? `Invalid settings file ${path}: ${error.message}` : `Invalid ${scope} settings: ${error.message}`, + })); +} + +/** + * Remove duplicate type/message diagnostics while preserving their first occurrence. + * Startup and runtime settings managers can report the same file error. + */ +export function deduplicateDiagnostics( + diagnostics: readonly AgentSessionRuntimeDiagnostic[], +): AgentSessionRuntimeDiagnostic[] { + const seen = new Set(); + return diagnostics.filter((diagnostic) => { + const key = `${diagnostic.type}\0${diagnostic.message}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 85d9523591c..f9f626a9e15 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -56,6 +56,7 @@ import { type SessionCwdIssue, } from "./core/session-cwd.ts"; import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; +import { collectSettingsDiagnostics } from "./core/settings-diagnostics.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; @@ -92,16 +93,6 @@ async function readPipedStdin(): Promise { }); } -function collectSettingsDiagnostics( - settingsManager: SettingsManager, - context: string, -): AgentSessionRuntimeDiagnostic[] { - return settingsManager.drainErrors().map(({ scope, error }) => ({ - type: "warning", - message: `(${context}, ${scope} settings) ${error.message}`, - })); -} - function reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void { for (const diagnostic of diagnostics) { const color = diagnostic.type === "error" ? chalk.red : diagnostic.type === "warning" ? chalk.yellow : chalk.dim; @@ -656,7 +647,7 @@ export async function main(args: string[], options?: MainOptions) { time("runMigrations"); const startupSettingsManager = SettingsManager.create(cwd, agentDir); - reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); + reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager)); // Experimental first-time setup: theme choice and analytics opt-in. // Runs before any runtime services are created so the chosen settings apply everywhere. @@ -784,7 +775,7 @@ export async function main(args: string[], options?: MainOptions) { const diagnostics: AgentSessionRuntimeDiagnostic[] = [ ...projectTrustDiagnostics, ...services.diagnostics, - ...collectSettingsDiagnostics(settingsManager, "runtime creation"), + ...collectSettingsDiagnostics(settingsManager), ...resourceLoader.getExtensions().errors.map(({ path, error }) => ({ type: "error" as const, message: `Failed to load extension "${path}": ${error}`, diff --git a/packages/coding-agent/test/settings-diagnostics.test.ts b/packages/coding-agent/test/settings-diagnostics.test.ts new file mode 100644 index 00000000000..9bda0fd6a4c --- /dev/null +++ b/packages/coding-agent/test/settings-diagnostics.test.ts @@ -0,0 +1,47 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { collectSettingsDiagnostics, deduplicateDiagnostics } from "../src/core/settings-diagnostics.ts"; +import { SettingsManager, type SettingsStorage } from "../src/core/settings-manager.ts"; + +describe("settings diagnostics", () => { + it("includes the settings file path for file-backed storage", () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-settings-diagnostics-")); + const agentDir = join(tempDir, "agent"); + const settingsPath = join(agentDir, "settings.json"); + mkdirSync(agentDir); + writeFileSync(settingsPath, "{"); + + try { + const diagnostics = collectSettingsDiagnostics(SettingsManager.create(tempDir, agentDir)); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.type).toBe("warning"); + expect(diagnostics[0]?.message).toContain(`Invalid settings file ${settingsPath}:`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("falls back to the settings scope for storage without file paths", () => { + const storage: SettingsStorage = { + withLock(scope, fn) { + if (scope === "global") throw new Error("backend failed"); + fn(undefined); + }, + }; + const diagnostics = collectSettingsDiagnostics(SettingsManager.fromStorage(storage)); + + expect(diagnostics).toEqual([{ type: "warning", message: "Invalid global settings: backend failed" }]); + }); + + it("deduplicates diagnostics by type and message", () => { + const warning = { type: "warning" as const, message: "Invalid settings file /tmp/settings.json" }; + + expect(deduplicateDiagnostics([warning, warning, { ...warning, type: "error" }])).toEqual([ + warning, + { ...warning, type: "error" }, + ]); + }); +}); From 678f0af30d63650052fb3ed16e9b779af1a8f9e8 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 13:06:00 +0200 Subject: [PATCH 224/284] fix(coding-agent): show startup diagnostics in TUI fixes #7829 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/main.ts | 15 +++-- .../src/modes/interactive/interactive-mode.ts | 22 +++++++- .../7829-invalid-settings-warning.test.ts | 56 +++++++++++++++++++ 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84eecd8209a..0f9bcf02c76 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f9f626a9e15..ac8b8cf718e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -56,7 +56,7 @@ import { type SessionCwdIssue, } from "./core/session-cwd.ts"; import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; -import { collectSettingsDiagnostics } from "./core/settings-diagnostics.ts"; +import { collectSettingsDiagnostics, deduplicateDiagnostics } from "./core/settings-diagnostics.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; @@ -647,7 +647,7 @@ export async function main(args: string[], options?: MainOptions) { time("runMigrations"); const startupSettingsManager = SettingsManager.create(cwd, agentDir); - reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager)); + const startupSettingsDiagnostics = collectSettingsDiagnostics(startupSettingsManager); // Experimental first-time setup: theme choice and analytics opt-in. // Runs before any runtime services are created so the chosen settings apply everywhere. @@ -847,6 +847,7 @@ export async function main(args: string[], options?: MainOptions) { configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); if (parsed.help) { + reportDiagnostics(startupSettingsDiagnostics); const extensionFlags = resourceLoader .getExtensions() .extensions.flatMap((extension) => Array.from(extension.flags.values())); @@ -855,6 +856,7 @@ export async function main(args: string[], options?: MainOptions) { } if (parsed.listModels !== undefined) { + reportDiagnostics(startupSettingsDiagnostics); const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; await listModels(modelRuntime, searchPattern, AbortSignal.timeout(15_000)); process.exit(0); @@ -885,8 +887,12 @@ export async function main(args: string[], options?: MainOptions) { } time("resolveModelScope"); - reportDiagnostics(runtime.diagnostics); - if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) { + const startupDiagnostics = deduplicateDiagnostics([...startupSettingsDiagnostics, ...runtime.diagnostics]); + const hasRuntimeErrors = runtime.diagnostics.some((diagnostic) => diagnostic.type === "error"); + if (appMode !== "interactive" || hasRuntimeErrors) { + reportDiagnostics(startupDiagnostics); + } + if (hasRuntimeErrors) { if (runtime.diagnostics.some((diagnostic) => diagnostic.message.includes("Failed to load extension"))) { console.error(chalk.yellow(EXTENSION_LOAD_FAILURE_HINT)); } @@ -921,6 +927,7 @@ export async function main(args: string[], options?: MainOptions) { } else if (appMode === "interactive") { const interactiveMode = new InteractiveMode(runtime, { migratedProviders, + startupDiagnostics, modelFallbackMessage, autoTrustOnReloadCwd, initialMessage, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 76c69b5440c..b008f0bb71a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -58,6 +58,7 @@ import { } from "../../config.ts"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; +import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-services.ts"; import { CACHE_TTL_MS, type CacheMiss, @@ -332,6 +333,8 @@ function formatLoginProviderCompletionDescription(provider: LoginProviderComplet export interface InteractiveModeOptions { /** Providers that were migrated to auth.json (shows warning) */ migratedProviders?: string[]; + /** Diagnostics collected before the interactive TUI was initialized. */ + startupDiagnostics?: AgentSessionRuntimeDiagnostic[]; /** Warning message if session model couldn't be restored */ modelFallbackMessage?: string; /** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */ @@ -1090,7 +1093,24 @@ export class InteractiveMode { }); // Show startup warnings - const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options; + const { + migratedProviders, + startupDiagnostics, + modelFallbackMessage, + initialMessage, + initialImages, + initialMessages, + } = this.options; + + for (const diagnostic of startupDiagnostics ?? []) { + if (diagnostic.type === "error") { + this.showError(diagnostic.message); + } else if (diagnostic.type === "warning") { + this.showWarning(diagnostic.message); + } else { + this.showStatus(diagnostic.message); + } + } if (migratedProviders && migratedProviders.length > 0) { this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); diff --git a/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts b/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts new file mode 100644 index 00000000000..cb73e360264 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7829-invalid-settings-warning.test.ts @@ -0,0 +1,56 @@ +import { Container } from "@earendil-works/pi-tui"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { AgentSessionRuntimeDiagnostic } from "../../../src/core/agent-session-services.ts"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { createHarness } from "../harness.ts"; + +function render(container: Container): string { + return container.children.flatMap((child) => child.render(120)).join("\n"); +} + +describe("issue #7829 invalid settings warning", () => { + beforeAll(() => initTheme("dark")); + + it("renders startup diagnostics inside the transcript", async () => { + const harness = await createHarness(); + const previousOffline = process.env.PI_OFFLINE; + process.env.PI_OFFLINE = "1"; + try { + const chatContainer = new Container(); + const startupDiagnostics: AgentSessionRuntimeDiagnostic[] = [ + { + type: "warning", + message: "Invalid settings file /tmp/settings.json: malformed JSON", + }, + ]; + const context = { + init: vi.fn(async () => {}), + options: { startupDiagnostics }, + chatContainer, + outputPad: 1, + ui: { requestRender: vi.fn() }, + version: "test", + showWarning: (InteractiveMode.prototype as unknown as { showWarning(message: string): void }).showWarning, + session: harness.session, + checkForPackageUpdates: vi.fn().mockResolvedValue([]), + checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), + getUserInput: vi.fn(() => new Promise(() => {})), + }; + const run = (InteractiveMode.prototype as unknown as { run(this: typeof context): Promise }).run; + + void run.call(context); + + await vi.waitFor(() => { + expect(render(chatContainer)).toContain( + "Warning: Invalid settings file /tmp/settings.json: malformed JSON", + ); + }); + } finally { + if (previousOffline === undefined) delete process.env.PI_OFFLINE; + else process.env.PI_OFFLINE = previousOffline; + harness.cleanup(); + } + }); +}); From 3de00332f71a9a472d26cd9bca9dfc1961061e2b Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 19 Aug 2026 13:19:26 +0200 Subject: [PATCH 225/284] fix(ai): derive Z.AI reasoning effort metadata Use models.dev effort options for Z.AI models while preserving GLM-5.2's none effort. Expose GLM-5.3's low, high, and max levels. closes #8336 --- packages/ai/scripts/generate-models.ts | 118 +++++++++--------- .../openai-completions-tool-choice.test.ts | 28 ++++- 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index bf732af931b..d91c999166c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -235,13 +235,6 @@ const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ "upstage/solar-10.7b-instruct", ]); const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); -const ZAI_GLM52_THINKING_LEVEL_MAP = { - minimal: null, - low: "high", - medium: "high", - high: "high", - max: "max", -} as const; const OPENCODE_GO_GLM52_THINKING_LEVEL_MAP = { off: null, minimal: null, @@ -1175,6 +1168,66 @@ async function fetchAiGatewayModels(): Promise[]> { } } +function processZaiModels(data: ModelsDevCatalog): Model[] { + const variants = [ + { + source: "zai-coding-plan", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + }, + { + source: "zhipuai-coding-plan", + provider: "zai-coding-cn", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + }, + ] as const; + const models: Model[] = []; + + for (const { source, provider, baseUrl } of variants) { + for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + const supportsImage = m.modalities?.input?.includes("image"); + + const thinkingLevelMap = getEffortThinkingLevelMap(m.reasoning_options ?? []); + const isGlm52 = modelId === "glm-5.2" || modelId === "glm-5.2-highspeed"; + if (thinkingLevelMap && isGlm52) { + thinkingLevelMap.off = "none"; + } + const supportsReasoningEffort = thinkingLevelMap !== undefined; + const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider, + baseUrl, + reasoning: m.reasoning === true, + ...(thinkingLevelMap ? { thinkingLevelMap } : {}), + input: supportsImage ? ["text", "image"] : ["text"], + cost: { + input: referenceCost?.input || 0, + output: referenceCost?.output || 0, + cacheRead: referenceCost?.cache_read || 0, + cacheWrite: referenceCost?.cache_write || 0, + }, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + ...(supportsReasoningEffort ? { supportsReasoningEffort: true } : {}), + ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + recordModelsDevReasoningOptions(provider, modelId, m); + } + } + + return models; +} + function processBasetenModels(provider: ModelsDevProvider | undefined): Model[] { if (!provider?.models) return []; @@ -1697,56 +1750,7 @@ async function loadModelsDevData(): Promise[]> { } } - // Process zAi models - const zaiCodingPlanVariants = [ - { - source: "zai-coding-plan", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - }, - { - source: "zhipuai-coding-plan", - provider: "zai-coding-cn", - baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", - }, - ] as const; - - for (const { source, provider, baseUrl } of zaiCodingPlanVariants) { - for (const [modelId, model] of Object.entries(data[source]?.models ?? {})) { - const m = model as ModelsDevModel; - if (m.tool_call !== true) continue; - const supportsImage = m.modalities?.input?.includes("image"); - - const isGlm52 = modelId === "glm-5.2"; - const referenceCost = data.zai?.models[modelId]?.cost ?? m.cost; - - models.push({ - id: modelId, - name: m.name || modelId, - api: "openai-completions", - provider, - baseUrl, - reasoning: m.reasoning === true, - ...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}), - input: supportsImage ? ["text", "image"] : ["text"], - cost: { - input: referenceCost?.input || 0, - output: referenceCost?.output || 0, - cacheRead: referenceCost?.cache_read || 0, - cacheWrite: referenceCost?.cache_write || 0, - }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - ...(isGlm52 ? { supportsReasoningEffort: true } : {}), - ...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}), - }, - contextWindow: m.limit?.context || 4096, - maxTokens: m.limit?.output || 4096, - }); - recordModelsDevReasoningOptions(provider, modelId, m); - } - } + models.push(...processZaiModels(data)); // Process Mistral models if (data.mistral?.models) { diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 00ded1acf54..c453f3d5403 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -295,15 +295,31 @@ describe("openai-completions tool_choice", () => { expect(getModel("zai", "glm-5.2")?.compat?.zaiToolStream).toBe(true); }); - it("stores z.ai GLM-5.2 effort metadata", () => { + it("stores z.ai effort metadata", () => { for (const provider of ["zai", "zai-coding-cn"] as const) { - const model = getModel(provider, "glm-5.2")!; - expect(model.compat?.supportsReasoningEffort).toBe(true); - expect(model.thinkingLevelMap).toEqual({ + for (const modelId of ["glm-5.2", "glm-5.2-highspeed"] as const) { + const model = getModel(provider, modelId)!; + expect(model.compat?.supportsReasoningEffort).toBe(true); + expect(model.thinkingLevelMap).toEqual({ + off: "none", + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", + }); + } + + const glm53 = getModel(provider, "glm-5.3")!; + expect(glm53.compat?.supportsReasoningEffort).toBe(true); + expect(glm53.thinkingLevelMap).toEqual({ + off: null, minimal: null, - low: "high", - medium: "high", + low: "low", + medium: null, high: "high", + xhigh: null, max: "max", }); } From 4495469a5e8466eb67e3f26969922d3e7b208de2 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 19 Aug 2026 13:44:00 +0200 Subject: [PATCH 226/284] fix(coding-agent): compact without provider usage closes #8328 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 25 +++--- .../8328-zero-usage-auto-compaction.test.ts | 80 +++++++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0f9bcf02c76..99e42ebc326 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,6 +21,7 @@ - Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). - Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). - Fixed branch summary entries recording the navigation destination in `fromId` instead of the pre-navigation source leaf. +- Fixed threshold auto-compaction being skipped when providers omit streaming usage data ([#8328](https://github.com/earendil-works/pi/issues/8328)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 74280d56411..9f11c551137 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2099,17 +2099,20 @@ export class AgentSession { if (assistantMessage.stopReason === "error" || directContextTokens === 0) { const messages = this.agent.state.messages; const estimate = estimateContextTokens(messages); - if (estimate.lastUsageIndex === null) return false; // No usage data at all - // Verify the usage source is post-compaction. Kept pre-compaction messages - // have stale usage reflecting the old (larger) context and would falsely - // trigger compaction right after one just finished. - const usageMsg = messages[estimate.lastUsageIndex]; - if ( - compactionEntry && - usageMsg.role === "assistant" && - (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() - ) { - return false; + // Without provider usage, estimate.tokens is the pure message-size estimate. + // Only usage-backed estimates need the stale pre-compaction check. + if (estimate.lastUsageIndex !== null) { + // Verify the usage source is post-compaction. Kept pre-compaction messages + // have stale usage reflecting the old (larger) context and would falsely + // trigger compaction right after one just finished. + const usageMsg = messages[estimate.lastUsageIndex]; + if ( + compactionEntry && + usageMsg.role === "assistant" && + (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() + ) { + return false; + } } contextTokens = estimate.tokens; } else { diff --git a/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts b/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts new file mode 100644 index 00000000000..910690bee28 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8328-zero-usage-auto-compaction.test.ts @@ -0,0 +1,80 @@ +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +type SessionWithCompactionInternals = { + _checkCompaction: (assistantMessage: AssistantMessage) => Promise; + _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; +}; + +function createZeroUsageAssistant(harness: Harness): AssistantMessage { + const model = harness.getModel(); + return { + role: "assistant", + content: [{ type: "text", text: "response" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +describe("issue #8328 zero-usage auto-compaction", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + async function createCompactionHarness(): Promise { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100, maxTokens: 20 }], + settings: { compaction: { enabled: true, reserveTokens: 10 } }, + }); + harnesses.push(harness); + return harness; + } + + it("uses the message estimate when no assistant has reported usage", async () => { + const harness = await createCompactionHarness(); + const assistant = createZeroUsageAssistant(harness); + harness.session.agent.state.messages = [ + { role: "user", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() - 1 }, + assistant, + ]; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + + await sessionInternals._checkCompaction(assistant); + + expect(runAutoCompactionSpy).toHaveBeenCalledOnce(); + expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false); + }); + + it("does not compact when the zero-usage message estimate is below the threshold", async () => { + const harness = await createCompactionHarness(); + const assistant = createZeroUsageAssistant(harness); + harness.session.agent.state.messages = [ + { role: "user", content: [{ type: "text", text: "short" }], timestamp: Date.now() - 1 }, + assistant, + ]; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + + await sessionInternals._checkCompaction(assistant); + + expect(runAutoCompactionSpy).not.toHaveBeenCalled(); + }); +}); From 1355cd36e0b10a3e71c6c78f713b7b36458db27f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 19 Aug 2026 14:10:27 +0200 Subject: [PATCH 227/284] fix(coding-agent): normalize UTF-8 BOMs in text inputs closes #8337 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/cli/file-processor.ts | 3 +- packages/coding-agent/src/config.ts | 3 +- .../coding-agent/src/core/auth-storage.ts | 7 +-- packages/coding-agent/src/core/keybindings.ts | 3 +- .../coding-agent/src/core/model-config.ts | 3 +- .../coding-agent/src/core/models-store.ts | 3 +- .../coding-agent/src/core/package-manager.ts | 5 +- packages/coding-agent/src/core/pi-manifest.ts | 3 +- .../coding-agent/src/core/resource-loader.ts | 5 +- .../coding-agent/src/core/settings-manager.ts | 5 +- .../coding-agent/src/core/tools/edit-diff.ts | 8 +-- packages/coding-agent/src/core/tools/edit.ts | 4 +- .../coding-agent/src/core/trust-manager.ts | 3 +- packages/coding-agent/src/migrations.ts | 7 +-- .../src/modes/interactive/external-editor.ts | 3 +- .../src/modes/interactive/theme/theme.ts | 7 +-- .../coding-agent/src/utils/frontmatter.ts | 3 +- packages/coding-agent/src/utils/text.ts | 9 ++++ .../regressions/8337-utf8-bom-parsing.test.ts | 49 +++++++++++++++++++ 20 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 packages/coding-agent/src/utils/text.ts create mode 100644 packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 99e42ebc326..3744e2db809 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). - Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 4b3bf0e20fe..af3c1c10096 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -9,6 +9,7 @@ import { resolve } from "path"; import { resolveReadPath } from "../core/tools/path-utils.ts"; import { processImage } from "../utils/image-process.ts"; import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.ts"; +import { stripBom } from "../utils/text.ts"; export interface ProcessedFiles { text: string; @@ -73,7 +74,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process } else { // Handle text file try { - const content = await readFile(absolutePath, "utf-8"); + const content = stripBom(await readFile(absolutePath, "utf-8")); text += `\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 38049f05c9a..3a252e5c8c3 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; import { spawnProcessSync } from "./utils/child-process.ts"; import { normalizePath } from "./utils/paths.ts"; +import { stripBom } from "./utils/text.ts"; // ============================================================================= // Package Detection @@ -478,7 +479,7 @@ interface PackageJson { let pkg: PackageJson = {}; try { - pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; + pkg = JSON.parse(stripBom(readFileSync(getPackageJsonPath(), "utf-8"))) as PackageJson; } catch (e: unknown) { const err = e as NodeJS.ErrnoException; if (err.code !== "ENOENT") throw e; diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 602e03a808f..71be9d022ed 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -11,6 +11,7 @@ import { setTimeout as sleep } from "timers/promises"; import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { isCommandConfigValue, resolveConfigValue } from "./resolve-config-value.ts"; type AuthStorageData = Record; @@ -214,7 +215,7 @@ export class ReadOnlyAuthStorage implements CredentialStore { let parsed: unknown; try { - parsed = JSON.parse(readFileSync(this.authPath, "utf-8")); + parsed = JSON.parse(stripBom(readFileSync(this.authPath, "utf-8"))); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { this.data = {}; @@ -364,7 +365,7 @@ export class AuthStorage implements CredentialStore { if (!content) { return {}; } - return JSON.parse(content) as AuthStorageData; + return JSON.parse(stripBom(content)) as AuthStorageData; } private updateReadState(data: AuthStorageData, revision?: string): void { @@ -499,7 +500,7 @@ export function readStoredCredential( authPath: string = join(getAgentDir(), "auth.json"), ): Credential | undefined { try { - const data = JSON.parse(readFileSync(normalizePath(authPath), "utf-8")) as AuthStorageData; + const data = JSON.parse(stripBom(readFileSync(normalizePath(authPath), "utf-8"))) as AuthStorageData; return data[providerId]; } catch { return undefined; diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index ec5b135d27d..0524b2a567e 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -9,6 +9,7 @@ import { import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { getAgentDir } from "../config.ts"; +import { stripBom } from "../utils/text.ts"; export interface AppKeybindings { "app.interrupt": true; @@ -329,7 +330,7 @@ function orderKeybindingsConfig(config: Record): Record | undefined { if (!existsSync(path)) return undefined; try { - const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + const parsed = JSON.parse(stripBom(readFileSync(path, "utf-8"))) as unknown; if (typeof parsed !== "object" || parsed === null) return undefined; return parsed as Record; } catch { diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 7ea12cbebdd..f24a2793707 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -6,6 +6,7 @@ import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { stripJsonComments } from "../utils/json.ts"; import { normalizePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; const PercentileCutoffsSchema = Type.Object({ p50: Type.Optional(Type.Number()), @@ -259,7 +260,7 @@ export class ModelConfig { let parsed: unknown; try { - parsed = JSON.parse(stripJsonComments(content)); + parsed = JSON.parse(stripJsonComments(stripBom(content))); } catch (error) { return new ModelConfig( new Map(), diff --git a/packages/coding-agent/src/core/models-store.ts b/packages/coding-agent/src/core/models-store.ts index 8251b59b271..bfac3c5e76e 100644 --- a/packages/coding-agent/src/core/models-store.ts +++ b/packages/coding-agent/src/core/models-store.ts @@ -3,6 +3,7 @@ import type { ModelsStore, ModelsStoreEntry, ModelsStoreOperationOptions } from import { getAgentDir } from "../config.ts"; import { raceWithAbortSignal } from "../utils/abort.ts"; import { getFileRevision, normalizePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts"; type StoredModels = Record; @@ -59,7 +60,7 @@ export class FileModelsStore implements ModelsStore { } private parse(content: string | undefined): StoredModels { - return content ? (JSON.parse(content) as StoredModels) : {}; + return content ? (JSON.parse(stripBom(content)) as StoredModels) : {}; } private updateReadState(readState: ModelsFileReadState, data: StoredModels, revision?: string): void { diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index f0f98359c8f..805928c0822 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -32,6 +32,7 @@ import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { isStdoutTakenOver } from "./output-guard.ts"; import { type PiManifest, readPiManifest } from "./pi-manifest.ts"; import type { PackageSource, SettingsManager } from "./settings-manager.ts"; @@ -1479,7 +1480,7 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(packageJsonPath)) return undefined; try { const content = readFileSync(packageJsonPath, "utf-8"); - const pkg = JSON.parse(content) as { version?: string }; + const pkg = JSON.parse(stripBom(content)) as { version?: string }; return pkg.version; } catch { return undefined; @@ -1861,7 +1862,7 @@ export class DefaultPackageManager implements PackageManager { if (!existsSync(packageJsonPath)) return false; try { - const manifest = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { dependencies?: unknown }; + const manifest = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as { dependencies?: unknown }; if ( !manifest.dependencies || typeof manifest.dependencies !== "object" || diff --git a/packages/coding-agent/src/core/pi-manifest.ts b/packages/coding-agent/src/core/pi-manifest.ts index bff128cd5d6..fd7dd5edb6e 100644 --- a/packages/coding-agent/src/core/pi-manifest.ts +++ b/packages/coding-agent/src/core/pi-manifest.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { stripBom } from "../utils/text.ts"; export interface PiManifest { extensions?: string[]; @@ -15,7 +16,7 @@ function isObject(value: unknown): value is Record { export function readPiManifest(packageJsonPath: string): PiManifest | null { try { - const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + const pkg: unknown = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))); if (!isObject(pkg) || !isObject(pkg.pi)) { return null; } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index c24d0a21047..04102425b82 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -8,6 +8,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts"; export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; import { clearExtensionCache, @@ -57,7 +58,7 @@ function resolvePromptInput(input: string | undefined, description: string): str if (existsSync(input)) { try { - return readFileSync(input, "utf-8"); + return stripBom(readFileSync(input, "utf-8")); } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); return input; @@ -78,7 +79,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } } return { path: filePath, - content: readFileSync(filePath, "utf-8"), + content: stripBom(readFileSync(filePath, "utf-8")), }; } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 8fb8cbaa1a6..fa270064092 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -7,6 +7,7 @@ import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; export interface CompactionSettings { @@ -398,7 +399,7 @@ export class SettingsManager { if (!content) { return {}; } - const settings = JSON.parse(content); + const settings = JSON.parse(stripBom(content)); return SettingsManager.migrateSettings(settings); } @@ -619,7 +620,7 @@ export class SettingsManager { ): void { this.storage.withLock(scope, (current) => { const currentFileSettings = current - ? SettingsManager.migrateSettings(JSON.parse(current) as Record) + ? SettingsManager.migrateSettings(JSON.parse(stripBom(current)) as Record) : {}; const mergedSettings: Settings = { ...currentFileSettings }; for (const field of modifiedFields) { diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index 5a4d966b0e6..c79f5eb7b36 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -5,6 +5,7 @@ import * as Diff from "diff"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; +import { splitBom } from "../../utils/text.ts"; import { resolveToCwd } from "./path-utils.ts"; export function detectLineEnding(content: string): "\r\n" | "\n" { @@ -243,11 +244,6 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul }; } -/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ -export function stripBom(content: string): { bom: string; text: string } { - return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; -} - function countOccurrences(content: string, oldText: string): number { const fuzzyContent = normalizeForFuzzyMatch(content); const fuzzyOldText = normalizeForFuzzyMatch(oldText); @@ -535,7 +531,7 @@ export async function computeEditsDiff( const rawContent = await readFile(absolutePath, "utf-8"); // Strip BOM before matching (LLM won't include invisible BOM in oldText) - const { text: content } = stripBom(rawContent); + const { text: content } = splitBom(rawContent); const normalizedContent = normalizeToLF(content); const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 149a2e15767..eb124864c71 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -5,6 +5,7 @@ import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } import { type Static, Type } from "typebox"; import { renderDiff } from "../../modes/interactive/components/diff.ts"; import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import { splitBom } from "../../utils/text.ts"; import { getExperimentalToolSampling } from "../experimental.ts"; import type { ToolDefinition } from "../extensions/types.ts"; import { @@ -18,7 +19,6 @@ import { generateUnifiedPatch, normalizeToLF, restoreLineEndings, - stripBom, } from "./edit-diff.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; @@ -361,7 +361,7 @@ export function createEditToolDefinition( throwIfAborted(); // Strip BOM before matching. The model will not include an invisible BOM in oldText. - const { bom, text: content } = stripBom(rawContent); + const { bom, text: content } = splitBom(rawContent); const originalEnding = detectLineEnding(content); const normalizedContent = normalizeToLF(content); const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 9c494b47a39..0a560f7f92a 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME } from "../config.ts"; import { canonicalizePath, resolvePath } from "../utils/paths.ts"; +import { stripBom } from "../utils/text.ts"; export type ProjectTrustDecision = boolean | null; @@ -101,7 +102,7 @@ function readTrustFile(path: string): TrustFile { let parsed: unknown; try { - parsed = JSON.parse(readFileSync(path, "utf-8")); + parsed = JSON.parse(stripBom(readFileSync(path, "utf-8"))); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read trust store ${path}: ${message}`); diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 39aeea0438d..e1aa7941873 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, w import { dirname, join } from "path"; import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts"; import { migrateKeybindingsConfig } from "./core/keybindings.ts"; +import { stripBom } from "./utils/text.ts"; const MIGRATION_GUIDE_URL = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration"; @@ -33,7 +34,7 @@ export function migrateAuthToAuthJson(): string[] { // Migrate oauth.json if (existsSync(oauthPath)) { try { - const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); + const oauth = JSON.parse(stripBom(readFileSync(oauthPath, "utf-8"))); for (const [provider, cred] of Object.entries(oauth)) { migrated[provider] = { type: "oauth", ...(cred as object) }; providers.push(provider); @@ -48,7 +49,7 @@ export function migrateAuthToAuthJson(): string[] { if (existsSync(settingsPath)) { try { const content = readFileSync(settingsPath, "utf-8"); - const settings = JSON.parse(content); + const settings = JSON.parse(stripBom(content)); if (settings.apiKeys && typeof settings.apiKeys === "object") { for (const [provider, key] of Object.entries(settings.apiKeys)) { if (!migrated[provider] && typeof key === "string") { @@ -159,7 +160,7 @@ function migrateKeybindingsConfigFile(): void { if (!existsSync(configPath)) return; try { - const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown; + const parsed = JSON.parse(stripBom(readFileSync(configPath, "utf-8"))) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return; } diff --git a/packages/coding-agent/src/modes/interactive/external-editor.ts b/packages/coding-agent/src/modes/interactive/external-editor.ts index 45fb572f80d..d672d4e0807 100644 --- a/packages/coding-agent/src/modes/interactive/external-editor.ts +++ b/packages/coding-agent/src/modes/interactive/external-editor.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { stripBom } from "../../utils/text.ts"; export interface ExternalEditorOptions { command: string; @@ -34,7 +35,7 @@ export async function editInExternalEditor(options: ExternalEditorOptions): Prom return { status: "failed" }; } - return { status: "complete", content: readFileSync(filePath, "utf-8").replace(/\n$/, "") }; + return { status: "complete", content: stripBom(readFileSync(filePath, "utf-8")).replace(/\n$/, "") }; } finally { try { rmSync(directory, { recursive: true, force: true }); diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 6fa0c8c922f..c49bad29eb6 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -16,6 +16,7 @@ import { getCustomThemesDir, getThemesDir } from "../../../config.ts"; import type { SourceInfo } from "../../../core/source-info.ts"; import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts"; import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts"; +import { stripBom } from "../../../utils/text.ts"; // ============================================================================ // Types & Schema @@ -474,8 +475,8 @@ function getBuiltinThemes(): Record { const darkPath = path.join(themesDir, "dark.json"); const lightPath = path.join(themesDir, "light.json"); BUILTIN_THEMES = { - dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, - light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, + dark: JSON.parse(stripBom(fs.readFileSync(darkPath, "utf-8"))) as ThemeJson, + light: JSON.parse(stripBom(fs.readFileSync(lightPath, "utf-8"))) as ThemeJson, }; } return BUILTIN_THEMES; @@ -596,7 +597,7 @@ function parseThemeJson(label: string, json: unknown): ThemeJson { function parseThemeJsonContent(label: string, content: string): ThemeJson { let json: unknown; try { - json = JSON.parse(content); + json = JSON.parse(stripBom(content)); } catch (error) { throw new Error(`Failed to parse theme ${label}: ${error}`); } diff --git a/packages/coding-agent/src/utils/frontmatter.ts b/packages/coding-agent/src/utils/frontmatter.ts index 847e2e539ad..54481073763 100644 --- a/packages/coding-agent/src/utils/frontmatter.ts +++ b/packages/coding-agent/src/utils/frontmatter.ts @@ -1,4 +1,5 @@ import { parse } from "yaml"; +import { stripBom } from "./text.ts"; type ParsedFrontmatter> = { frontmatter: T; @@ -8,7 +9,7 @@ type ParsedFrontmatter> = { const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => { - const normalized = normalizeNewlines(content); + const normalized = normalizeNewlines(stripBom(content)); if (!normalized.startsWith("---")) { return { yamlString: null, body: normalized }; diff --git a/packages/coding-agent/src/utils/text.ts b/packages/coding-agent/src/utils/text.ts new file mode 100644 index 00000000000..737466e851b --- /dev/null +++ b/packages/coding-agent/src/utils/text.ts @@ -0,0 +1,9 @@ +/** Split a leading UTF-8 byte order mark from decoded text. */ +export function splitBom(content: string): { bom: string; text: string } { + return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; +} + +/** Remove a leading UTF-8 byte order mark from decoded text. */ +export function stripBom(content: string): string { + return splitBom(content).text; +} diff --git a/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts b/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts new file mode 100644 index 00000000000..eef0d710e39 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8337-utf8-bom-parsing.test.ts @@ -0,0 +1,49 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SettingsManager } from "../../../src/core/settings-manager.ts"; +import { parseFrontmatter } from "../../../src/utils/frontmatter.ts"; +import { splitBom } from "../../../src/utils/text.ts"; + +describe("issue #8337 UTF-8 BOM parsing", () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "pi-8337-")); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it("loads frontmatter and settings with a leading BOM", async () => { + expect(splitBom("\uFEFFcontent")).toEqual({ bom: "\uFEFF", text: "content" }); + const document = "---\nname: demo\ndescription: Test\n---\nBody"; + expect(parseFrontmatter(`\uFEFF${document}`)).toEqual({ + frontmatter: { name: "demo", description: "Test" }, + body: "Body", + }); + + const agentDir = join(testDir, "agent"); + const projectDir = join(testDir, "project"); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + const globalSettingsPath = join(agentDir, "settings.json"); + writeFileSync(globalSettingsPath, `\uFEFF${JSON.stringify({ defaultModel: "global-model" })}`); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + `\uFEFF${JSON.stringify({ defaultProvider: "project-provider" })}`, + ); + + const settings = SettingsManager.create(projectDir, agentDir); + expect(settings.getDefaultModel()).toBe("global-model"); + expect(settings.getDefaultProvider()).toBe("project-provider"); + + settings.setTheme("dark"); + await settings.flush(); + expect(readFileSync(globalSettingsPath, "utf-8")).not.toMatch(/^\uFEFF/); + }); +}); From 87af49dec23f5aee132071ef33d4b2441f551e4b Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 19 Aug 2026 15:56:26 +0200 Subject: [PATCH 228/284] Add pi user-agent to most api adapters (#8361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit added Pi’s default User-Agent to seven adapters: - openai-responses - openai-completions - anthropic-messages - azure-openai-responses - google-generative-ai - google-vertex - mistral-conversations Closes #8305 --- packages/ai/src/api/anthropic-messages.ts | 17 +-- packages/ai/src/api/azure-openai-responses.ts | 3 +- packages/ai/src/api/google-generative-ai.ts | 3 +- packages/ai/src/api/google-vertex.ts | 3 +- packages/ai/src/api/mistral-conversations.ts | 2 + packages/ai/src/api/openai-completions.ts | 8 +- packages/ai/src/api/openai-responses.ts | 8 +- packages/ai/src/utils/pi-user-agent.ts | 8 -- packages/ai/test/anthropic-auth-token.test.ts | 20 ++-- .../ai/test/azure-openai-base-url.test.ts | 24 ++++ .../ai/test/google-raw-stop-reason.test.ts | 32 ++++++ .../google-vertex-api-key-resolution.test.ts | 21 +++- .../ai/test/mistral-http-transport.test.ts | 7 +- packages/ai/test/xai-responses.test.ts | 106 ++++++++++-------- 14 files changed, 168 insertions(+), 94 deletions(-) diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index f36b54755e4..6c4ff62d08c 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -281,18 +281,8 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid return merged; } -function mergeClientHeaders( - model: Model<"anthropic-messages">, - ...headerSources: (ProviderHeaders | undefined)[] -): ProviderHeaders { - const merged = mergeHeaders(...headerSources); - if (model.provider === "kimi-coding") { - for (const name of Object.keys(merged)) { - if (name.toLowerCase() === "user-agent") delete merged[name]; - } - merged["User-Agent"] = getPiUserAgent(); - } - return merged; +function mergeClientHeaders(...headerSources: (ProviderHeaders | undefined)[]): ProviderHeaders { + return mergeHeaders({ "User-Agent": getPiUserAgent() }, ...headerSources); } function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean { @@ -917,7 +907,6 @@ function createClient( dangerouslyAllowBrowser: true, fetch, defaultHeaders: mergeClientHeaders( - model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -941,7 +930,6 @@ function createClient( dangerouslyAllowBrowser: true, fetch, defaultHeaders: mergeClientHeaders( - model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", @@ -961,7 +949,6 @@ function createClient( const sessionAffinityHeaders: ProviderHeaders = sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {}; const defaultHeaders = mergeClientHeaders( - model, { accept: "application/json", "anthropic-dangerous-direct-browser-access": "true", diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 3f106af87a7..56578805d2b 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -13,6 +13,7 @@ import type { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -253,7 +254,7 @@ function resolveAzureConfig( } function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) { - const headers = { ...model.headers }; + const headers = { "User-Agent": getPiUserAgent(), ...model.headers }; if (options?.headers) { Object.assign(headers, options.headers); diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index fc534f2b732..a0f9f39c978 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -22,6 +22,7 @@ import type { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; import { @@ -344,7 +345,7 @@ function createClient( httpOptions.baseUrl = model.baseUrl; httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append } - const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders }); + const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders }); if (headers) { httpOptions.headers = headers; } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index e0b7edc6551..cf8c13706a0 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -26,6 +26,7 @@ import type { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; @@ -391,7 +392,7 @@ function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: Provid } } - const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders }); + const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders }); if (headers) { httpOptions.headers = headers; } diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index d0834939759..64bb10815c2 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -17,6 +17,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -329,6 +330,7 @@ class MistralHttpError extends Error { function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers { const headers = new Headers({ + "User-Agent": getPiUserAgent(), accept: "text/event-stream", authorization: `Bearer ${apiKey}`, "content-type": "application/json", diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 8d968521a3e..9f89b4b24dd 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -40,7 +40,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; -import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -647,7 +647,7 @@ function createClient( sessionId?: string, compat: ResolvedOpenAICompletionsCompat = getCompat(model), ) { - const headers: ProviderHeaders = { ...model.headers }; + const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ @@ -674,10 +674,6 @@ function createClient( Object.assign(headers, optionsHeaders); } - if (model.provider === "xai") { - forcePiUserAgent(headers); - } - return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 52ff366d5a2..6bfb3d65b86 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -19,7 +19,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; -import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; +import { getPiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -224,7 +224,7 @@ function createClient( sessionId?: string, ) { const compat = getCompat(model); - const headers: ProviderHeaders = { ...model.headers }; + const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers }; if (model.provider === "github-copilot") { const hasImages = hasCopilotVisionInput(context.messages); const copilotHeaders = buildCopilotDynamicHeaders({ @@ -250,10 +250,6 @@ function createClient( Object.assign(headers, optionsHeaders); } - if (model.provider === "xai") { - forcePiUserAgent(headers); - } - return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts index c36d5ffbc89..93b23dd3511 100644 --- a/packages/ai/src/utils/pi-user-agent.ts +++ b/packages/ai/src/utils/pi-user-agent.ts @@ -1,5 +1,4 @@ import type * as NodeOs from "node:os"; -import type { ProviderHeaders } from "../types.ts"; type ProcessWithOsBuiltinModule = typeof process & { getBuiltinModule?: (id: "node:os") => typeof NodeOs; @@ -18,10 +17,3 @@ const nodeOs = loadNodeOs(); export function getPiUserAgent(): string { return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; } - -export function forcePiUserAgent(headers: ProviderHeaders): void { - for (const name of Object.keys(headers)) { - if (name.toLowerCase() === "user-agent") delete headers[name]; - } - headers["User-Agent"] = getPiUserAgent(); -} diff --git a/packages/ai/test/anthropic-auth-token.test.ts b/packages/ai/test/anthropic-auth-token.test.ts index a80a021dbeb..2bad50181a4 100644 --- a/packages/ai/test/anthropic-auth-token.test.ts +++ b/packages/ai/test/anthropic-auth-token.test.ts @@ -52,6 +52,7 @@ vi.mock("@anthropic-ai/sdk", () => { return { default: FakeAnthropic }; }); +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; const neverAbortedSignal = new AbortController().signal; const context: Context = { @@ -196,21 +197,20 @@ describe("Anthropic auth token env", () => { }); describe("Anthropic-compatible user agents", () => { - it("enforces the pi runtime user agent for Kimi Coding", async () => { - await streamAnthropic(kimiCodingModel, context, { - apiKey: "kimi-key", - headers: { "user-agent": "custom-client" }, - }).result(); + it("uses pi's User-Agent by default for Anthropic Messages requests", async () => { + await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result(); const headers = mockState.constructorOpts?.defaultHeaders as Record; - const userAgentHeaders = Object.entries(headers).filter(([name]) => name.toLowerCase() === "user-agent"); - expect(userAgentHeaders).toEqual([["User-Agent", `pi (${platform()} ${release()}; ${arch()})`]]); + expect(headers["User-Agent"]).toBe(PI_USER_AGENT); }); - it("does not apply the pi runtime user agent to Anthropic", async () => { - await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result(); + it("lets explicit headers override the default Anthropic Messages User-Agent", async () => { + await streamAnthropic(kimiCodingModel, context, { + apiKey: "kimi-key", + headers: { "User-Agent": "custom-client" }, + }).result(); const headers = mockState.constructorOpts?.defaultHeaders as Record; - expect(Object.keys(headers).some((name) => name.toLowerCase() === "user-agent")).toBe(false); + expect(headers["User-Agent"]).toBe("custom-client"); }); }); diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 12e3fb25e6a..b317fd79f00 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts"; @@ -40,6 +41,8 @@ vi.mock("openai", () => { return { AzureOpenAI }; }); +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; @@ -92,6 +95,17 @@ async function captureClientBaseUrl(baseUrl: string): Promise { return azureMock.constructorCalls[0].baseURL; } +async function captureClientHeaders(headers?: Record): Promise> { + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { + apiKey: "test-api-key", + azureBaseUrl: "https://my-resource.openai.azure.com", + headers, + }).result(); + expect(azureMock.constructorCalls).toHaveLength(1); + return azureMock.constructorCalls[0].defaultHeaders ?? {}; +} + describe("azure-openai-responses base URL normalization", () => { it("normalizes Cognitive Services root endpoints to /openai/v1", async () => { const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.cognitiveservices.azure.com"); @@ -201,3 +215,13 @@ describe("azure-openai-responses base URL normalization", () => { expect(azureMock.constructorCalls[0].baseURL).toBe("https://my-resource.openai.azure.com/openai/v1"); }); }); + +describe("azure-openai-responses user agent", () => { + it("uses pi's User-Agent by default", async () => { + expect((await captureClientHeaders())["User-Agent"]).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default User-Agent", async () => { + expect((await captureClientHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent"); + }); +}); diff --git a/packages/ai/test/google-raw-stop-reason.test.ts b/packages/ai/test/google-raw-stop-reason.test.ts index ccd85b0a429..245c0d7251a 100644 --- a/packages/ai/test/google-raw-stop-reason.test.ts +++ b/packages/ai/test/google-raw-stop-reason.test.ts @@ -1,12 +1,18 @@ +import { arch, platform, release } from "node:os"; import { describe, expect, it, vi } from "vitest"; const googleGenAiMock = vi.hoisted(() => ({ + constructorCalls: [] as Array>, finishReason: "MALFORMED_FUNCTION_CALL", includeFunctionCall: false, })); vi.mock("@google/genai", () => { class GoogleGenAI { + constructor(config: Record) { + googleGenAiMock.constructorCalls.push(config); + } + models = { generateContentStream: async function* () { yield { @@ -84,10 +90,26 @@ import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts"; import { getModel } from "../src/compat.ts"; import type { Context } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], }; +async function captureGoogleHeaders(headers?: Record): Promise> { + googleGenAiMock.constructorCalls.length = 0; + googleGenAiMock.finishReason = "STOP"; + googleGenAiMock.includeFunctionCall = false; + await streamGoogleGenerativeAi(getModel("google", "gemini-2.5-flash"), context, { + apiKey: "test-api-key", + headers, + }).result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + const httpOptions = googleGenAiMock.constructorCalls[0].httpOptions as { headers?: Record }; + return httpOptions.headers ?? {}; +} + describe("Google raw stop reasons", () => { it("preserves raw Gemini finish reasons for Google Generative AI errors", async () => { googleGenAiMock.finishReason = "MALFORMED_FUNCTION_CALL"; @@ -160,3 +182,13 @@ describe("Google raw stop reasons", () => { expect(message.content.some((block) => block.type === "toolCall")).toBe(true); }); }); + +describe("Google Generative AI user agent", () => { + it("uses pi's User-Agent by default", async () => { + expect((await captureGoogleHeaders())["User-Agent"]).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default User-Agent", async () => { + expect((await captureGoogleHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent"); + }); +}); diff --git a/packages/ai/test/google-vertex-api-key-resolution.test.ts b/packages/ai/test/google-vertex-api-key-resolution.test.ts index 46f24a773c8..3937b3feb24 100644 --- a/packages/ai/test/google-vertex-api-key-resolution.test.ts +++ b/packages/ai/test/google-vertex-api-key-resolution.test.ts @@ -1,3 +1,4 @@ +import { arch, platform, release } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const googleGenAiMock = vi.hoisted(() => ({ @@ -49,6 +50,7 @@ import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts"; import { getModel } from "../src/compat.ts"; import type { Context, Model } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; const model = getModel("google-vertex", "gemini-3-flash-preview"); const context: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], @@ -154,7 +156,24 @@ describe("google-vertex api key resolution", () => { await stream.result(); expect(googleGenAiMock.constructorCalls).toHaveLength(1); - expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toBeUndefined(); + expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({ + headers: { "User-Agent": PI_USER_AGENT }, + }); + }); + + it("lets explicit headers override the default User-Agent", async () => { + const stream = streamGoogleVertex(model, context, { + project: "test-project", + location: "us-central1", + headers: { "User-Agent": "custom-agent" }, + }); + + await stream.result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toEqual({ + headers: { "User-Agent": "custom-agent" }, + }); }); it("forwards custom baseUrl to the ADC client", async () => { diff --git a/packages/ai/test/mistral-http-transport.test.ts b/packages/ai/test/mistral-http-transport.test.ts index dde2d111ec7..3622fb56cf7 100644 --- a/packages/ai/test/mistral-http-transport.test.ts +++ b/packages/ai/test/mistral-http-transport.test.ts @@ -1,9 +1,12 @@ +import { arch, platform, release } from "node:os"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { stream as streamMistral } from "../src/api/mistral-conversations.ts"; import { getModel } from "../src/compat.ts"; import type { Context, FetchFunction, ProviderResponse } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + function createSseResponse(events: unknown[], headers?: Record): Response { const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`; return new Response(body, { @@ -109,6 +112,7 @@ describe("Mistral HTTP transport", () => { expect(headers.get("accept")).toBe("text/event-stream"); expect(headers.get("x-affinity")).toBe("session-1"); expect(headers.get("x-custom")).toBe("value"); + expect(headers.get("user-agent")).toBe(PI_USER_AGENT); expect(callbackPayload?.maxTokens).toBe(123); expect(callbackPayload?.promptMode).toBe("reasoning"); expect(callbackPayload?.promptCacheKey).toBe("session-1"); @@ -356,11 +360,12 @@ describe("Mistral HTTP transport", () => { apiKey: "request-key", fetch, sessionId: "automatic-affinity", - headers: { authorization: null, "x-affinity": null }, + headers: { authorization: null, "x-affinity": null, "User-Agent": "custom-agent" }, }).result(); expect(requestHeaders?.has("authorization")).toBe(false); expect(requestHeaders?.has("x-affinity")).toBe(false); + expect(requestHeaders?.get("user-agent")).toBe("custom-agent"); }); it("aborts while waiting for an SSE chunk", async () => { diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index 00e89081523..601b5359d6d 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -38,6 +38,53 @@ function completedResponse(): Response { }); } +const customCompletionsModel: Model<"openai-completions"> = { + id: "grok-custom", + name: "Grok Custom", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, +}; + +async function captureCompletionsUserAgent(headers?: Record): Promise { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + const chunks = [ + { id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] }, + { + id: "chatcmpl-ua", + choices: [{ delta: {}, finish_reason: "stop", index: 0 }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + const result = await streamOpenAICompletions( + customCompletionsModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token", headers }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + return userAgent; +} + async function captureRequest( model: Model<"openai-responses">, context: Context, @@ -184,7 +231,7 @@ describe("xAI Responses provider", () => { }); }); - it("keeps the SDK User-Agent for non-xAI Responses requests", async () => { + it("uses pi's User-Agent by default for Responses requests", async () => { let userAgent: string | null = null; vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { userAgent = new Request(input, init).headers.get("user-agent"); @@ -203,53 +250,24 @@ describe("xAI Responses provider", () => { ).result(); expect(result.stopReason, result.errorMessage).toBe("stop"); - expect(userAgent).not.toBeNull(); - expect(userAgent).not.toBe(PI_USER_AGENT); + expect(userAgent).toBe(PI_USER_AGENT); }); - it("forces pi's User-Agent on custom xAI Completions models over caller headers", async () => { - let userAgent: string | null = null; - vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { - userAgent = new Request(input, init).headers.get("user-agent"); - const chunks = [ - { id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] }, - { - id: "chatcmpl-ua", - choices: [{ delta: {}, finish_reason: "stop", index: 0 }], - usage: { - prompt_tokens: 1, - completion_tokens: 1, - prompt_tokens_details: { cached_tokens: 0 }, - completion_tokens_details: { reasoning_tokens: 0 }, - }, - }, - ]; - const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`; - return new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); - }); - - const customModel: Model<"openai-completions"> = { - id: "grok-custom", - name: "Grok Custom", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 16384, - }; - const result = await streamOpenAICompletions( - customModel, + it("lets explicit headers override the default Responses User-Agent", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.5"], { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, { apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } }, - ).result(); + ); - expect(result.stopReason, result.errorMessage).toBe("stop"); - expect(userAgent).toBe(PI_USER_AGENT); + expect(captured.headers.get("user-agent")).toBe("custom-agent"); + }); + + it("uses pi's User-Agent by default for Completions requests", async () => { + expect(await captureCompletionsUserAgent()).toBe(PI_USER_AGENT); + }); + + it("lets explicit headers override the default Completions User-Agent", async () => { + expect(await captureCompletionsUserAgent({ "User-Agent": "custom-agent" })).toBe("custom-agent"); }); }); From 4ca636c5e07eb1e0fbc6be6c11c720d1a8856daa Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:44:17 +0200 Subject: [PATCH 229/284] feat(ai): opeani completions reasoning details (#8246) * fix(ai): opeani completions reasoning details * cleanup: type i didnt like * changelog --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/api/openai-completions.ts | 136 ++++++++++++++---- packages/ai/src/types.ts | 1 + ...enai-completions-reasoning-details.test.ts | 50 +++++++ packages/server/src/protocol.ts | 1 + 5 files changed, 163 insertions(+), 26 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 324a0c6d89d..49d2ce9e55a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` (`reasoning.text`, `reasoning.summary`, and `reasoning.encrypted`) verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)). - Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). - Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. - Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 9f89b4b24dd..a36ce4cb145 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -18,6 +18,7 @@ import type { ChatTemplateKwargValue, Context, ImageContent, + JsonValue, Message, Model, OpenAICompletionsCompat, @@ -128,17 +129,45 @@ function isImageContentBlock(block: { type: string }): block is ImageContent { return block.type === "image"; } -function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { - if (typeof detail !== "object" || detail === null) { +function isReasoningDetailObject(detail: unknown): detail is Record { + return typeof detail === "object" && detail !== null && !Array.isArray(detail); +} + +function hasValidCommonReasoningDetailFields(candidate: Record): boolean { + return ( + (candidate.id === undefined || candidate.id === null || typeof candidate.id === "string") && + (candidate.format === undefined || typeof candidate.format === "string") && + (candidate.index === undefined || typeof candidate.index === "number") + ); +} + +function isOpenAIReasoningDetail(detail: unknown): detail is OpenAIReasoningDetail { + if (!isReasoningDetailObject(detail) || !hasValidCommonReasoningDetailFields(detail)) { return false; } - const candidate = detail as Record; + switch (detail.type) { + case "reasoning.summary": + return typeof detail.summary === "string"; + case "reasoning.encrypted": + return typeof detail.data === "string"; + case "reasoning.text": + return ( + typeof detail.text === "string" && + (detail.signature === undefined || detail.signature === null || typeof detail.signature === "string") + ); + default: + return false; + } +} + +function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { return ( - candidate.type === "reasoning.encrypted" && - typeof candidate.id === "string" && - candidate.id.length > 0 && - typeof candidate.data === "string" && - candidate.data.length > 0 + isReasoningDetailObject(detail) && + detail.type === "reasoning.encrypted" && + typeof detail.id === "string" && + detail.id.length > 0 && + typeof detail.data === "string" && + detail.data.length > 0 ); } @@ -177,12 +206,44 @@ type KimiToolSystemMessageParam = { tools: OpenAI.Chat.Completions.ChatCompletionTool[]; }; -type OpenAIEncryptedReasoningDetail = { +type OpenAIReasoningDetailBase = Record & { + id?: string | null; + format?: string; + index?: number; +}; + +type OpenAIReasoningSummaryDetail = OpenAIReasoningDetailBase & { + type: "reasoning.summary"; + summary: string; +}; + +type OpenAIEncryptedReasoningDetail = OpenAIReasoningDetailBase & { type: "reasoning.encrypted"; id: string; data: string; }; +type OpenAIReasoningTextDetail = OpenAIReasoningDetailBase & { + type: "reasoning.text"; + text: string; + signature?: string | null; +}; + +type OpenAIReasoningDetail = OpenAIReasoningSummaryDetail | OpenAIEncryptedReasoningDetail | OpenAIReasoningTextDetail; + +const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const; + +type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number]; + +function isOpenAICompletionsReasoningField(field: string): field is OpenAICompletionsReasoningField { + return OPENAI_COMPLETIONS_REASONING_FIELDS.includes(field as OpenAICompletionsReasoningField); +} + +type ChatCompletionAssistantMessageParamWithReasoning = ChatCompletionAssistantMessageParam & + Partial> & { + reasoning_details?: JsonValue[]; + }; + type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { cache_control?: OpenAICompatCacheControl; }; @@ -555,6 +616,15 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; if (Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { + if (!isOpenAIReasoningDetail(detail)) { + continue; + } + output.reasoningDetails ??= []; + // OpenRouter requires reasoning_details to be replayed unmodified and in order. + output.reasoningDetails.push(detail); + + // Keep the legacy encrypted tool-call attachment path for compatibility with + // sessions that replay encrypted details from toolCall.thoughtSignature. if (isEncryptedReasoningDetail(detail)) { const serializedDetail = JSON.stringify(detail); const matchingToolCall = toolCallBlocksById.get(detail.id); @@ -1150,7 +1220,7 @@ export function convertMessages( } } else if (msg.role === "assistant") { // Some providers don't accept null content, use empty string instead - const assistantMsg: ChatCompletionAssistantMessageParam = { + const assistantMsg: ChatCompletionAssistantMessageParamWithReasoning = { role: "assistant", content: compat.requiresAssistantAfterToolResult ? "" : null, }; @@ -1192,8 +1262,8 @@ export function convertMessages( if (model.provider === "opencode-go" && signature === "reasoning") { signature = "reasoning_content"; } - if (signature && signature.length > 0) { - (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + if (signature && isOpenAICompletionsReasoningField(signature)) { + assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); } } } else if (assistantText.length > 0) { @@ -1205,6 +1275,14 @@ export function convertMessages( assistantMsg.content = assistantText; } + const preservedReasoningDetails = + msg.provider === model.provider && + msg.api === model.api && + msg.model === model.id && + msg.reasoningDetails?.length + ? msg.reasoningDetails + : undefined; + const toolCalls = msg.content.filter(isToolCallBlock); if (toolCalls.length > 0) { assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => { @@ -1228,26 +1306,32 @@ export function convertMessages( }, }; }); - const reasoningDetails = toolCalls - .filter((tc) => tc.thoughtSignature) - .map((tc) => { - try { - return JSON.parse(tc.thoughtSignature!); - } catch { - return null; - } - }) - .filter(Boolean); - if (reasoningDetails.length > 0) { - (assistantMsg as any).reasoning_details = reasoningDetails; + if (!preservedReasoningDetails) { + const reasoningDetails = toolCalls + .filter((tc) => tc.thoughtSignature) + .map((tc): OpenAIReasoningDetail | null => { + try { + const parsed = JSON.parse(tc.thoughtSignature!) as unknown; + return isOpenAIReasoningDetail(parsed) ? parsed : null; + } catch { + return null; + } + }) + .filter((detail): detail is OpenAIReasoningDetail => detail !== null); + if (reasoningDetails.length > 0) { + assistantMsg.reasoning_details = reasoningDetails; + } } } + if (preservedReasoningDetails) { + assistantMsg.reasoning_details = preservedReasoningDetails; + } if ( compat.requiresReasoningContentOnAssistantMessages && model.reasoning && - (assistantMsg as { reasoning_content?: string }).reasoning_content === undefined + assistantMsg.reasoning_content === undefined ) { - (assistantMsg as { reasoning_content?: string }).reasoning_content = ""; + assistantMsg.reasoning_content = ""; } // Skip assistant messages that have no content and no tool calls. // Some providers require "either content or tool_calls, but not none". diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b241038199d..7647226b716 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -432,6 +432,7 @@ export interface AssistantMessage { model: string; responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one + reasoningDetails?: JsonValue[]; // Provider-specific structured reasoning details to replay verbatim on later same-model turns. diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. usage: Usage; stopReason: StopReason; diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index 88d42874488..ae41b938f92 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -38,6 +38,21 @@ vi.mock("openai", () => { }); const reasoningDetail = { type: "reasoning.encrypted", id: "call_1", data: "encrypted-signature" }; +const signedReasoningTextDetail = { + type: "reasoning.text", + text: "I should call the read tool.", + signature: "sha256:signed-text", + id: "reasoning-text-1", + format: "anthropic-claude-v1", + index: 0, +}; +const reasoningSummaryDetail = { + type: "reasoning.summary", + summary: "Decided to inspect the requested file.", + id: "reasoning-summary-1", + format: "anthropic-claude-v1", + index: 1, +}; const readTool: Tool = { name: "read", description: "Read a file", @@ -110,9 +125,44 @@ describe("openai-completions reasoning_details streaming", () => { arguments: { path: "README.md" }, thoughtSignature: JSON.stringify(reasoningDetail), }); + expect(assistantMessage.reasoningDetails).toEqual([reasoningDetail]); + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]); + }); + + it("falls back to encrypted tool-call signatures for older stored assistant messages", async () => { + mockState.chunkSets = [ + [chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + delete assistantMessage.reasoningDetails; await runOpenAICompletionsStream([assistantMessage]); expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]); }); + + it("preserves signed text and summary reasoning_details in their original sequence", async () => { + mockState.chunkSets = [ + [ + chunk({ reasoning_details: [signedReasoningTextDetail] }), + chunk({ reasoning_details: [reasoningDetail, reasoningSummaryDetail] }), + toolCallChunk(), + chunk({}, "tool_calls"), + ], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + const expectedReasoningDetails = [signedReasoningTextDetail, reasoningDetail, reasoningSummaryDetail]; + expect(assistantMessage.reasoningDetails).toEqual(expectedReasoningDetails); + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); + }); }); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 069828e590e..08ebfbf32f3 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -88,6 +88,7 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "model" | "responseModel" | "responseId" + | "reasoningDetails" | "diagnostics" | "usage" | "stopReason" From 2ff8ba62238e61642fe80985175774b163f77cfa Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:48:29 +0200 Subject: [PATCH 230/284] fix(coding-agent): keep model and thinking level changes session scoped (#8356) * fix(coding-agent): keep model and thinking level changes session scoped * fix(coding-agent): persist requested default thinking level * feat: --default arg to persist * fix * fix(coding-agent): keep model and thinking level changes session scoped * fix: make default thinking level per model show list of models * cleanup export --- .../coding-agent/src/core/agent-session.ts | 90 ++++-- packages/coding-agent/src/core/defaults.ts | 9 + .../coding-agent/src/core/model-resolver.ts | 13 +- packages/coding-agent/src/core/sdk.ts | 9 +- .../coding-agent/src/core/settings-manager.ts | 28 ++ .../coding-agent/src/core/slash-commands.ts | 2 +- .../interactive/components/model-selector.ts | 6 - .../components/settings-selector.ts | 276 +++++++++++++----- .../components/settings-submenu.ts | 196 +++++++++++++ .../src/modes/interactive/interactive-mode.ts | 130 +++++++-- .../test/interactive-mode-status.test.ts | 64 +++- .../coding-agent/test/model-selector.test.ts | 1 - .../test/settings-selector.test.ts | 3 + .../agent-session-model-extension.test.ts | 108 ++++++- .../3217-scoped-model-order.test.ts | 1 - .../6999-models-json-hot-reload.test.ts | 2 - ...l-selector-filter-resets-selection.test.ts | 2 - packages/tui/src/components/settings-list.ts | 49 +++- 18 files changed, 836 insertions(+), 153 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/settings-submenu.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9f11c551137..1be95d1d178 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -65,7 +65,7 @@ import { prepareCompaction, shouldCompact, } from "./compaction/index.ts"; -import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts"; import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts"; import { @@ -252,6 +252,12 @@ export interface PromptOptions { preflightResult?: (success: boolean) => void; } +/** Options for model/thinking mutations. */ +export interface ModelMutationOptions { + /** Persist the new value to global defaults. Defaults to session-only. */ + persist?: boolean; +} + /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; @@ -297,9 +303,6 @@ function estimateMessagesTokens(messages: AgentMessage[]): number { // Constants // ============================================================================ -/** Standard thinking levels */ -const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; - // ============================================================================ // AgentSession Class // ============================================================================ @@ -1588,21 +1591,26 @@ export class AgentSession { /** * Set model directly. - * Validates that auth is configured, saves to session and settings. + * Validates that auth is configured and saves to the session transcript. + * Persists to global defaults only when options.persist is true. * @throws Error if no auth is configured for the model */ - async setModel(model: Model): Promise { + async setModel(model: Model, options: ModelMutationOptions = {}): Promise { if (!(await this._modelRuntime.checkAuth(model.provider))) { throw new Error(`No API key for ${model.provider}/${model.id}`); } const previousModel = this.model; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); + const thinkingLevel = this._getThinkingLevelForModelSwitch(model); this.agent.state.model = model; this.sessionManager.appendModelChange(model.provider, model.id); - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + } - // Re-clamp thinking level for new model's capabilities + // Re-clamp session thinking level for new model's capabilities. + // Per-model thinking level overrides take priority over session carry-over. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(model, previousModel, "set"); @@ -1614,14 +1622,20 @@ export class AgentSession { * @param direction - "forward" (default) or "backward" * @returns The new model info, or undefined if only one model available */ - async cycleModel(direction: "forward" | "backward" = "forward"): Promise { + async cycleModel( + direction: "forward" | "backward" = "forward", + options: ModelMutationOptions = {}, + ): Promise { if (this._scopedModels.length > 0) { - return this._cycleScopedModel(direction); + return this._cycleScopedModel(direction, options); } - return this._cycleAvailableModel(direction); + return this._cycleAvailableModel(direction, options); } - private async _cycleScopedModel(direction: "forward" | "backward"): Promise { + private async _cycleScopedModel( + direction: "forward" | "backward", + options: ModelMutationOptions, + ): Promise { const availableIds = new Set( this._modelRuntime.getAvailableSnapshot().map((model) => `${model.provider}\0${model.id}`), ); @@ -1637,17 +1651,20 @@ export class AgentSession { const len = scopedModels.length; const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const next = scopedModels[nextIndex]; - const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel); + const thinkingLevel = this._getThinkingLevelForModelSwitch(next.model, next.thinkingLevel); // Apply model this.agent.state.model = next.model; this.sessionManager.appendModelChange(next.model.provider, next.model.id); - this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + } - // Apply thinking level. + // Apply session thinking level. // - Explicit scoped model thinking level overrides current session level // - Undefined scoped model thinking level inherits the current session preference // setThinkingLevel clamps to model capabilities. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(next.model, currentModel, "cycle"); @@ -1655,7 +1672,10 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } - private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { + private async _cycleAvailableModel( + direction: "forward" | "backward", + options: ModelMutationOptions, + ): Promise { const availableModels = this._modelRuntime.getAvailableSnapshot(); if (availableModels.length <= 1) return undefined; @@ -1667,12 +1687,15 @@ export class AgentSession { const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); + const thinkingLevel = this._getThinkingLevelForModelSwitch(nextModel); this.agent.state.model = nextModel; this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); - this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + if (options.persist) { + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + } - // Re-clamp thinking level for new model's capabilities + // Re-clamp session thinking level for new model's capabilities. + // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); await this._emitModelSelect(nextModel, currentModel, "cycle"); @@ -1687,9 +1710,10 @@ export class AgentSession { /** * Set thinking level. * Clamps to model capabilities based on available thinking levels. - * Saves to session and settings only if the level actually changes. + * Saves the clamped level to the session transcript only if the level actually changes. + * Persists the requested level to global defaults only when options.persist is true. */ - setThinkingLevel(level: ThinkingLevel): void { + setThinkingLevel(level: ThinkingLevel, options: ModelMutationOptions = {}): void { const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); @@ -1699,11 +1723,12 @@ export class AgentSession { this.agent.state.thinkingLevel = effectiveLevel; + if (options.persist) { + this.settingsManager.setDefaultThinkingLevel(level); + } + if (isChanging) { this.sessionManager.appendThinkingLevelChange(effectiveLevel); - if (this.supportsThinking() || effectiveLevel !== "off") { - this.settingsManager.setDefaultThinkingLevel(effectiveLevel); - } this._emit({ type: "thinking_level_changed", level: effectiveLevel }); void this._extensionRunner.emit({ type: "thinking_level_select", @@ -1717,7 +1742,7 @@ export class AgentSession { * Cycle to next thinking level. * @returns New level, or undefined if model doesn't support thinking */ - cycleThinkingLevel(): ThinkingLevel | undefined { + cycleThinkingLevel(options: ModelMutationOptions = {}): ThinkingLevel | undefined { if (!this.supportsThinking()) return undefined; const levels = this.getAvailableThinkingLevels(); @@ -1725,7 +1750,7 @@ export class AgentSession { const nextIndex = (currentIndex + 1) % levels.length; const nextLevel = levels[nextIndex]; - this.setThinkingLevel(nextLevel); + this.setThinkingLevel(nextLevel, options); return nextLevel; } @@ -1734,7 +1759,7 @@ export class AgentSession { * The provider will clamp to what the specific model supports internally. */ getAvailableThinkingLevels(): ThinkingLevel[] { - if (!this.model) return THINKING_LEVELS; + if (!this.model) return [...THINKING_LEVEL_OPTIONS]; return getSupportedThinkingLevels(this.model) as ThinkingLevel[]; } @@ -1745,10 +1770,17 @@ export class AgentSession { return !!this.model?.reasoning; } - private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel { + private _getThinkingLevelForModelSwitch(targetModel?: Model, explicitLevel?: ThinkingLevel): ThinkingLevel { if (explicitLevel !== undefined) { return explicitLevel; } + // Per-model default takes priority when switching to a model that has one + if (targetModel) { + const perModel = this.settingsManager.getModelThinkingLevel(targetModel.provider, targetModel.id); + if (perModel !== undefined) { + return perModel; + } + } if (!this.supportsThinking()) { return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; } diff --git a/packages/coding-agent/src/core/defaults.ts b/packages/coding-agent/src/core/defaults.ts index fddc7d14f9a..1e15c40e0f0 100644 --- a/packages/coding-agent/src/core/defaults.ts +++ b/packages/coding-agent/src/core/defaults.ts @@ -1,3 +1,12 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; +export const THINKING_LEVEL_OPTIONS: readonly ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 80a84f963ab..4bf4ac54128 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -626,6 +626,7 @@ export async function findInitialModel(options: { defaultProvider?: string; defaultModelId?: string; defaultThinkingLevel?: ThinkingLevel; + modelThinkingLevels?: Record; modelRuntime: ModelRuntime; }): Promise { const { @@ -636,6 +637,7 @@ export async function findInitialModel(options: { defaultProvider, defaultModelId, defaultThinkingLevel, + modelThinkingLevels, modelRuntime, } = options; @@ -660,9 +662,11 @@ export async function findInitialModel(options: { // 2. Use first model from scoped models (skip if continuing/resuming) if (scopedModels.length > 0 && !isContinuing) { + const scopedModel = scopedModels[0]; + const perModel = modelThinkingLevels?.[`${scopedModel.model.provider}/${scopedModel.model.id}`]; return { - model: scopedModels[0].model, - thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, + model: scopedModel.model, + thinkingLevel: scopedModel.thinkingLevel ?? perModel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, fallbackMessage: undefined, }; } @@ -672,7 +676,10 @@ export async function findInitialModel(options: { const found = modelRuntime.getModel(defaultProvider, defaultModelId); if (found && modelRuntime.hasConfiguredAuth(found.provider)) { model = found; - if (defaultThinkingLevel) { + const perModel = modelThinkingLevels?.[`${defaultProvider}/${defaultModelId}`]; + if (perModel) { + thinkingLevel = perModel; + } else if (defaultThinkingLevel) { thinkingLevel = defaultThinkingLevel; } return { model, thinkingLevel, fallbackMessage: undefined }; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a9a26641868..8af041a57b3 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -213,6 +213,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} defaultProvider: settingsManager.getDefaultProvider(), defaultModelId: settingsManager.getDefaultModel(), defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), + modelThinkingLevels: settingsManager.getAllModelThinkingLevels(), modelRuntime, }); model = result.model; @@ -232,7 +233,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); } - // Fall back to settings default + // Fall back to per-model override, then global default + if (thinkingLevel === undefined && model) { + const perModel = settingsManager.getModelThinkingLevel(model.provider, model.id); + if (perModel) { + thinkingLevel = perModel; + } + } if (thinkingLevel === undefined) { thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index fa270064092..19d59483eaf 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -93,6 +93,7 @@ export interface Settings { defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: ThinkingLevel; + modelThinkingLevels?: Record; // per-model default thinking level overrides keyed by "provider/modelId" transport?: TransportSetting; // default: "auto" steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time"; @@ -784,6 +785,33 @@ export class SettingsManager { this.save(); } + getModelThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined { + return this.settings.modelThinkingLevels?.[`${provider}/${modelId}`]; + } + + getAllModelThinkingLevels(): Record { + return { ...(this.settings.modelThinkingLevels ?? {}) }; + } + + setModelThinkingLevel(provider: string, modelId: string, level: ThinkingLevel): void { + if (!this.globalSettings.modelThinkingLevels) { + this.globalSettings.modelThinkingLevels = {}; + } + this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`] = level; + this.markModified("modelThinkingLevels"); + this.save(); + } + + removeModelThinkingLevel(provider: string, modelId: string): void { + if (!this.globalSettings.modelThinkingLevels) return; + delete this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`]; + if (Object.keys(this.globalSettings.modelThinkingLevels).length === 0) { + delete this.globalSettings.modelThinkingLevels; + } + this.markModified("modelThinkingLevels"); + this.save(); + } + getTransport(): TransportSetting { return this.settings.transport ?? "auto"; } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 7204988fe77..edf2b4987b6 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -18,7 +18,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, - { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, + { name: "model", description: "Select model (opens selector UI)", argumentHint: "[--default] " }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 1d5e58b12a2..efa96ffa4d5 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -10,7 +10,6 @@ import { type TUI, } from "@earendil-works/pi-tui"; import type { ModelRuntime } from "../../../core/model-runtime.ts"; -import type { SettingsManager } from "../../../core/settings-manager.ts"; import { refreshModelCatalogs } from "../model-catalog-refresh.ts"; import { getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; @@ -52,7 +51,6 @@ export class ModelSelectorComponent extends Container implements Focusable { private filteredModels: ModelItem[] = []; private selectedIndex: number = 0; private currentModel?: Model; - private settingsManager: SettingsManager; private modelRuntime: ModelRuntime; private onSelectCallback: (model: Model) => void; private onCancelCallback: () => void; @@ -71,7 +69,6 @@ export class ModelSelectorComponent extends Container implements Focusable { constructor( tui: TUI, currentModel: Model | undefined, - settingsManager: SettingsManager, modelRuntime: ModelRuntime, scopedModels: ReadonlyArray, onSelect: (model: Model) => void, @@ -82,7 +79,6 @@ export class ModelSelectorComponent extends Container implements Focusable { this.tui = tui; this.currentModel = currentModel; - this.settingsManager = settingsManager; this.modelRuntime = modelRuntime; this.scopedModels = scopedModels; this.scope = scopedModels.length > 0 ? "scoped" : "all"; @@ -363,8 +359,6 @@ export class ModelSelectorComponent extends Container implements Focusable { private handleSelect(model: Model): void { this.dispose(); - // Save as new default - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this.onSelectCallback(model); } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 1d626270e25..5fe374c5095 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,13 +1,11 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { Transport } from "@earendil-works/pi-ai"; +import { getSupportedThinkingLevels, type Model, type Transport } from "@earendil-works/pi-ai"; import { type Component, Container, getCapabilities, type ScrollViewScrollbar, type SelectItem, - SelectList, - type SelectListLayoutOptions, type SettingItem, SettingsList, Spacer, @@ -21,20 +19,13 @@ import type { TuiMode, WarningSettings, } from "../../../core/settings-manager.ts"; -import { - getSelectListTheme, - getSettingsListTheme, - parseAutoThemeSetting, - type TerminalTheme, - theme, -} from "../theme/theme.ts"; +import { getSettingsListTheme, parseAutoThemeSetting, type TerminalTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; +import { SelectSubmenu, SteppedSubmenu, type SteppedSubmenuStep } from "./settings-submenu.ts"; -const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { - minPrimaryColumnWidth: 12, - maxPrimaryColumnWidth: 32, -}; +const NO_DEFAULT_MODEL_VALUE = "__none__"; +const NO_DEFAULT_MODEL_LABEL = "not set"; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", @@ -58,6 +49,8 @@ const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( export interface SettingsConfig { autoCompact: boolean; + defaultModel: string; + availableDefaultModels: readonly Model[]; showImages: boolean; imageWidthCells: number; autoResizeImages: boolean; @@ -69,6 +62,7 @@ export interface SettingsConfig { httpIdleTimeoutMs: number; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; + modelThinkingLevels: Record; currentTheme: string; terminalTheme: TerminalTheme; availableThemes: string[]; @@ -95,6 +89,7 @@ export interface SettingsConfig { export interface SettingsCallbacks { onAutoCompactChange: (enabled: boolean) => void; + onDefaultModelChange: (model: Model) => Promise; onShowImagesChange: (enabled: boolean) => void; onImageWidthCellsChange: (width: number) => void; onAutoResizeImagesChange: (enabled: boolean) => void; @@ -105,6 +100,8 @@ export interface SettingsCallbacks { onTransportChange: (transport: Transport) => void; onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; onThinkingLevelChange: (level: ThinkingLevel) => void; + onModelThinkingLevelChange: (provider: string, modelId: string, level: ThinkingLevel) => void; + onModelThinkingLevelRemove: (provider: string, modelId: string) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; @@ -174,68 +171,34 @@ class WarningSettingsSubmenu extends Container { } } -class SelectSubmenu extends Container { - private selectList: SelectList; - - constructor( - title: string, - description: string, - options: SelectItem[], - currentValue: string, - onSelect: (value: string) => void, - onCancel: () => void, - onSelectionChange?: (value: string) => void, - ) { - super(); - - // Title - this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); - - // Description - if (description) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", description), 0, 0)); - } - - // Spacer - this.addChild(new Spacer(1)); - - // Select list - this.selectList = new SelectList( - options, - Math.min(options.length, 10), - getSelectListTheme(), - SETTINGS_SUBMENU_SELECT_LIST_LAYOUT, - ); - - // Pre-select current value - const currentIndex = options.findIndex((o) => o.value === currentValue); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); - } - - this.selectList.onSelect = (item) => { - onSelect(item.value); - }; - - this.selectList.onCancel = onCancel; +const CLEAR_OVERRIDE_VALUE = "__clear__"; - if (onSelectionChange) { - this.selectList.onSelectionChange = (item) => { - onSelectionChange(item.value); - }; - } +function modelSettingKey(model: Model): string { + return `${model.provider}/${model.id}`; +} - this.addChild(this.selectList); +function defaultModelDisplayValue(defaultModel: string, overrides: Record): string { + const override = overrides[defaultModel]; + return override ? `${defaultModel} \u00b7 ${override}` : defaultModel; +} - // Hint - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); - } +function modelThinkingOverridesSummary(overrides: Record): string { + const count = Object.keys(overrides).length; + if (count === 0) return "none"; + return `${count} configured`; +} - handleInput(data: string): void { - this.selectList.handleInput(data); - } +function defaultModelItems(models: readonly Model[]): SelectItem[] { + return [...models] + .sort((a, b) => { + const providerCompare = a.provider.localeCompare(b.provider); + if (providerCompare !== 0) return providerCompare; + return (a.name || a.id).localeCompare(b.name || b.id); + }) + .map((model) => { + const key = modelSettingKey(model); + return { value: key, label: key, description: model.name }; + }); } function themeItems(availableThemes: string[]): SelectItem[] { @@ -493,6 +456,15 @@ export class SettingsSelectorComponent extends Container { const supportsImages = getCapabilities().images; const followUpKey = keyDisplayText("app.message.followUp"); let currentWarnings = { ...config.warnings }; + const currentModelThinkingLevels = { ...config.modelThinkingLevels }; + let lastSelectedDefaultModel: Model | undefined; + const defaultModelOptions = defaultModelItems(config.availableDefaultModels); + const defaultModelByValue = new Map( + config.availableDefaultModels.map((model) => [modelSettingKey(model), model]), + ); + let currentDefaultModelKey: string | undefined = defaultModelByValue.has(config.defaultModel) + ? config.defaultModel + : undefined; const items: SettingItem[] = [ { @@ -524,6 +496,48 @@ export class SettingsSelectorComponent extends Container { currentValue: config.transport, values: ["sse", "websocket", "websocket-cached", "auto"], }, + { + id: "default-model", + label: "Default model", + description: "Startup model for new sessions", + currentValue: defaultModelDisplayValue(config.defaultModel, currentModelThinkingLevels), + submenu: (currentValue, done) => { + const options = + defaultModelOptions.length > 0 + ? defaultModelOptions + : [ + { + value: NO_DEFAULT_MODEL_VALUE, + label: "No models available", + description: "Log in to a provider or configure an API key first", + }, + ]; + return new SelectSubmenu( + "Default Model", + "Select the model to use when starting new sessions", + options, + currentValue === NO_DEFAULT_MODEL_LABEL ? NO_DEFAULT_MODEL_VALUE : currentValue, + (value) => { + const model = defaultModelByValue.get(value); + if (!model) { + done(); + return; + } + void callbacks.onDefaultModelChange(model).then( + () => { + lastSelectedDefaultModel = model; + currentDefaultModelKey = value; + done(defaultModelDisplayValue(value, currentModelThinkingLevels), { + navigateTo: "model-thinking", + }); + }, + () => done(), + ); + }, + () => done(), + ); + }, + }, { id: "http-idle-timeout", label: "HTTP idle timeout", @@ -612,13 +626,13 @@ export class SettingsSelectorComponent extends Container { }, { id: "thinking", - label: "Thinking level", - description: "Reasoning depth for thinking-capable models", + label: "Default thinking level", + description: "Startup reasoning depth for thinking-capable models", currentValue: config.thinkingLevel, submenu: (currentValue, done) => new SelectSubmenu( - "Thinking Level", - "Select reasoning depth for thinking-capable models", + "Default Thinking Level", + "Select the reasoning depth to use when starting new sessions", config.availableThinkingLevels.map((level) => ({ value: level, label: level, @@ -632,6 +646,116 @@ export class SettingsSelectorComponent extends Container { () => done(), ), }, + { + id: "model-thinking", + label: "Default thinking level per model", + description: "Override the default thinking level for specific models", + currentValue: modelThinkingOverridesSummary(currentModelThinkingLevels), + submenu: (_currentValue, done) => { + const preselected = lastSelectedDefaultModel; + lastSelectedDefaultModel = undefined; + + const steps: SteppedSubmenuStep[] = [ + { + key: "model", + title: "Per-Model Thinking Level", + description: "Select a model to configure", + options: () => { + const sorted = [...config.availableDefaultModels].sort((a, b) => { + const aKey = modelSettingKey(a); + const bKey = modelSettingKey(b); + if (aKey === currentDefaultModelKey) return -1; + if (bKey === currentDefaultModelKey) return 1; + const pc = a.provider.localeCompare(b.provider); + if (pc !== 0) return pc; + return (a.name || a.id).localeCompare(b.name || b.id); + }); + const items: SelectItem[] = sorted.map((model) => { + const key = modelSettingKey(model); + const override = currentModelThinkingLevels[key]; + const isDefault = key === currentDefaultModelKey; + const desc = [ + isDefault ? "default model" : undefined, + override ? `thinking: ${override}` : undefined, + ] + .filter(Boolean) + .join(" \u00b7 "); + return { value: key, label: key, description: desc || undefined }; + }); + if (items.length === 0) { + items.push({ + value: "__none__", + label: "No models available", + description: "Log in to a provider or configure an API key first", + }); + } + return items; + }, + preselect: () => currentDefaultModelKey, + }, + { + key: "level", + title: (ctx) => `Thinking Level for ${ctx.model}`, + description: "Select default thinking level for this model", + options: (ctx) => { + const model = defaultModelByValue.get(ctx.model); + if (!model) return []; + const levels = ( + model.reasoning ? getSupportedThinkingLevels(model) : ["off"] + ) as ThinkingLevel[]; + const items: SelectItem[] = levels.map((level) => ({ + value: level, + label: level, + description: THINKING_DESCRIPTIONS[level], + })); + if (currentModelThinkingLevels[ctx.model] !== undefined) { + items.push({ + value: CLEAR_OVERRIDE_VALUE, + label: "(clear override)", + description: `Revert to global default (${config.thinkingLevel})`, + }); + } + return items; + }, + preselect: (ctx) => currentModelThinkingLevels[ctx.model], + }, + ]; + + const summary = () => modelThinkingOverridesSummary(currentModelThinkingLevels); + + return new SteppedSubmenu( + steps, + (selections) => { + const model = defaultModelByValue.get(selections.model); + if (!model) return; + if (selections.level === CLEAR_OVERRIDE_VALUE) { + callbacks.onModelThinkingLevelRemove(model.provider, model.id); + delete currentModelThinkingLevels[selections.model]; + } else { + callbacks.onModelThinkingLevelChange( + model.provider, + model.id, + selections.level as ThinkingLevel, + ); + currentModelThinkingLevels[selections.model] = selections.level as ThinkingLevel; + } + }, + () => { + this.settingsList.updateValue( + "default-model", + defaultModelDisplayValue( + currentDefaultModelKey ?? config.defaultModel, + currentModelThinkingLevels, + ), + ); + done(summary()); + }, + preselected + ? { startAtStep: 1, initialContext: { model: modelSettingKey(preselected) } } + : { loop: true }, + ); + }, + }, { id: "tui-mode", label: "TUI mode", diff --git a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts new file mode 100644 index 00000000000..bc6a0224405 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts @@ -0,0 +1,196 @@ +import { + type Component, + Container, + type SelectItem, + SelectList, + type SelectListLayoutOptions, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { getSelectListTheme, theme } from "../theme/theme.ts"; + +const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + +/** + * Single-step submenu that shows a titled select list. + */ +export class SelectSubmenu extends Container { + private selectList: SelectList; + + constructor( + title: string, + description: string, + options: SelectItem[], + currentValue: string, + onSelect: (value: string) => void, + onCancel: () => void, + onSelectionChange?: (value: string) => void, + ) { + super(); + + // Title + this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); + + // Description + if (description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", description), 0, 0)); + } + + // Spacer + this.addChild(new Spacer(1)); + + // Select list + this.selectList = new SelectList( + options, + Math.min(options.length, 10), + getSelectListTheme(), + SUBMENU_SELECT_LIST_LAYOUT, + ); + + // Pre-select current value + const currentIndex = options.findIndex((o) => o.value === currentValue); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value); + }; + + this.selectList.onCancel = onCancel; + + if (onSelectionChange) { + this.selectList.onSelectionChange = (item) => { + onSelectionChange(item.value); + }; + } + + this.addChild(this.selectList); + + // Hint + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } +} + +// ============================================================================ +// SteppedSubmenu — reusable multi-step selector +// ============================================================================ + +/** One step in a {@link SteppedSubmenu}. */ +export interface SteppedSubmenuStep { + /** Unique key — the selected value is stored in the result context under this key. */ + key: string; + /** Title shown at the top of the step. Receives prior selections. */ + title: string | ((context: Record) => string); + /** Description shown below the title. Receives prior selections. */ + description: string | ((context: Record) => string); + /** Build the option list for this step. Called fresh each time the step is shown. */ + options: (context: Record) => SelectItem[]; + /** Optionally pre-select a value when entering this step. */ + preselect?: (context: Record) => string | undefined; +} + +interface SteppedSubmenuOptions { + /** Start at this step index (0-based), skipping earlier steps. Requires initialContext for skipped keys. */ + startAtStep?: number; + /** Pre-fill selections for skipped steps. */ + initialContext?: Record; + /** After completing the last step, loop back to step 0 instead of closing. */ + loop?: boolean; +} + +/** + * Generic N-step submenu built on top of {@link SelectSubmenu}. + * + * Each step's options can depend on prior selections via the shared context. + * Esc goes back one step; Esc at step 0 cancels. + * With `loop: true`, completing the final step invokes `onComplete` then returns to step 0. + */ +export class SteppedSubmenu extends Container { + private readonly steps: SteppedSubmenuStep[]; + private readonly onComplete: (context: Record) => void; + private readonly onCancel: () => void; + private readonly opts: SteppedSubmenuOptions; + private activeComponent: Component; + private context: Record; + + constructor( + steps: SteppedSubmenuStep[], + onComplete: (context: Record) => void, + onCancel: () => void, + opts: SteppedSubmenuOptions = {}, + ) { + super(); + this.steps = steps; + this.onComplete = onComplete; + this.onCancel = onCancel; + this.opts = opts; + this.context = { ...(opts.initialContext ?? {}) }; + this.activeComponent = this.buildStep(opts.startAtStep ?? 0); + } + + private buildStep(stepIndex: number): Component { + const step = this.steps[stepIndex]; + const total = this.steps.length; + const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} · ` : ""; + + const title = typeof step.title === "function" ? step.title(this.context) : step.title; + const desc = typeof step.description === "function" ? step.description(this.context) : step.description; + const items = step.options(this.context); + const preselect = step.preselect?.(this.context) ?? ""; + + return new SelectSubmenu( + title, + `${stepLabel}${desc}`, + items, + preselect, + (value) => { + this.context[step.key] = value; + + if (stepIndex < total - 1) { + // Advance to next step + this.activeComponent = this.buildStep(stepIndex + 1); + } else { + // Final step — deliver result + this.onComplete({ ...this.context }); + + if (this.opts.loop) { + this.context = {}; + this.activeComponent = this.buildStep(0); + } else { + this.onCancel(); + } + } + }, + () => { + if (stepIndex > 0) { + delete this.context[step.key]; + this.activeComponent = this.buildStep(stepIndex - 1); + } else { + this.onCancel(); + } + }, + ); + } + + render(width: number): string[] { + return this.activeComponent.render(width); + } + + handleInput(data: string): void { + this.activeComponent.handleInput?.(data); + } + + invalidate(): void { + this.activeComponent.invalidate?.(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b008f0bb71a..ea80883e99b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -66,6 +66,7 @@ import { computeCacheWaste, detectCacheMiss, } from "../../core/cache-stats.ts"; +import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts"; import type { AutocompleteProviderFactory, EditorFactory, @@ -231,6 +232,44 @@ function isDeadTerminalError(error: unknown): boolean { return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); } +export interface ModelCommandArgs { + searchTerm?: string; + persist: boolean; + error?: string; +} + +export function parseModelCommandArgs(args: string | undefined): ModelCommandArgs { + if (!args?.trim()) return { persist: false }; + + let persist = false; + const searchTerms: string[] = []; + for (const token of args.trim().split(/\s+/u)) { + if (token === "--default") { + persist = true; + continue; + } + + if (token.startsWith("--default=")) { + persist = true; + const value = token.slice("--default=".length); + if (value) searchTerms.push(value); + continue; + } + + if (token.startsWith("--")) { + return { + persist, + searchTerm: searchTerms.join(" ") || undefined, + error: `Unknown /model option "${token}". Supported option: --default.`, + }; + } + + searchTerms.push(token); + } + + return { persist, searchTerm: searchTerms.join(" ") || undefined }; +} + const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings."; @@ -681,6 +720,16 @@ export class InteractiveMode { const modelCommand = slashCommands.find((command) => command.name === "model"); if (modelCommand) { modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const trimmedPrefix = prefix.trimStart(); + if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) { + return "--default".startsWith(trimmedPrefix) + ? [{ value: "--default ", label: "--default", description: "Set as startup default" }] + : null; + } + + const parsed = parseModelCommandArgs(prefix); + if (parsed.error) return null; + const models = this.session.scopedModels.length > 0 ? this.session.scopedModels.map((s) => s.model) @@ -696,10 +745,11 @@ export class InteractiveMode { label: `${m.provider}/${m.id}`, })); - return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({ - value: item.label, + const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix; + return createFuzzyAutocompleteItems(items, searchPrefix, getModelSearchText, (item) => ({ + value: parsed.persist ? `--default ${item.label}` : item.label, label: item.id, - description: item.provider, + description: parsed.persist ? `${item.provider} · set as startup default` : item.provider, })); }; } @@ -2950,9 +3000,13 @@ export class InteractiveMode { return; } if (text === "/model" || text.startsWith("/model ")) { - const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; + const args = parseModelCommandArgs(text.startsWith("/model ") ? text.slice(7).trim() : undefined); this.editor.setText(""); - await this.handleModelCommand(searchTerm); + if (args.error) { + this.showError(args.error); + return; + } + await this.handleModelCommand(args.searchTerm, { persist: args.persist }); return; } if (text === "/export" || text.startsWith("/export ")) { @@ -4493,9 +4547,14 @@ export class InteractiveMode { private showSettingsSelector(): void { this.showSelector((done) => { let selector: SettingsSelectorComponent | undefined; + const defaultProvider = this.settingsManager.getDefaultProvider(); + const defaultModelId = this.settingsManager.getDefaultModel(); + const defaultModel = defaultProvider && defaultModelId ? `${defaultProvider}/${defaultModelId}` : "not set"; selector = new SettingsSelectorComponent( { autoCompact: this.session.autoCompactionEnabled, + defaultModel, + availableDefaultModels: this.session.modelRuntime.getAvailableSnapshot(), showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), autoResizeImages: this.settingsManager.getImageAutoResize(), @@ -4505,8 +4564,9 @@ export class InteractiveMode { followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), - thinkingLevel: this.session.thinkingLevel, - availableThinkingLevels: this.session.getAvailableThinkingLevels(), + thinkingLevel: this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, + availableThinkingLevels: [...THINKING_LEVEL_OPTIONS], + modelThinkingLevels: this.settingsManager.getAllModelThinkingLevels(), currentTheme: this.themeController.getThemeSelection() || "dark", terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), @@ -4535,6 +4595,18 @@ export class InteractiveMode { this.session.setAutoCompactionEnabled(enabled); this.footer.setAutoCompactEnabled(enabled); }, + onDefaultModelChange: async (model) => { + try { + await this.session.setModel(model, { persist: true }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(`Default model: ${model.provider}/${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + this.checkDaxnutsEasterEgg(model); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + }, onShowImagesChange: (enabled) => { this.settingsManager.setShowImages(enabled); for (const child of this.chatContainer.children) { @@ -4577,10 +4649,31 @@ export class InteractiveMode { this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); }, onThinkingLevelChange: (level) => { - this.session.setThinkingLevel(level); + this.session.setThinkingLevel(level, { persist: true }); this.footer.invalidate(); this.updateEditorBorderColor(); }, + onModelThinkingLevelChange: (provider, modelId, level) => { + this.settingsManager.setModelThinkingLevel(provider, modelId, level); + // If the override is for the current model, apply it to the session too + const current = this.session.model; + if (current && current.provider === provider && current.id === modelId) { + this.session.setThinkingLevel(level); + this.footer.invalidate(); + this.updateEditorBorderColor(); + } + }, + onModelThinkingLevelRemove: (provider, modelId) => { + this.settingsManager.removeModelThinkingLevel(provider, modelId); + // If the override was for the current model, revert to global default + const current = this.session.model; + if (current && current.provider === provider && current.id === modelId) { + const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + this.session.setThinkingLevel(globalDefault); + this.footer.invalidate(); + this.updateEditorBorderColor(); + } + }, onThemeChange: (themeSetting) => { this.settingsManager.setTheme(themeSetting); void this.themeController.setThemeSetting(themeSetting); @@ -4703,19 +4796,19 @@ export class InteractiveMode { }); } - private async handleModelCommand(searchTerm?: string): Promise { + private async handleModelCommand(searchTerm?: string, options: { persist?: boolean } = {}): Promise { if (!searchTerm) { - this.showModelSelector(); + this.showModelSelector(undefined, options); return; } const model = await this.findExactModelMatch(searchTerm); if (model) { try { - await this.session.setModel(model); + await this.session.setModel(model, { persist: options.persist === true }); this.footer.invalidate(); this.updateEditorBorderColor(); - this.showStatus(`Model: ${model.id}`); + this.showStatus(options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { @@ -4724,7 +4817,7 @@ export class InteractiveMode { return; } - this.showModelSelector(searchTerm); + this.showModelSelector(searchTerm, options); } private async findExactModelMatch(searchTerm: string): Promise | undefined> { @@ -4852,21 +4945,22 @@ export class InteractiveMode { }); } - private showModelSelector(initialSearchInput?: string): void { + private showModelSelector(initialSearchInput?: string, options: { persist?: boolean } = {}): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( this.ui, this.session.model, - this.settingsManager, this.session.modelRuntime, this.session.scopedModels, async (model) => { try { - await this.session.setModel(model); + await this.session.setModel(model, { persist: options.persist === true }); this.footer.invalidate(); this.updateEditorBorderColor(); done(); - this.showStatus(`Model: ${model.id}`); + this.showStatus( + options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`, + ); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { @@ -5558,7 +5652,7 @@ export class InteractiveMode { selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`; } else { try { - await this.session.setModel(selectedModel); + await this.session.setModel(selectedModel, { persist: true }); } catch (error: unknown) { selectedModel = undefined; const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index a4aaa48c02a..cd6a0b2729a 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -8,7 +8,7 @@ import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.ts"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { InteractiveMode, parseModelCommandArgs } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; function renderLastLine(container: Container, width = 120): string { @@ -401,6 +401,22 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); describe("InteractiveMode.createBaseAutocompleteProvider", () => { + describe("parseModelCommandArgs", () => { + test("parses --default as a persistent model selection", () => { + expect(parseModelCommandArgs("--default openai/gpt-5")).toEqual({ + persist: true, + searchTerm: "openai/gpt-5", + }); + }); + + test("rejects unknown /model flags", () => { + expect(parseModelCommandArgs("--global openai/gpt-5")).toMatchObject({ + persist: false, + error: 'Unknown /model option "--global". Supported option: --default.', + }); + }); + }); + test("matches model command arguments across provider/model order", async () => { type TestModel = { id: string; provider: string; name: string }; type FakeInteractiveMode = { @@ -452,6 +468,52 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { ]); }); + test("preserves --default when completing model command arguments", async () => { + type TestModel = { id: string; provider: string; name: string }; + type FakeInteractiveMode = { + session: { + scopedModels: Array<{ model: TestModel }>; + modelRuntime: { getAvailableSnapshot: () => TestModel[] }; + promptTemplates: []; + extensionRunner: { getRegisteredCommands: () => [] }; + resourceLoader: { getSkills: () => { skills: [] } }; + }; + settingsManager: { getEnableSkillCommands: () => boolean }; + skillCommands: Map; + sessionManager: { getCwd: () => string }; + fdPath: null; + }; + + const createBaseAutocompleteProvider = ( + InteractiveMode as unknown as { + prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider }; + } + ).prototype.createBaseAutocompleteProvider; + const models = [{ id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" }]; + const fakeThis: FakeInteractiveMode = { + session: { + scopedModels: [], + modelRuntime: { getAvailableSnapshot: () => models }, + promptTemplates: [], + extensionRunner: { getRegisteredCommands: () => [] }, + resourceLoader: { getSkills: () => ({ skills: [] }) }, + }, + settingsManager: { getEnableSkillCommands: () => false }, + skillCommands: new Map(), + sessionManager: { getCwd: () => "/tmp" }, + fdPath: null, + }; + + const provider = createBaseAutocompleteProvider.call(fakeThis); + const line = "/model --default codex"; + const suggestions = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + + expect(suggestions?.prefix).toBe("--default codex"); + expect(suggestions?.items[0]?.value).toBe("--default openai-codex/gpt-5.5"); + }); + test("matches login command arguments by provider id and name", async () => { type FakeInteractiveMode = { session: { diff --git a/packages/coding-agent/test/model-selector.test.ts b/packages/coding-agent/test/model-selector.test.ts index 060386ee708..fd5550ac17b 100644 --- a/packages/coding-agent/test/model-selector.test.ts +++ b/packages/coding-agent/test/model-selector.test.ts @@ -34,7 +34,6 @@ describe("model selector", () => { const selector = new ModelSelectorComponent( createFakeTui(), harness.getModel(), - harness.settingsManager, harness.session.modelRuntime, [], () => {}, diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index e658223b9a1..9c88fd2820e 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -21,7 +21,10 @@ describe("SettingsSelectorComponent", () => { fullscreenExitOutput: "transcript", fullscreenScrollbar: "auto", warnings: {}, + defaultModel: "not set", + availableDefaultModels: [], availableThinkingLevels: [], + modelThinkingLevels: {}, availableThemes: [], } as unknown as SettingsConfig; const callbacks = { diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index b334b1232a0..487f55e0c75 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -14,7 +14,7 @@ describe("AgentSession model and extension characterization", () => { } }); - it("setModel saves the model and emits model_select", async () => { + it("setModel saves the model to the session and emits model_select", async () => { const modelEvents: string[] = []; const harness = await createHarness({ models: [ @@ -42,6 +42,112 @@ describe("AgentSession model and extension characterization", () => { .filter((entry) => entry.type === "model_change") .map((entry) => `${entry.provider}/${entry.modelId}`), ).toEqual([`${nextModel.provider}/${nextModel.id}`]); + expect(harness.settingsManager.getDefaultProvider()).toBeUndefined(); + expect(harness.settingsManager.getDefaultModel()).toBeUndefined(); + }); + + it("only persists model and thinking defaults when requested", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + const nextModel = harness.getModel("faux-2")!; + + await harness.session.setModel(nextModel); + expect(harness.settingsManager.getDefaultProvider()).toBeUndefined(); + expect(harness.settingsManager.getDefaultModel()).toBeUndefined(); + + harness.session.setThinkingLevel("low"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBeUndefined(); + + await harness.session.setModel(nextModel, { persist: true }); + expect(harness.settingsManager.getDefaultProvider()).toBe(nextModel.provider); + expect(harness.settingsManager.getDefaultModel()).toBe(nextModel.id); + + harness.session.setThinkingLevel("high", { persist: true }); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("high"); + }); + + it("persists the requested default thinking level even when the current model clamps it", async () => { + const harness = await createHarness({ models: [{ id: "faux-1", reasoning: true }] }); + harnesses.push(harness); + + harness.session.setThinkingLevel("max", { persist: true }); + + expect(harness.session.thinkingLevel).toBe("high"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("max"); + }); + + it("cycleModel and cycleThinkingLevel are session-only by default", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { + defaultProvider: "faux", + defaultModel: "faux-1", + defaultThinkingLevel: "low", + }, + }); + harnesses.push(harness); + + await harness.session.cycleModel(); + expect(harness.session.model?.id).toBe("faux-2"); + expect(harness.settingsManager.getDefaultModel()).toBe("faux-1"); + + harness.session.setThinkingLevel("off"); + expect(harness.session.cycleThinkingLevel()).toBe("minimal"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low"); + }); + + it("applies per-model thinking level override on model switch", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + + // Set a per-model override for faux-2 + harness.settingsManager.setModelThinkingLevel("faux", "faux-2", "low"); + + // Session starts on faux-1 with default thinking + harness.session.setThinkingLevel("high"); + expect(harness.session.thinkingLevel).toBe("high"); + + // Switch to faux-2 → per-model override should apply + const model2 = harness.getModel("faux-2")!; + await harness.session.setModel(model2); + expect(harness.session.thinkingLevel).toBe("low"); + + // Switch back to faux-1 → no per-model override, carries session level + const model1 = harness.getModel("faux-1")!; + await harness.session.setModel(model1); + expect(harness.session.thinkingLevel).toBe("low"); + }); + + it("per-model override takes priority over global default during model switch", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + settings: { + defaultThinkingLevel: "high", + modelThinkingLevels: { "faux/faux-2": "minimal" }, + }, + }); + harnesses.push(harness); + + // Start on a non-thinking model, then switch to faux-2 + const model2 = harness.getModel("faux-2")!; + await harness.session.setModel(model2); + expect(harness.session.thinkingLevel).toBe("minimal"); }); it("cycles through scoped models and preserves the scoped thinking preference", async () => { diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index c44e1b36f3e..e9f33f90ccb 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -78,7 +78,6 @@ describe("issue #3217 scoped model ordering", () => { const selector = new ModelSelectorComponent( createFakeTui(), modelOne, - harness.settingsManager, harness.session.modelRuntime, [{ model: modelTwo }, { model: modelOne }, { model: modelThree }], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts index 8a4c144bc28..2f64c52a7c2 100644 --- a/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts +++ b/packages/coding-agent/test/suite/regressions/6999-models-json-hot-reload.test.ts @@ -5,7 +5,6 @@ import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { AuthStorage } from "../../../src/core/auth-storage.ts"; import { KeybindingsManager } from "../../../src/core/keybindings.ts"; -import { SettingsManager } from "../../../src/core/settings-manager.ts"; import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts"; import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; import { stripAnsi } from "../../../src/utils/ansi.ts"; @@ -69,7 +68,6 @@ describe("issue #6999 models.json hot reload", () => { const selector = new ModelSelectorComponent( tui, undefined, - SettingsManager.inMemory(), modelRuntime, [], () => {}, diff --git a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts index 84551ddfbf9..e853ac35a02 100644 --- a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts @@ -51,7 +51,6 @@ describe("model selector filter resets selection to top", () => { const selector = new ModelSelectorComponent( createFakeTui(), current, - harness.settingsManager, harness.session.modelRuntime, [], () => {}, @@ -102,7 +101,6 @@ describe("model selector filter resets selection to top", () => { const selector = new ModelSelectorComponent( createFakeTui(), alpha1, - harness.settingsManager, harness.session.modelRuntime, [{ model: alpha2 }, { model: alpha3 }, { model: alpha1 }], () => {}, diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 7dce91b87cf..72f9aece213 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -15,8 +15,12 @@ export interface SettingItem { currentValue: string; /** If provided, Enter/Space cycles through these values */ values?: string[]; - /** If provided, Enter opens this submenu. Receives current value and done callback. */ - submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; + /** If provided, Enter opens this submenu. Receives current value and done callback. + * done() accepts an optional selectedValue and an optional navigateTo id to move the cursor after close. */ + submenu?: ( + currentValue: string, + done: (selectedValue?: string, options?: { navigateTo?: string }) => void, + ) => Component; } export interface SettingsListTheme { @@ -45,6 +49,7 @@ export class SettingsList implements Component { // Submenu state private submenuComponent: Component | null = null; private submenuItemIndex: number | null = null; + private navigateAfterClose: string | null = null; constructor( items: SettingItem[], @@ -74,6 +79,15 @@ export class SettingsList implements Component { } } + /** Move selection to the item with the given id (no-op if not found). */ + selectItem(id: string): void { + const items = this.searchEnabled ? this.filteredItems : this.items; + const index = items.findIndex((i) => i.id === id); + if (index !== -1) { + this.selectedIndex = index; + } + } + invalidate(): void { this.submenuComponent?.invalidate?.(); } @@ -202,13 +216,19 @@ export class SettingsList implements Component { if (item.submenu) { // Open submenu, passing current value so it can pre-select correctly this.submenuItemIndex = this.selectedIndex; - this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { - if (selectedValue !== undefined) { - item.currentValue = selectedValue; - this.onChange(item.id, selectedValue); - } - this.closeSubmenu(); - }); + this.submenuComponent = item.submenu( + item.currentValue, + (selectedValue?: string, options?: { navigateTo?: string }) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + if (options?.navigateTo) { + this.navigateAfterClose = options.navigateTo; + } + this.closeSubmenu(); + }, + ); } else if (item.values && item.values.length > 0) { // Cycle through values const currentIndex = item.values.indexOf(item.currentValue); @@ -221,8 +241,15 @@ export class SettingsList implements Component { private closeSubmenu(): void { this.submenuComponent = null; - // Restore selection to the item that opened the submenu - if (this.submenuItemIndex !== null) { + if (this.navigateAfterClose !== null) { + const id = this.navigateAfterClose; + this.navigateAfterClose = null; + this.submenuItemIndex = null; + this.selectItem(id); + // Open the target item's submenu automatically + this.activateItem(); + } else if (this.submenuItemIndex !== null) { + // Restore selection to the item that opened the submenu this.selectedIndex = this.submenuItemIndex; this.submenuItemIndex = null; } From 98767a25d24494ea0b6acc10af4b91fc68c4cf7e Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:29:02 +0200 Subject: [PATCH 231/284] fix(settings-selector): remove token estimates --- .../modes/interactive/components/settings-selector.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 5fe374c5095..c4eb594e025 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -29,11 +29,11 @@ const NO_DEFAULT_MODEL_LABEL = "not set"; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", - minimal: "Very brief reasoning (~1k tokens)", - low: "Light reasoning (~2k tokens)", - medium: "Moderate reasoning (~8k tokens)", - high: "Deep reasoning (~16k tokens)", - xhigh: "Extra-high reasoning (~32k tokens)", + minimal: "Very brief reasoning", + low: "Light reasoning", + medium: "Moderate reasoning", + high: "Deep reasoning", + xhigh: "Extra-deep reasoning", max: "Maximum reasoning", }; From ee29aa118bdeb7d8c4fdafa81130e0c61f8e0423 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:43:21 +0200 Subject: [PATCH 232/284] feat(settings-selector): make default model and thinking level searchable --- .../components/settings-selector.ts | 3 + .../components/settings-submenu.ts | 109 ++++++++++++++---- 2 files changed, 88 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index c4eb594e025..f35a7852d5d 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -535,6 +535,8 @@ export class SettingsSelectorComponent extends Container { ); }, () => done(), + undefined, + { searchable: true }, ); }, }, @@ -692,6 +694,7 @@ export class SettingsSelectorComponent extends Container { return items; }, preselect: () => currentDefaultModelKey, + searchable: true, }, { key: "level", diff --git a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts index bc6a0224405..e8e6ddaf946 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts @@ -1,6 +1,9 @@ import { type Component, Container, + fuzzyFilter, + getKeybindings, + Input, type SelectItem, SelectList, type SelectListLayoutOptions, @@ -14,11 +17,23 @@ const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { maxPrimaryColumnWidth: 32, }; +export interface SelectSubmenuOptions { + /** Enable type-to-search fuzzy filtering. */ + searchable?: boolean; +} + /** * Single-step submenu that shows a titled select list. + * With `searchable: true`, typing filters the list using fuzzy matching. */ export class SelectSubmenu extends Container { private selectList: SelectList; + private listChildIndex: number; + private allOptions: SelectItem[]; + private searchInput: Input | undefined; + private onSelectCb: (value: string) => void; + private onCancelCb: () => void; + private onSelectionChangeCb?: (value: string) => void; constructor( title: string, @@ -28,9 +43,15 @@ export class SelectSubmenu extends Container { onSelect: (value: string) => void, onCancel: () => void, onSelectionChange?: (value: string) => void, + submenuOptions?: SelectSubmenuOptions, ) { super(); + this.allOptions = options; + this.onSelectCb = onSelect; + this.onCancelCb = onCancel; + this.onSelectionChangeCb = onSelectionChange; + // Title this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); @@ -40,44 +61,80 @@ export class SelectSubmenu extends Container { this.addChild(new Text(theme.fg("muted", description), 0, 0)); } + // Search input + if (submenuOptions?.searchable) { + this.addChild(new Spacer(1)); + this.searchInput = new Input(); + this.searchInput.onSubmit = () => { + this.selectList.handleInput("\r"); + }; + this.addChild(this.searchInput); + } + // Spacer this.addChild(new Spacer(1)); // Select list - this.selectList = new SelectList( + this.selectList = this.buildSelectList(options, currentValue); + this.listChildIndex = this.children.length; + this.addChild(this.selectList); + + // Hint + this.addChild(new Spacer(1)); + const hint = submenuOptions?.searchable + ? " Type to filter \u00b7 Enter to select \u00b7 Esc to go back" + : " Enter to select \u00b7 Esc to go back"; + this.addChild(new Text(theme.fg("dim", hint), 0, 0)); + } + + private buildSelectList(options: SelectItem[], preselect: string): SelectList { + const list = new SelectList( options, Math.min(options.length, 10), getSelectListTheme(), SUBMENU_SELECT_LIST_LAYOUT, ); - // Pre-select current value - const currentIndex = options.findIndex((o) => o.value === currentValue); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); - } - - this.selectList.onSelect = (item) => { - onSelect(item.value); - }; + const idx = options.findIndex((o) => o.value === preselect); + if (idx !== -1) list.setSelectedIndex(idx); - this.selectList.onCancel = onCancel; - - if (onSelectionChange) { - this.selectList.onSelectionChange = (item) => { - onSelectionChange(item.value); - }; + list.onSelect = (item) => this.onSelectCb(item.value); + list.onCancel = this.onCancelCb; + if (this.onSelectionChangeCb) { + const cb = this.onSelectionChangeCb; + list.onSelectionChange = (item) => cb(item.value); } - this.addChild(this.selectList); + return list; + } - // Hint - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + private applyFilter(query: string): void { + const filtered = query + ? fuzzyFilter(this.allOptions, query, (item) => `${item.label} ${item.description ?? ""}`) + : this.allOptions; + + const newList = this.buildSelectList(filtered, ""); + this.children[this.listChildIndex] = newList; + this.selectList = newList; } handleInput(data: string): void { - this.selectList.handleInput(data); + if (this.searchInput) { + const kb = getKeybindings(); + const isNav = + kb.matches(data, "tui.select.up") || + kb.matches(data, "tui.select.down") || + kb.matches(data, "tui.select.confirm") || + kb.matches(data, "tui.select.cancel"); + if (isNav) { + this.selectList.handleInput(data); + } else { + this.searchInput.handleInput(data); + this.applyFilter(this.searchInput.getValue()); + } + } else { + this.selectList.handleInput(data); + } } } @@ -87,7 +144,7 @@ export class SelectSubmenu extends Container { /** One step in a {@link SteppedSubmenu}. */ export interface SteppedSubmenuStep { - /** Unique key — the selected value is stored in the result context under this key. */ + /** Unique key \u2014 the selected value is stored in the result context under this key. */ key: string; /** Title shown at the top of the step. Receives prior selections. */ title: string | ((context: Record) => string); @@ -97,6 +154,8 @@ export interface SteppedSubmenuStep { options: (context: Record) => SelectItem[]; /** Optionally pre-select a value when entering this step. */ preselect?: (context: Record) => string | undefined; + /** Enable type-to-search fuzzy filtering for this step. */ + searchable?: boolean; } interface SteppedSubmenuOptions { @@ -141,7 +200,7 @@ export class SteppedSubmenu extends Container { private buildStep(stepIndex: number): Component { const step = this.steps[stepIndex]; const total = this.steps.length; - const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} · ` : ""; + const stepLabel = total > 1 ? `Step ${stepIndex + 1}/${total} \u00b7 ` : ""; const title = typeof step.title === "function" ? step.title(this.context) : step.title; const desc = typeof step.description === "function" ? step.description(this.context) : step.description; @@ -160,7 +219,7 @@ export class SteppedSubmenu extends Container { // Advance to next step this.activeComponent = this.buildStep(stepIndex + 1); } else { - // Final step — deliver result + // Final step \u2014 deliver result this.onComplete({ ...this.context }); if (this.opts.loop) { @@ -179,6 +238,8 @@ export class SteppedSubmenu extends Container { this.onCancel(); } }, + undefined, + step.searchable ? { searchable: true } : undefined, ); } From a669db3c33851c7236315f7f5e0d028494c071f5 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:25:14 +0200 Subject: [PATCH 233/284] fix(settings-selector): show modelid [provider] like /model --- .../components/settings-selector.ts | 66 +++++++++++++------ .../components/settings-submenu.ts | 15 +++-- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index f35a7852d5d..a361a9df41e 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -27,6 +27,8 @@ import { SelectSubmenu, SteppedSubmenu, type SteppedSubmenuStep } from "./settin const NO_DEFAULT_MODEL_VALUE = "__none__"; const NO_DEFAULT_MODEL_LABEL = "not set"; +const MODEL_PICKER_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 46 }; + const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", minimal: "Very brief reasoning", @@ -177,9 +179,18 @@ function modelSettingKey(model: Model): string { return `${model.provider}/${model.id}`; } -function defaultModelDisplayValue(defaultModel: string, overrides: Record): string { - const override = overrides[defaultModel]; - return override ? `${defaultModel} \u00b7 ${override}` : defaultModel; +function modelDisplayLabel(model: Model): string { + return `${model.id} [${model.provider}]`; +} + +function defaultModelDisplayValue( + key: string, + model: Model | undefined, + overrides: Record, +): string { + const label = model ? modelDisplayLabel(model) : key; + const override = overrides[key]; + return override ? `${label} \u00b7 ${override}` : label; } function modelThinkingOverridesSummary(overrides: Record): string { @@ -188,7 +199,11 @@ function modelThinkingOverridesSummary(overrides: Record) return `${count} configured`; } -function defaultModelItems(models: readonly Model[]): SelectItem[] { +function modelItemLabel(model: Model): string { + return `${model.id} ${theme.fg("muted", `[${model.provider}]`)}`; +} + +function defaultModelItems(models: readonly Model[], overrides?: Record): SelectItem[] { return [...models] .sort((a, b) => { const providerCompare = a.provider.localeCompare(b.provider); @@ -197,7 +212,12 @@ function defaultModelItems(models: readonly Model[]): SelectItem[] { }) .map((model) => { const key = modelSettingKey(model); - return { value: key, label: key, description: model.name }; + const override = overrides?.[key]; + return { + value: key, + label: modelItemLabel(model), + description: override ?? undefined, + }; }); } @@ -458,7 +478,6 @@ export class SettingsSelectorComponent extends Container { let currentWarnings = { ...config.warnings }; const currentModelThinkingLevels = { ...config.modelThinkingLevels }; let lastSelectedDefaultModel: Model | undefined; - const defaultModelOptions = defaultModelItems(config.availableDefaultModels); const defaultModelByValue = new Map( config.availableDefaultModels.map((model) => [modelSettingKey(model), model]), ); @@ -500,11 +519,16 @@ export class SettingsSelectorComponent extends Container { id: "default-model", label: "Default model", description: "Startup model for new sessions", - currentValue: defaultModelDisplayValue(config.defaultModel, currentModelThinkingLevels), + currentValue: defaultModelDisplayValue( + config.defaultModel, + defaultModelByValue.get(config.defaultModel), + currentModelThinkingLevels, + ), submenu: (currentValue, done) => { + const fresh = defaultModelItems(config.availableDefaultModels, currentModelThinkingLevels); const options = - defaultModelOptions.length > 0 - ? defaultModelOptions + fresh.length > 0 + ? fresh : [ { value: NO_DEFAULT_MODEL_VALUE, @@ -527,7 +551,7 @@ export class SettingsSelectorComponent extends Container { () => { lastSelectedDefaultModel = model; currentDefaultModelKey = value; - done(defaultModelDisplayValue(value, currentModelThinkingLevels), { + done(defaultModelDisplayValue(value, model, currentModelThinkingLevels), { navigateTo: "model-thinking", }); }, @@ -536,7 +560,7 @@ export class SettingsSelectorComponent extends Container { }, () => done(), undefined, - { searchable: true }, + { searchable: true, layout: MODEL_PICKER_LAYOUT }, ); }, }, @@ -675,14 +699,11 @@ export class SettingsSelectorComponent extends Container { const items: SelectItem[] = sorted.map((model) => { const key = modelSettingKey(model); const override = currentModelThinkingLevels[key]; - const isDefault = key === currentDefaultModelKey; - const desc = [ - isDefault ? "default model" : undefined, - override ? `thinking: ${override}` : undefined, - ] - .filter(Boolean) - .join(" \u00b7 "); - return { value: key, label: key, description: desc || undefined }; + return { + value: key, + label: modelItemLabel(model), + description: override ?? undefined, + }; }); if (items.length === 0) { items.push({ @@ -695,10 +716,14 @@ export class SettingsSelectorComponent extends Container { }, preselect: () => currentDefaultModelKey, searchable: true, + layout: MODEL_PICKER_LAYOUT, }, { key: "level", - title: (ctx) => `Thinking Level for ${ctx.model}`, + title: (ctx) => { + const m = defaultModelByValue.get(ctx.model); + return `Thinking Level for ${m ? modelDisplayLabel(m) : ctx.model}`; + }, description: "Select default thinking level for this model", options: (ctx) => { const model = defaultModelByValue.get(ctx.model); @@ -748,6 +773,7 @@ export class SettingsSelectorComponent extends Container { "default-model", defaultModelDisplayValue( currentDefaultModelKey ?? config.defaultModel, + defaultModelByValue.get(currentDefaultModelKey ?? config.defaultModel), currentModelThinkingLevels, ), ); diff --git a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts index e8e6ddaf946..81703323a34 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-submenu.ts @@ -20,6 +20,8 @@ const SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { export interface SelectSubmenuOptions { /** Enable type-to-search fuzzy filtering. */ searchable?: boolean; + /** Override the select list layout (column widths). */ + layout?: SelectListLayoutOptions; } /** @@ -30,6 +32,7 @@ export class SelectSubmenu extends Container { private selectList: SelectList; private listChildIndex: number; private allOptions: SelectItem[]; + private listLayout: SelectListLayoutOptions; private searchInput: Input | undefined; private onSelectCb: (value: string) => void; private onCancelCb: () => void; @@ -48,6 +51,7 @@ export class SelectSubmenu extends Container { super(); this.allOptions = options; + this.listLayout = submenuOptions?.layout ?? SUBMENU_SELECT_LIST_LAYOUT; this.onSelectCb = onSelect; this.onCancelCb = onCancel; this.onSelectionChangeCb = onSelectionChange; @@ -88,12 +92,7 @@ export class SelectSubmenu extends Container { } private buildSelectList(options: SelectItem[], preselect: string): SelectList { - const list = new SelectList( - options, - Math.min(options.length, 10), - getSelectListTheme(), - SUBMENU_SELECT_LIST_LAYOUT, - ); + const list = new SelectList(options, Math.min(options.length, 10), getSelectListTheme(), this.listLayout); const idx = options.findIndex((o) => o.value === preselect); if (idx !== -1) list.setSelectedIndex(idx); @@ -156,6 +155,8 @@ export interface SteppedSubmenuStep { preselect?: (context: Record) => string | undefined; /** Enable type-to-search fuzzy filtering for this step. */ searchable?: boolean; + /** Override the select list layout (column widths) for this step. */ + layout?: SelectListLayoutOptions; } interface SteppedSubmenuOptions { @@ -239,7 +240,7 @@ export class SteppedSubmenu extends Container { } }, undefined, - step.searchable ? { searchable: true } : undefined, + step.searchable || step.layout ? { searchable: step.searchable, layout: step.layout } : undefined, ); } From f0a2880f291a0f13d6fee1fa0032dd6c61518c45 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:26:36 +0200 Subject: [PATCH 234/284] fix(settings-selector): revert token estimate removal --- .../modes/interactive/components/settings-selector.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index a361a9df41e..45c6961865c 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -31,11 +31,11 @@ const MODEL_PICKER_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", - minimal: "Very brief reasoning", - low: "Light reasoning", - medium: "Moderate reasoning", - high: "Deep reasoning", - xhigh: "Extra-deep reasoning", + minimal: "Very brief reasoning (~1k tokens)", + low: "Light reasoning (~2k tokens)", + medium: "Moderate reasoning (~8k tokens)", + high: "Deep reasoning (~16k tokens)", + xhigh: "Extra-high reasoning (~32k tokens)", max: "Maximum reasoning", }; From 9c8070fbe4ef7c18feee8ab47c22c5dc1572fcd9 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:41:53 +0200 Subject: [PATCH 235/284] feat(settings-selector): ctrl + s persists /model --- .../interactive/components/model-selector.ts | 19 +++++++++++ .../components/settings-selector.ts | 5 +-- .../src/modes/interactive/interactive-mode.ts | 32 +++++++++---------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index efa96ffa4d5..35013185032 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -5,6 +5,7 @@ import { fuzzyFilter, getKeybindings, Input, + matchesKey, Spacer, Text, type TUI, @@ -53,6 +54,7 @@ export class ModelSelectorComponent extends Container implements Focusable { private currentModel?: Model; private modelRuntime: ModelRuntime; private onSelectCallback: (model: Model) => void; + private onSelectAsDefaultCallback?: (model: Model) => void; private onCancelCallback: () => void; private errorMessage?: string; private refreshStatusMessage = "Refreshing model catalogs…"; @@ -74,6 +76,7 @@ export class ModelSelectorComponent extends Container implements Focusable { onSelect: (model: Model) => void, onCancel: () => void, initialSearchInput?: string, + onSelectAsDefault?: (model: Model) => void, ) { super(); @@ -83,6 +86,7 @@ export class ModelSelectorComponent extends Container implements Focusable { this.scopedModels = scopedModels; this.scope = scopedModels.length > 0 ? "scoped" : "all"; this.onSelectCallback = onSelect; + this.onSelectAsDefaultCallback = onSelectAsDefault; this.onCancelCallback = onCancel; // Add top border @@ -122,6 +126,13 @@ export class ModelSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); + // Hint + if (this.onSelectAsDefaultCallback) { + this.addChild( + new Text(theme.fg("dim", " Enter to select \u00b7 Ctrl+S to set as default \u00b7 Esc to cancel"), 0, 0), + ); + } + // Add bottom border this.addChild(new DynamicBorder()); @@ -350,6 +361,14 @@ export class ModelSelectorComponent extends Container implements Focusable { this.dispose(); this.onCancelCallback(); } + // Ctrl+S — select and save as default + else if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefaultCallback) { + const selectedModel = this.filteredModels[this.selectedIndex]; + if (selectedModel) { + this.dispose(); + this.onSelectAsDefaultCallback(selectedModel.model); + } + } // Pass everything else to search input else { this.searchInput.handleInput(keyData); diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 45c6961865c..0cfb649237e 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -475,6 +475,7 @@ export class SettingsSelectorComponent extends Container { const supportsImages = getCapabilities().images; const followUpKey = keyDisplayText("app.message.followUp"); + const cycleThinkingKey = keyDisplayText("app.thinking.cycle"); let currentWarnings = { ...config.warnings }; const currentModelThinkingLevels = { ...config.modelThinkingLevels }; let lastSelectedDefaultModel: Model | undefined; @@ -653,7 +654,7 @@ export class SettingsSelectorComponent extends Container { { id: "thinking", label: "Default thinking level", - description: "Startup reasoning depth for thinking-capable models", + description: `Startup reasoning depth for thinking-capable models. ${cycleThinkingKey} cycles in-session.`, currentValue: config.thinkingLevel, submenu: (currentValue, done) => new SelectSubmenu( @@ -675,7 +676,7 @@ export class SettingsSelectorComponent extends Container { { id: "model-thinking", label: "Default thinking level per model", - description: "Override the default thinking level for specific models", + description: `Override the default thinking level for specific models. ${cycleThinkingKey} cycles in-session.`, currentValue: modelThinkingOverridesSummary(currentModelThinkingLevels), submenu: (_currentValue, done) => { const preselected = lastSelectedDefaultModel; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ea80883e99b..e3f941a200e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4947,32 +4947,32 @@ export class InteractiveMode { private showModelSelector(initialSearchInput?: string, options: { persist?: boolean } = {}): void { this.showSelector((done) => { + const selectModel = async (model: Model, persist: boolean) => { + try { + await this.session.setModel(model, { persist }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + done(); + this.showStatus(persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + this.checkDaxnutsEasterEgg(model); + } catch (error) { + done(); + this.showError(error instanceof Error ? error.message : String(error)); + } + }; const selector = new ModelSelectorComponent( this.ui, this.session.model, this.session.modelRuntime, this.session.scopedModels, - async (model) => { - try { - await this.session.setModel(model, { persist: options.persist === true }); - this.footer.invalidate(); - this.updateEditorBorderColor(); - done(); - this.showStatus( - options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`, - ); - void this.maybeWarnAboutAnthropicSubscriptionAuth(model); - this.checkDaxnutsEasterEgg(model); - } catch (error) { - done(); - this.showError(error instanceof Error ? error.message : String(error)); - } - }, + (model) => selectModel(model, options.persist === true), () => { done(); this.ui.requestRender(); }, initialSearchInput, + (model) => selectModel(model, true), ); return { component: selector, focus: selector, dispose: () => selector.dispose() }; }); From 496185f6e4267b979e3663c45f7eb70b0c6a97b4 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:54:52 +0200 Subject: [PATCH 236/284] feat(coding-agent): /thinking command --- .../coding-agent/src/core/slash-commands.ts | 1 + .../components/thinking-selector.ts | 45 +++++++- .../src/modes/interactive/interactive-mode.ts | 100 ++++++++++++++++-- .../test/interactive-mode-status.test.ts | 8 +- 4 files changed, 141 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index edf2b4987b6..d149074c29b 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -19,6 +19,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)", argumentHint: "[--default] " }, + { name: "thinking", description: "Set thinking level", argumentHint: "[--default] " }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, diff --git a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts index bc71bafc2c7..07273235c12 100644 --- a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts @@ -1,7 +1,17 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui"; -import { getSelectListTheme } from "../theme/theme.ts"; +import { + Container, + type Focusable, + matchesKey, + type SelectItem, + SelectList, + type SelectListLayoutOptions, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { getSelectListTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; +import { keyDisplayText } from "./keybinding-hints.ts"; const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { minPrimaryColumnWidth: 12, @@ -21,16 +31,28 @@ const LEVEL_DESCRIPTIONS: Record = { /** * Component that renders a thinking level selector with borders */ -export class ThinkingSelectorComponent extends Container { +export class ThinkingSelectorComponent extends Container implements Focusable { private selectList: SelectList; + private onSelectAsDefault?: (level: ThinkingLevel) => void; + private _focused = false; + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + } constructor( currentLevel: ThinkingLevel, availableLevels: ThinkingLevel[], onSelect: (level: ThinkingLevel) => void, onCancel: () => void, + onSelectAsDefault?: (level: ThinkingLevel) => void, ) { super(); + this.onSelectAsDefault = onSelectAsDefault; const thinkingLevels: SelectItem[] = availableLevels.map((level) => ({ value: level, @@ -40,6 +62,11 @@ export class ThinkingSelectorComponent extends Container { // Add top border this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text("Thinking Level", 0, 0)); + this.addChild(new Spacer(1)); + this.addChild(new Text(`${keyDisplayText("app.thinking.cycle")} cycles thinking levels in-session`, 0, 0)); + this.addChild(new Spacer(1)); // Create selector this.selectList = new SelectList( @@ -64,11 +91,23 @@ export class ThinkingSelectorComponent extends Container { }; this.addChild(this.selectList); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Ctrl+S to set as default · Esc to cancel"), 0, 0)); // Add bottom border this.addChild(new DynamicBorder()); } + handleInput(keyData: string): void { + if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefault) { + const item = this.selectList.getSelectedItem(); + if (item) this.onSelectAsDefault(item.value as ThinkingLevel); + return; + } + + this.selectList.handleInput(keyData); + } + getSelectList(): SelectList { return this.selectList; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e3f941a200e..1a1edef951c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7,7 +7,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat"; import type { @@ -148,6 +148,7 @@ import { type StatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.ts"; +import { ThinkingSelectorComponent } from "./components/thinking-selector.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts"; @@ -232,13 +233,13 @@ function isDeadTerminalError(error: unknown): boolean { return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); } -export interface ModelCommandArgs { +export interface DefaultFlagArgs { searchTerm?: string; persist: boolean; error?: string; } -export function parseModelCommandArgs(args: string | undefined): ModelCommandArgs { +export function parseDefaultFlagArgs(commandName: string, args: string | undefined): DefaultFlagArgs { if (!args?.trim()) return { persist: false }; let persist = false; @@ -260,7 +261,7 @@ export function parseModelCommandArgs(args: string | undefined): ModelCommandArg return { persist, searchTerm: searchTerms.join(" ") || undefined, - error: `Unknown /model option "${token}". Supported option: --default.`, + error: `Unknown /${commandName} option "${token}". Supported option: --default.`, }; } @@ -727,7 +728,7 @@ export class InteractiveMode { : null; } - const parsed = parseModelCommandArgs(prefix); + const parsed = parseDefaultFlagArgs("model", prefix); if (parsed.error) return null; const models = @@ -754,6 +755,32 @@ export class InteractiveMode { }; } + const thinkingCommand = slashCommands.find((command) => command.name === "thinking"); + if (thinkingCommand) { + thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const trimmedPrefix = prefix.trimStart(); + if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) { + return "--default".startsWith(trimmedPrefix) + ? [{ value: "--default ", label: "--default", description: "Set as startup default" }] + : null; + } + + const parsed = parseDefaultFlagArgs("thinking", prefix); + if (parsed.error) return null; + const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix; + return createFuzzyAutocompleteItems( + this.session.getAvailableThinkingLevels(), + searchPrefix, + (level) => level, + (level) => ({ + value: parsed.persist ? `--default ${level}` : level, + label: level, + description: parsed.persist ? "set as startup default" : undefined, + }), + ); + }; + } + const loginCommand = slashCommands.find((command) => command.name === "login"); if (loginCommand) { loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { @@ -3000,7 +3027,7 @@ export class InteractiveMode { return; } if (text === "/model" || text.startsWith("/model ")) { - const args = parseModelCommandArgs(text.startsWith("/model ") ? text.slice(7).trim() : undefined); + const args = parseDefaultFlagArgs("model", text.startsWith("/model ") ? text.slice(7).trim() : undefined); this.editor.setText(""); if (args.error) { this.showError(args.error); @@ -3009,6 +3036,19 @@ export class InteractiveMode { await this.handleModelCommand(args.searchTerm, { persist: args.persist }); return; } + if (text === "/thinking" || text.startsWith("/thinking ")) { + const args = parseDefaultFlagArgs( + "thinking", + text.startsWith("/thinking ") ? text.slice(10).trim() : undefined, + ); + this.editor.setText(""); + if (args.error) { + this.showError(args.error); + return; + } + this.handleThinkingCommand(args.searchTerm, { persist: args.persist }); + return; + } if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); @@ -4796,6 +4836,54 @@ export class InteractiveMode { }); } + private handleThinkingCommand(searchTerm?: string, options: { persist?: boolean } = {}): void { + const availableLevels = this.session.getAvailableThinkingLevels(); + if (!searchTerm) { + this.showThinkingSelector(options); + return; + } + + const normalized = searchTerm.trim().toLowerCase(); + const level = availableLevels.find((candidate) => candidate.toLowerCase() === normalized); + if (!level) { + this.showError(`Unknown thinking level "${searchTerm}". Available levels: ${availableLevels.join(", ")}.`); + return; + } + + this.selectThinkingLevel(level, options.persist === true); + } + + private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void { + try { + this.session.setThinkingLevel(level, { persist }); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + + private showThinkingSelector(options: { persist?: boolean } = {}): void { + this.showSelector((done) => { + const selectLevel = (level: ThinkingLevel, persist: boolean) => { + this.selectThinkingLevel(level, persist); + done(); + }; + const selector = new ThinkingSelectorComponent( + this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL, + this.session.getAvailableThinkingLevels(), + (level) => selectLevel(level, options.persist === true), + () => { + done(); + this.ui.requestRender(); + }, + (level) => selectLevel(level, true), + ); + return { component: selector, focus: selector }; + }); + } + private async handleModelCommand(searchTerm?: string, options: { persist?: boolean } = {}): Promise { if (!searchTerm) { this.showModelSelector(undefined, options); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index cd6a0b2729a..52729e85d9b 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -8,7 +8,7 @@ import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.ts"; -import { InteractiveMode, parseModelCommandArgs } from "../src/modes/interactive/interactive-mode.ts"; +import { InteractiveMode, parseDefaultFlagArgs } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; function renderLastLine(container: Container, width = 120): string { @@ -401,16 +401,16 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); describe("InteractiveMode.createBaseAutocompleteProvider", () => { - describe("parseModelCommandArgs", () => { + describe("parseDefaultFlagArgs", () => { test("parses --default as a persistent model selection", () => { - expect(parseModelCommandArgs("--default openai/gpt-5")).toEqual({ + expect(parseDefaultFlagArgs("model", "--default openai/gpt-5")).toEqual({ persist: true, searchTerm: "openai/gpt-5", }); }); test("rejects unknown /model flags", () => { - expect(parseModelCommandArgs("--global openai/gpt-5")).toMatchObject({ + expect(parseDefaultFlagArgs("model", "--global openai/gpt-5")).toMatchObject({ persist: false, error: 'Unknown /model option "--global". Supported option: --default.', }); From b7bb00b936dbe21b8e160b3e89efdec361846699 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 19 Aug 2026 23:35:43 +0200 Subject: [PATCH 237/284] fix(ai): retain reasoning details in thinking signature --- packages/ai/src/api/openai-completions.ts | 134 ++++++++---------- packages/ai/src/types.ts | 3 +- ...enai-completions-reasoning-details.test.ts | 38 +++-- packages/server/src/protocol.ts | 1 - 4 files changed, 85 insertions(+), 91 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index a36ce4cb145..08b181f230b 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -160,17 +160,6 @@ function isOpenAIReasoningDetail(detail: unknown): detail is OpenAIReasoningDeta } } -function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { - return ( - isReasoningDetailObject(detail) && - detail.type === "reasoning.encrypted" && - typeof detail.id === "string" && - detail.id.length > 0 && - typeof detail.data === "string" && - detail.data.length > 0 - ); -} - export interface OpenAICompletionsOptions extends StreamOptions { toolChoice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; @@ -219,7 +208,6 @@ type OpenAIReasoningSummaryDetail = OpenAIReasoningDetailBase & { type OpenAIEncryptedReasoningDetail = OpenAIReasoningDetailBase & { type: "reasoning.encrypted"; - id: string; data: string; }; @@ -231,6 +219,34 @@ type OpenAIReasoningTextDetail = OpenAIReasoningDetailBase & { type OpenAIReasoningDetail = OpenAIReasoningSummaryDetail | OpenAIEncryptedReasoningDetail | OpenAIReasoningTextDetail; +function parseOpenAIReasoningDetails(signature: string | undefined): OpenAIReasoningDetail[] | undefined { + if (!signature) return undefined; + try { + const parsed = JSON.parse(signature) as unknown; + return Array.isArray(parsed) && parsed.length > 0 && parsed.every(isOpenAIReasoningDetail) ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseLegacyEncryptedReasoningDetail( + signature: string | undefined, +): OpenAIEncryptedReasoningDetail | undefined { + if (!signature) return undefined; + try { + const parsed = JSON.parse(signature) as unknown; + return isOpenAIReasoningDetail(parsed) && + parsed.type === "reasoning.encrypted" && + typeof parsed.id === "string" && + parsed.id.length > 0 && + parsed.data.length > 0 + ? parsed + : undefined; + } catch { + return undefined; + } +} + const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const; type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number]; @@ -341,7 +357,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio let hasFinishReason = false; const toolCallBlocksByIndex = new Map(); const toolCallBlocksById = new Map(); - const pendingReasoningDetailsByToolCallId = new Map(); const blocks = output.content as StreamingBlock[]; const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); const getCustomToolCallInput = (block: StreamingToolCallBlock): string => { @@ -432,16 +447,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio } return thinkingBlock; }; - const applyPendingReasoningDetail = (block: StreamingToolCallBlock) => { - if (!block.id) { - return; - } - const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id); - if (pendingReasoningDetail) { - block.thoughtSignature = pendingReasoningDetail; - pendingReasoningDetailsByToolCallId.delete(block.id); - } - }; const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => { const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; const name = toolCall.function?.name ?? toolCall.custom?.name ?? ""; @@ -498,7 +503,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio }; delete block.partialArgs; } - applyPendingReasoningDetail(block); return block; }; @@ -616,24 +620,13 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; if (Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { - if (!isOpenAIReasoningDetail(detail)) { - continue; - } - output.reasoningDetails ??= []; - // OpenRouter requires reasoning_details to be replayed unmodified and in order. - output.reasoningDetails.push(detail); - - // Keep the legacy encrypted tool-call attachment path for compatibility with - // sessions that replay encrypted details from toolCall.thoughtSignature. - if (isEncryptedReasoningDetail(detail)) { - const serializedDetail = JSON.stringify(detail); - const matchingToolCall = toolCallBlocksById.get(detail.id); - if (matchingToolCall) { - matchingToolCall.thoughtSignature = serializedDetail; - } else { - pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail); - } - } + if (!isOpenAIReasoningDetail(detail)) continue; + const block = ensureThinkingBlock(""); + const preservedDetails = parseOpenAIReasoningDetails(block.thinkingSignature) ?? []; + preservedDetails.push(detail); + // Keep provider replay data in the existing signature slot. OpenRouter + // requires the complete reasoning_details sequence in its original order. + block.thinkingSignature = JSON.stringify(preservedDetails); } } } @@ -1237,9 +1230,18 @@ export function convertMessages( ); const assistantText = assistantTextParts.map((part) => part.text).join(""); - const nonEmptyThinkingBlocks = msg.content - .filter(isThinkingContentBlock) - .filter((block) => block.thinking.trim().length > 0); + const thinkingBlocks = msg.content.filter(isThinkingContentBlock); + const toolCalls = msg.content.filter(isToolCallBlock); + const signedReasoningDetails = thinkingBlocks + .map((block) => parseOpenAIReasoningDetails(block.thinkingSignature)) + .find((details) => details !== undefined); + const legacyReasoningDetails = toolCalls + .map((toolCall) => parseLegacyEncryptedReasoningDetail(toolCall.thoughtSignature)) + .filter((detail): detail is OpenAIEncryptedReasoningDetail => detail !== undefined); + const preservedReasoningDetails = + signedReasoningDetails ?? (legacyReasoningDetails.length > 0 ? legacyReasoningDetails : undefined); + + const nonEmptyThinkingBlocks = thinkingBlocks.filter((block) => block.thinking.trim().length > 0); if (nonEmptyThinkingBlocks.length > 0) { if (compat.requiresThinkingAsText) { // Convert thinking blocks to plain text (no tags to avoid model mimicking them) @@ -1257,13 +1259,16 @@ export function convertMessages( assistantMsg.content = assistantText; } - // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) - let signature = nonEmptyThinkingBlocks[0].thinkingSignature; - if (model.provider === "opencode-go" && signature === "reasoning") { - signature = "reasoning_content"; - } - if (signature && isOpenAICompletionsReasoningField(signature)) { - assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + // reasoning_details is the structured alternative to a raw reasoning field. + if (!preservedReasoningDetails) { + // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } + if (signature && isOpenAICompletionsReasoningField(signature)) { + assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + } } } } else if (assistantText.length > 0) { @@ -1275,15 +1280,6 @@ export function convertMessages( assistantMsg.content = assistantText; } - const preservedReasoningDetails = - msg.provider === model.provider && - msg.api === model.api && - msg.model === model.id && - msg.reasoningDetails?.length - ? msg.reasoningDetails - : undefined; - - const toolCalls = msg.content.filter(isToolCallBlock); if (toolCalls.length > 0) { assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => { const customInputProperty = options?.grammarToolInputProperties?.get(tc.name); @@ -1306,22 +1302,6 @@ export function convertMessages( }, }; }); - if (!preservedReasoningDetails) { - const reasoningDetails = toolCalls - .filter((tc) => tc.thoughtSignature) - .map((tc): OpenAIReasoningDetail | null => { - try { - const parsed = JSON.parse(tc.thoughtSignature!) as unknown; - return isOpenAIReasoningDetail(parsed) ? parsed : null; - } catch { - return null; - } - }) - .filter((detail): detail is OpenAIReasoningDetail => detail !== null); - if (reasoningDetails.length > 0) { - assistantMsg.reasoning_details = reasoningDetails; - } - } } if (preservedReasoningDetails) { assistantMsg.reasoning_details = preservedReasoningDetails; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7647226b716..d35a48b84eb 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -356,7 +356,7 @@ export interface TextContent { export interface ThinkingContent { type: "thinking"; thinking: string; - thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID + thinkingSignature?: string; // Provider-specific opaque or serialized reasoning replay data /** When true, the thinking content was redacted by safety filters. The opaque * encrypted payload is stored in `thinkingSignature` so it can be passed back * to the API for multi-turn continuity. */ @@ -432,7 +432,6 @@ export interface AssistantMessage { model: string; responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one - reasoningDetails?: JsonValue[]; // Provider-specific structured reasoning details to replay verbatim on later same-model turns. diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. usage: Usage; stopReason: StopReason; diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index ae41b938f92..723a51b79a5 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -99,9 +99,11 @@ async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Pr return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result(); } -function getAssistantPayload(payload: unknown): { reasoning_details?: unknown } | undefined { - const messages = (payload as { messages?: Array<{ role?: string; reasoning_details?: unknown }> }).messages ?? []; - return messages.find((message) => message.role === "assistant"); +function getAssistantPayload(payload: unknown): { reasoning?: unknown; reasoning_details?: unknown } | undefined { + const messages = ( + payload as { messages?: Array<{ role?: string; reasoning?: unknown; reasoning_details?: unknown }> } + ).messages; + return messages?.find((message) => message.role === "assistant"); } describe("openai-completions reasoning_details streaming", () => { @@ -110,22 +112,26 @@ describe("openai-completions reasoning_details streaming", () => { mockState.payloads = []; }); - it("preserves reasoning_details that arrive before their matching tool call", async () => { + it("preserves reasoning_details in the thinking signature", async () => { mockState.chunkSets = [ [chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")], [chunk({ content: "ok" }), chunk({}, "stop")], ]; const assistantMessage = await runOpenAICompletionsStream(); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: "", + thinkingSignature: JSON.stringify([reasoningDetail]), + }); const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); - expect(toolCall).toMatchObject({ + expect(toolCall).toEqual({ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" }, - thoughtSignature: JSON.stringify(reasoningDetail), }); - expect(assistantMessage.reasoningDetails).toEqual([reasoningDetail]); await runOpenAICompletionsStream([assistantMessage]); @@ -139,7 +145,10 @@ describe("openai-completions reasoning_details streaming", () => { ]; const assistantMessage = await runOpenAICompletionsStream(); - delete assistantMessage.reasoningDetails; + assistantMessage.content = assistantMessage.content.filter((block) => block.type !== "thinking"); + const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); + if (!toolCall || toolCall.type !== "toolCall") throw new Error("Expected tool call"); + toolCall.thoughtSignature = JSON.stringify(reasoningDetail); await runOpenAICompletionsStream([assistantMessage]); @@ -149,7 +158,7 @@ describe("openai-completions reasoning_details streaming", () => { it("preserves signed text and summary reasoning_details in their original sequence", async () => { mockState.chunkSets = [ [ - chunk({ reasoning_details: [signedReasoningTextDetail] }), + chunk({ reasoning: signedReasoningTextDetail.text, reasoning_details: [signedReasoningTextDetail] }), chunk({ reasoning_details: [reasoningDetail, reasoningSummaryDetail] }), toolCallChunk(), chunk({}, "tool_calls"), @@ -159,10 +168,17 @@ describe("openai-completions reasoning_details streaming", () => { const assistantMessage = await runOpenAICompletionsStream(); const expectedReasoningDetails = [signedReasoningTextDetail, reasoningDetail, reasoningSummaryDetail]; - expect(assistantMessage.reasoningDetails).toEqual(expectedReasoningDetails); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: signedReasoningTextDetail.text, + thinkingSignature: JSON.stringify(expectedReasoningDetails), + }); await runOpenAICompletionsStream([assistantMessage]); - expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); + const payload = getAssistantPayload(mockState.payloads[1]); + expect(payload?.reasoning_details).toEqual(expectedReasoningDetails); + expect(payload?.reasoning).toBeUndefined(); }); }); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 08ebfbf32f3..069828e590e 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -88,7 +88,6 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "model" | "responseModel" | "responseId" - | "reasoningDetails" | "diagnostics" | "usage" | "stopReason" From 5133c9284fe023436f4251fac2a6e8fb00a883b4 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:30:47 +0200 Subject: [PATCH 238/284] chore(settings-selector): get rid of --default and global model ctrl+s is enough, there goes my joy --- .../coding-agent/src/core/agent-session.ts | 17 +-- .../coding-agent/src/core/slash-commands.ts | 4 +- .../components/settings-selector.ts | 101 +------------- .../src/modes/interactive/interactive-mode.ts | 125 +++--------------- .../test/interactive-mode-status.test.ts | 64 +-------- .../agent-session-model-extension.test.ts | 19 ++- 6 files changed, 50 insertions(+), 280 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1be95d1d178..17b5b5909e9 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1608,8 +1608,8 @@ export class AgentSession { this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); } - // Re-clamp session thinking level for new model's capabilities. - // Per-model thinking level overrides take priority over session carry-over. + // Apply thinking level for the new model. + // Per-model thinking level overrides take priority over the global default. // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); @@ -1660,9 +1660,9 @@ export class AgentSession { this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); } - // Apply session thinking level. - // - Explicit scoped model thinking level overrides current session level - // - Undefined scoped model thinking level inherits the current session preference + // Apply thinking level for the new model. + // - Explicit scoped model thinking level overrides defaults + // - Per-model thinking level overrides take priority over the global default // setThinkingLevel clamps to model capabilities. // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); @@ -1694,7 +1694,7 @@ export class AgentSession { this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); } - // Re-clamp session thinking level for new model's capabilities. + // Apply thinking level for the new model. // Model persistence does not implicitly rewrite the global thinking default. this.setThinkingLevel(thinkingLevel); @@ -1781,10 +1781,7 @@ export class AgentSession { return perModel; } } - if (!this.supportsThinking()) { - return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; - } - return this.thinkingLevel; + return this.settingsManager.getDefaultThinkingLevel() ?? this.thinkingLevel ?? DEFAULT_THINKING_LEVEL; } private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel { diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index d149074c29b..57c3e23d6e7 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -18,8 +18,8 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, - { name: "model", description: "Select model (opens selector UI)", argumentHint: "[--default] " }, - { name: "thinking", description: "Set thinking level", argumentHint: "[--default] " }, + { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, + { name: "thinking", description: "Set thinking level", argumentHint: "" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 0cfb649237e..c8683d2909d 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -24,9 +24,6 @@ import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; import { SelectSubmenu, SteppedSubmenu, type SteppedSubmenuStep } from "./settings-submenu.ts"; -const NO_DEFAULT_MODEL_VALUE = "__none__"; -const NO_DEFAULT_MODEL_LABEL = "not set"; - const MODEL_PICKER_LAYOUT = { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 46 }; const THINKING_DESCRIPTIONS: Record = { @@ -91,7 +88,6 @@ export interface SettingsConfig { export interface SettingsCallbacks { onAutoCompactChange: (enabled: boolean) => void; - onDefaultModelChange: (model: Model) => Promise; onShowImagesChange: (enabled: boolean) => void; onImageWidthCellsChange: (width: number) => void; onAutoResizeImagesChange: (enabled: boolean) => void; @@ -183,16 +179,6 @@ function modelDisplayLabel(model: Model): string { return `${model.id} [${model.provider}]`; } -function defaultModelDisplayValue( - key: string, - model: Model | undefined, - overrides: Record, -): string { - const label = model ? modelDisplayLabel(model) : key; - const override = overrides[key]; - return override ? `${label} \u00b7 ${override}` : label; -} - function modelThinkingOverridesSummary(overrides: Record): string { const count = Object.keys(overrides).length; if (count === 0) return "none"; @@ -203,24 +189,6 @@ function modelItemLabel(model: Model): string { return `${model.id} ${theme.fg("muted", `[${model.provider}]`)}`; } -function defaultModelItems(models: readonly Model[], overrides?: Record): SelectItem[] { - return [...models] - .sort((a, b) => { - const providerCompare = a.provider.localeCompare(b.provider); - if (providerCompare !== 0) return providerCompare; - return (a.name || a.id).localeCompare(b.name || b.id); - }) - .map((model) => { - const key = modelSettingKey(model); - const override = overrides?.[key]; - return { - value: key, - label: modelItemLabel(model), - description: override ?? undefined, - }; - }); -} - function themeItems(availableThemes: string[]): SelectItem[] { return availableThemes.map((name) => ({ value: name, label: name })); } @@ -478,13 +446,10 @@ export class SettingsSelectorComponent extends Container { const cycleThinkingKey = keyDisplayText("app.thinking.cycle"); let currentWarnings = { ...config.warnings }; const currentModelThinkingLevels = { ...config.modelThinkingLevels }; - let lastSelectedDefaultModel: Model | undefined; const defaultModelByValue = new Map( config.availableDefaultModels.map((model) => [modelSettingKey(model), model]), ); - let currentDefaultModelKey: string | undefined = defaultModelByValue.has(config.defaultModel) - ? config.defaultModel - : undefined; + const currentDefaultModelKey = defaultModelByValue.has(config.defaultModel) ? config.defaultModel : undefined; const items: SettingItem[] = [ { @@ -516,55 +481,6 @@ export class SettingsSelectorComponent extends Container { currentValue: config.transport, values: ["sse", "websocket", "websocket-cached", "auto"], }, - { - id: "default-model", - label: "Default model", - description: "Startup model for new sessions", - currentValue: defaultModelDisplayValue( - config.defaultModel, - defaultModelByValue.get(config.defaultModel), - currentModelThinkingLevels, - ), - submenu: (currentValue, done) => { - const fresh = defaultModelItems(config.availableDefaultModels, currentModelThinkingLevels); - const options = - fresh.length > 0 - ? fresh - : [ - { - value: NO_DEFAULT_MODEL_VALUE, - label: "No models available", - description: "Log in to a provider or configure an API key first", - }, - ]; - return new SelectSubmenu( - "Default Model", - "Select the model to use when starting new sessions", - options, - currentValue === NO_DEFAULT_MODEL_LABEL ? NO_DEFAULT_MODEL_VALUE : currentValue, - (value) => { - const model = defaultModelByValue.get(value); - if (!model) { - done(); - return; - } - void callbacks.onDefaultModelChange(model).then( - () => { - lastSelectedDefaultModel = model; - currentDefaultModelKey = value; - done(defaultModelDisplayValue(value, model, currentModelThinkingLevels), { - navigateTo: "model-thinking", - }); - }, - () => done(), - ); - }, - () => done(), - undefined, - { searchable: true, layout: MODEL_PICKER_LAYOUT }, - ); - }, - }, { id: "http-idle-timeout", label: "HTTP idle timeout", @@ -679,9 +595,6 @@ export class SettingsSelectorComponent extends Container { description: `Override the default thinking level for specific models. ${cycleThinkingKey} cycles in-session.`, currentValue: modelThinkingOverridesSummary(currentModelThinkingLevels), submenu: (_currentValue, done) => { - const preselected = lastSelectedDefaultModel; - lastSelectedDefaultModel = undefined; - const steps: SteppedSubmenuStep[] = [ { key: "model", @@ -770,19 +683,9 @@ export class SettingsSelectorComponent extends Container { } }, () => { - this.settingsList.updateValue( - "default-model", - defaultModelDisplayValue( - currentDefaultModelKey ?? config.defaultModel, - defaultModelByValue.get(currentDefaultModelKey ?? config.defaultModel), - currentModelThinkingLevels, - ), - ); done(summary()); }, - preselected - ? { startAtStep: 1, initialContext: { model: modelSettingKey(preselected) } } - : { loop: true }, + { loop: true }, ); }, }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1a1edef951c..b20b7ef5ec7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -233,44 +233,6 @@ function isDeadTerminalError(error: unknown): boolean { return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); } -export interface DefaultFlagArgs { - searchTerm?: string; - persist: boolean; - error?: string; -} - -export function parseDefaultFlagArgs(commandName: string, args: string | undefined): DefaultFlagArgs { - if (!args?.trim()) return { persist: false }; - - let persist = false; - const searchTerms: string[] = []; - for (const token of args.trim().split(/\s+/u)) { - if (token === "--default") { - persist = true; - continue; - } - - if (token.startsWith("--default=")) { - persist = true; - const value = token.slice("--default=".length); - if (value) searchTerms.push(value); - continue; - } - - if (token.startsWith("--")) { - return { - persist, - searchTerm: searchTerms.join(" ") || undefined, - error: `Unknown /${commandName} option "${token}". Supported option: --default.`, - }; - } - - searchTerms.push(token); - } - - return { persist, searchTerm: searchTerms.join(" ") || undefined }; -} - const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings."; @@ -721,16 +683,6 @@ export class InteractiveMode { const modelCommand = slashCommands.find((command) => command.name === "model"); if (modelCommand) { modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { - const trimmedPrefix = prefix.trimStart(); - if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) { - return "--default".startsWith(trimmedPrefix) - ? [{ value: "--default ", label: "--default", description: "Set as startup default" }] - : null; - } - - const parsed = parseDefaultFlagArgs("model", prefix); - if (parsed.error) return null; - const models = this.session.scopedModels.length > 0 ? this.session.scopedModels.map((s) => s.model) @@ -746,11 +698,10 @@ export class InteractiveMode { label: `${m.provider}/${m.id}`, })); - const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix; - return createFuzzyAutocompleteItems(items, searchPrefix, getModelSearchText, (item) => ({ - value: parsed.persist ? `--default ${item.label}` : item.label, + return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({ + value: item.label, label: item.id, - description: parsed.persist ? `${item.provider} · set as startup default` : item.provider, + description: item.provider, })); }; } @@ -758,24 +709,13 @@ export class InteractiveMode { const thinkingCommand = slashCommands.find((command) => command.name === "thinking"); if (thinkingCommand) { thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { - const trimmedPrefix = prefix.trimStart(); - if (trimmedPrefix.startsWith("--") && !trimmedPrefix.includes(" ")) { - return "--default".startsWith(trimmedPrefix) - ? [{ value: "--default ", label: "--default", description: "Set as startup default" }] - : null; - } - - const parsed = parseDefaultFlagArgs("thinking", prefix); - if (parsed.error) return null; - const searchPrefix = parsed.persist ? (parsed.searchTerm ?? "") : prefix; return createFuzzyAutocompleteItems( this.session.getAvailableThinkingLevels(), - searchPrefix, + prefix, (level) => level, (level) => ({ - value: parsed.persist ? `--default ${level}` : level, + value: level, label: level, - description: parsed.persist ? "set as startup default" : undefined, }), ); }; @@ -3027,26 +2967,15 @@ export class InteractiveMode { return; } if (text === "/model" || text.startsWith("/model ")) { - const args = parseDefaultFlagArgs("model", text.startsWith("/model ") ? text.slice(7).trim() : undefined); + const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; this.editor.setText(""); - if (args.error) { - this.showError(args.error); - return; - } - await this.handleModelCommand(args.searchTerm, { persist: args.persist }); + await this.handleModelCommand(searchTerm); return; } if (text === "/thinking" || text.startsWith("/thinking ")) { - const args = parseDefaultFlagArgs( - "thinking", - text.startsWith("/thinking ") ? text.slice(10).trim() : undefined, - ); + const searchTerm = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined; this.editor.setText(""); - if (args.error) { - this.showError(args.error); - return; - } - this.handleThinkingCommand(args.searchTerm, { persist: args.persist }); + this.handleThinkingCommand(searchTerm); return; } if (text === "/export" || text.startsWith("/export ")) { @@ -4635,18 +4564,6 @@ export class InteractiveMode { this.session.setAutoCompactionEnabled(enabled); this.footer.setAutoCompactEnabled(enabled); }, - onDefaultModelChange: async (model) => { - try { - await this.session.setModel(model, { persist: true }); - this.footer.invalidate(); - this.updateEditorBorderColor(); - this.showStatus(`Default model: ${model.provider}/${model.id}`); - void this.maybeWarnAboutAnthropicSubscriptionAuth(model); - this.checkDaxnutsEasterEgg(model); - } catch (error) { - this.showError(error instanceof Error ? error.message : String(error)); - } - }, onShowImagesChange: (enabled) => { this.settingsManager.setShowImages(enabled); for (const child of this.chatContainer.children) { @@ -4836,10 +4753,10 @@ export class InteractiveMode { }); } - private handleThinkingCommand(searchTerm?: string, options: { persist?: boolean } = {}): void { + private handleThinkingCommand(searchTerm?: string): void { const availableLevels = this.session.getAvailableThinkingLevels(); if (!searchTerm) { - this.showThinkingSelector(options); + this.showThinkingSelector(); return; } @@ -4850,7 +4767,7 @@ export class InteractiveMode { return; } - this.selectThinkingLevel(level, options.persist === true); + this.selectThinkingLevel(level, false); } private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void { @@ -4864,7 +4781,7 @@ export class InteractiveMode { } } - private showThinkingSelector(options: { persist?: boolean } = {}): void { + private showThinkingSelector(): void { this.showSelector((done) => { const selectLevel = (level: ThinkingLevel, persist: boolean) => { this.selectThinkingLevel(level, persist); @@ -4873,7 +4790,7 @@ export class InteractiveMode { const selector = new ThinkingSelectorComponent( this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL, this.session.getAvailableThinkingLevels(), - (level) => selectLevel(level, options.persist === true), + (level) => selectLevel(level, false), () => { done(); this.ui.requestRender(); @@ -4884,19 +4801,19 @@ export class InteractiveMode { }); } - private async handleModelCommand(searchTerm?: string, options: { persist?: boolean } = {}): Promise { + private async handleModelCommand(searchTerm?: string): Promise { if (!searchTerm) { - this.showModelSelector(undefined, options); + this.showModelSelector(); return; } const model = await this.findExactModelMatch(searchTerm); if (model) { try { - await this.session.setModel(model, { persist: options.persist === true }); + await this.session.setModel(model, { persist: false }); this.footer.invalidate(); this.updateEditorBorderColor(); - this.showStatus(options.persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); + this.showStatus(`Model: ${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); } catch (error) { @@ -4905,7 +4822,7 @@ export class InteractiveMode { return; } - this.showModelSelector(searchTerm, options); + this.showModelSelector(searchTerm); } private async findExactModelMatch(searchTerm: string): Promise | undefined> { @@ -5033,7 +4950,7 @@ export class InteractiveMode { }); } - private showModelSelector(initialSearchInput?: string, options: { persist?: boolean } = {}): void { + private showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { const selectModel = async (model: Model, persist: boolean) => { try { @@ -5054,7 +4971,7 @@ export class InteractiveMode { this.session.model, this.session.modelRuntime, this.session.scopedModels, - (model) => selectModel(model, options.persist === true), + (model) => selectModel(model, false), () => { done(); this.ui.requestRender(); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 52729e85d9b..a4aaa48c02a 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -8,7 +8,7 @@ import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.ts"; -import { InteractiveMode, parseDefaultFlagArgs } from "../src/modes/interactive/interactive-mode.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; function renderLastLine(container: Container, width = 120): string { @@ -401,22 +401,6 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); describe("InteractiveMode.createBaseAutocompleteProvider", () => { - describe("parseDefaultFlagArgs", () => { - test("parses --default as a persistent model selection", () => { - expect(parseDefaultFlagArgs("model", "--default openai/gpt-5")).toEqual({ - persist: true, - searchTerm: "openai/gpt-5", - }); - }); - - test("rejects unknown /model flags", () => { - expect(parseDefaultFlagArgs("model", "--global openai/gpt-5")).toMatchObject({ - persist: false, - error: 'Unknown /model option "--global". Supported option: --default.', - }); - }); - }); - test("matches model command arguments across provider/model order", async () => { type TestModel = { id: string; provider: string; name: string }; type FakeInteractiveMode = { @@ -468,52 +452,6 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { ]); }); - test("preserves --default when completing model command arguments", async () => { - type TestModel = { id: string; provider: string; name: string }; - type FakeInteractiveMode = { - session: { - scopedModels: Array<{ model: TestModel }>; - modelRuntime: { getAvailableSnapshot: () => TestModel[] }; - promptTemplates: []; - extensionRunner: { getRegisteredCommands: () => [] }; - resourceLoader: { getSkills: () => { skills: [] } }; - }; - settingsManager: { getEnableSkillCommands: () => boolean }; - skillCommands: Map; - sessionManager: { getCwd: () => string }; - fdPath: null; - }; - - const createBaseAutocompleteProvider = ( - InteractiveMode as unknown as { - prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider }; - } - ).prototype.createBaseAutocompleteProvider; - const models = [{ id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" }]; - const fakeThis: FakeInteractiveMode = { - session: { - scopedModels: [], - modelRuntime: { getAvailableSnapshot: () => models }, - promptTemplates: [], - extensionRunner: { getRegisteredCommands: () => [] }, - resourceLoader: { getSkills: () => ({ skills: [] }) }, - }, - settingsManager: { getEnableSkillCommands: () => false }, - skillCommands: new Map(), - sessionManager: { getCwd: () => "/tmp" }, - fdPath: null, - }; - - const provider = createBaseAutocompleteProvider.call(fakeThis); - const line = "/model --default codex"; - const suggestions = await provider.getSuggestions([line], 0, line.length, { - signal: new AbortController().signal, - }); - - expect(suggestions?.prefix).toBe("--default codex"); - expect(suggestions?.items[0]?.value).toBe("--default openai-codex/gpt-5.5"); - }); - test("matches login command arguments by provider id and name", async () => { type FakeInteractiveMode = { session: { diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index 487f55e0c75..f024a67d151 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -110,6 +110,7 @@ describe("AgentSession model and extension characterization", () => { { id: "faux-1", name: "One", reasoning: true }, { id: "faux-2", name: "Two", reasoning: true }, ], + settings: { defaultThinkingLevel: "medium" }, }); harnesses.push(harness); @@ -125,10 +126,24 @@ describe("AgentSession model and extension characterization", () => { await harness.session.setModel(model2); expect(harness.session.thinkingLevel).toBe("low"); - // Switch back to faux-1 → no per-model override, carries session level + // Switch back to faux-1 → no per-model override, uses global default const model1 = harness.getModel("faux-1")!; await harness.session.setModel(model1); - expect(harness.session.thinkingLevel).toBe("low"); + expect(harness.session.thinkingLevel).toBe("medium"); + }); + + it("falls back to current session thinking level when no per-model or global default is configured", async () => { + const harness = await createHarness({ + models: [ + { id: "faux-1", name: "One", reasoning: true }, + { id: "faux-2", name: "Two", reasoning: true }, + ], + }); + harnesses.push(harness); + + harness.session.setThinkingLevel("high"); + await harness.session.setModel(harness.getModel("faux-2")!); + expect(harness.session.thinkingLevel).toBe("high"); }); it("per-model override takes priority over global default during model switch", async () => { From 5b3caaf4cb45d1e0315fbe46ebf26fc68f1dd065 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:35:21 +0200 Subject: [PATCH 239/284] chore(settings-selector): get rid of default thinking in settings, ctrl + S is enough --- .../components/settings-selector.ts | 23 ------------------- .../src/modes/interactive/interactive-mode.ts | 5 ---- 2 files changed, 28 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index c8683d2909d..9e6673fc43d 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -97,7 +97,6 @@ export interface SettingsCallbacks { onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onTransportChange: (transport: Transport) => void; onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; - onThinkingLevelChange: (level: ThinkingLevel) => void; onModelThinkingLevelChange: (provider: string, modelId: string, level: ThinkingLevel) => void; onModelThinkingLevelRemove: (provider: string, modelId: string) => void; onThemeChange: (theme: string) => void; @@ -567,28 +566,6 @@ export class SettingsSelectorComponent extends Container { () => done(), ), }, - { - id: "thinking", - label: "Default thinking level", - description: `Startup reasoning depth for thinking-capable models. ${cycleThinkingKey} cycles in-session.`, - currentValue: config.thinkingLevel, - submenu: (currentValue, done) => - new SelectSubmenu( - "Default Thinking Level", - "Select the reasoning depth to use when starting new sessions", - config.availableThinkingLevels.map((level) => ({ - value: level, - label: level, - description: THINKING_DESCRIPTIONS[level], - })), - currentValue, - (value) => { - callbacks.onThinkingLevelChange(value as ThinkingLevel); - done(value); - }, - () => done(), - ), - }, { id: "model-thinking", label: "Default thinking level per model", diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b20b7ef5ec7..e95000042ea 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4605,11 +4605,6 @@ export class InteractiveMode { configureHttpDispatcher(timeoutMs); this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); }, - onThinkingLevelChange: (level) => { - this.session.setThinkingLevel(level, { persist: true }); - this.footer.invalidate(); - this.updateEditorBorderColor(); - }, onModelThinkingLevelChange: (provider, modelId, level) => { this.settingsManager.setModelThinkingLevel(provider, modelId, level); // If the override is for the current model, apply it to the session too From 1d3503fb9b422f3b5262f369c1b718230ca76df8 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:55:27 +0200 Subject: [PATCH 240/284] feat(settings-selector): show default, make default searchable for model and thinking (#8399) --- .../interactive/components/model-selector.ts | 41 ++++++++-- .../components/thinking-selector.ts | 80 +++++++++++++------ .../src/modes/interactive/interactive-mode.ts | 4 + 3 files changed, 94 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 35013185032..f3aa1b01f29 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -28,6 +28,11 @@ interface ScopedModelItem { thinkingLevel?: string; } +interface DefaultModelReference { + provider: string; + id: string; +} + type ModelScope = "all" | "scoped"; /** @@ -61,6 +66,7 @@ export class ModelSelectorComponent extends Container implements Focusable { private refreshStatusSuccess = false; private tui: TUI; private scopedModels: ReadonlyArray; + private defaultModel?: DefaultModelReference; private scope: ModelScope = "all"; private scopeText?: Text; private scopeHintText?: Text; @@ -77,6 +83,7 @@ export class ModelSelectorComponent extends Container implements Focusable { onCancel: () => void, initialSearchInput?: string, onSelectAsDefault?: (model: Model) => void, + defaultModel?: DefaultModelReference, ) { super(); @@ -84,6 +91,7 @@ export class ModelSelectorComponent extends Container implements Focusable { this.currentModel = currentModel; this.modelRuntime = modelRuntime; this.scopedModels = scopedModels; + this.defaultModel = defaultModel; this.scope = scopedModels.length > 0 ? "scoped" : "all"; this.onSelectCallback = onSelect; this.onSelectAsDefaultCallback = onSelectAsDefault; @@ -237,6 +245,10 @@ export class ModelSelectorComponent extends Container implements Focusable { return keyHint("tui.input.tab", "scope") + theme.fg("muted", " (all/scoped)"); } + private isDefaultModel(model: Model): boolean { + return this.defaultModel?.provider === model.provider && this.defaultModel.id === model.id; + } + private setScope(scope: ModelScope): void { if (this.scope === scope) return; this.scope = scope; @@ -250,11 +262,24 @@ export class ModelSelectorComponent extends Container implements Focusable { } private filterModels(query: string): void { - this.filteredModels = query - ? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) => - getModelSelectorSearchText({ id, provider, name: model.name }), - ) - : this.activeModels; + if (query) { + const filtered = fuzzyFilter(this.activeModels, query, (item) => { + const defaultText = this.isDefaultModel(item.model) ? " default startup" : ""; + return `${getModelSelectorSearchText({ id: item.id, provider: item.provider, name: item.model.name })}${defaultText}`; + }); + if (/\b(default|startup)\b/iu.test(query)) { + const defaultItems = this.activeModels.filter((item) => this.isDefaultModel(item.model)); + const defaultKeys = new Set(defaultItems.map((item) => `${item.provider}\0${item.id}`)); + this.filteredModels = [ + ...defaultItems, + ...filtered.filter((item) => !defaultKeys.has(`${item.provider}\0${item.id}`)), + ]; + } else { + this.filteredModels = filtered; + } + } else { + this.filteredModels = this.activeModels; + } // When filtering by a query, move the selector to the top row so the best // match is highlighted. When the query is cleared, keep the current position // clamped to the (restored) list length. @@ -279,6 +304,8 @@ export class ModelSelectorComponent extends Container implements Focusable { const isSelected = i === this.selectedIndex; const isCurrent = modelsAreEqual(this.currentModel, item.model); + const isDefault = this.isDefaultModel(item.model); + const defaultBadge = isDefault ? theme.fg("muted", " · default") : ""; let line = ""; if (isSelected) { @@ -286,12 +313,12 @@ export class ModelSelectorComponent extends Container implements Focusable { const modelText = `${item.id}`; const providerBadge = theme.fg("muted", `[${item.provider}]`); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; - line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`; + line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${defaultBadge}${checkmark}`; } else { const modelText = ` ${item.id}`; const providerBadge = theme.fg("muted", `[${item.provider}]`); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; - line = `${modelText} ${providerBadge}${checkmark}`; + line = `${modelText} ${providerBadge}${defaultBadge}${checkmark}`; } this.listContainer.addChild(new Text(line, 0, 0)); diff --git a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts index 07273235c12..c67ccc990c6 100644 --- a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts @@ -2,6 +2,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { Container, type Focusable, + fuzzyFilter, + getKeybindings, + Input, matchesKey, type SelectItem, SelectList, @@ -32,7 +35,12 @@ const LEVEL_DESCRIPTIONS: Record = { * Component that renders a thinking level selector with borders */ export class ThinkingSelectorComponent extends Container implements Focusable { + private searchInput: Input; private selectList: SelectList; + private selectListChildIndex: number; + private allItems: SelectItem[]; + private onSelect: (level: ThinkingLevel) => void; + private onCancel: () => void; private onSelectAsDefault?: (level: ThinkingLevel) => void; private _focused = false; @@ -42,6 +50,7 @@ export class ThinkingSelectorComponent extends Container implements Focusable { set focused(value: boolean) { this._focused = value; + this.searchInput.focused = value; } constructor( @@ -50,14 +59,18 @@ export class ThinkingSelectorComponent extends Container implements Focusable { onSelect: (level: ThinkingLevel) => void, onCancel: () => void, onSelectAsDefault?: (level: ThinkingLevel) => void, + defaultThinkingLevel?: ThinkingLevel, ) { super(); + this.onSelect = onSelect; + this.onCancel = onCancel; this.onSelectAsDefault = onSelectAsDefault; - const thinkingLevels: SelectItem[] = availableLevels.map((level) => ({ + this.allItems = availableLevels.map((level) => ({ value: level, label: level, - description: LEVEL_DESCRIPTIONS[level], + description: + level === defaultThinkingLevel ? `${LEVEL_DESCRIPTIONS[level]} · default` : LEVEL_DESCRIPTIONS[level], })); // Add top border @@ -68,28 +81,14 @@ export class ThinkingSelectorComponent extends Container implements Focusable { this.addChild(new Text(`${keyDisplayText("app.thinking.cycle")} cycles thinking levels in-session`, 0, 0)); this.addChild(new Spacer(1)); - // Create selector - this.selectList = new SelectList( - thinkingLevels, - thinkingLevels.length, - getSelectListTheme(), - THINKING_SELECT_LIST_LAYOUT, - ); - - // Preselect current level - const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); - } - - this.selectList.onSelect = (item) => { - onSelect(item.value as ThinkingLevel); - }; - - this.selectList.onCancel = () => { - onCancel(); - }; + this.searchInput = new Input(); + this.searchInput.onSubmit = () => this.selectList.handleInput("\r"); + this.addChild(this.searchInput); + this.addChild(new Spacer(1)); + // Create selector + this.selectList = this.buildSelectList(this.allItems, currentLevel); + this.selectListChildIndex = this.children.length; this.addChild(this.selectList); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", " Enter to select · Ctrl+S to set as default · Esc to cancel"), 0, 0)); @@ -98,6 +97,27 @@ export class ThinkingSelectorComponent extends Container implements Focusable { this.addChild(new DynamicBorder()); } + private buildSelectList(items: SelectItem[], preselect?: ThinkingLevel): SelectList { + const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme(), THINKING_SELECT_LIST_LAYOUT); + const currentIndex = items.findIndex((item) => item.value === preselect); + if (currentIndex !== -1) { + list.setSelectedIndex(currentIndex); + } + list.onSelect = (item) => this.onSelect(item.value as ThinkingLevel); + list.onCancel = () => this.onCancel(); + return list; + } + + private applyFilter(query: string): void { + const filtered = query + ? fuzzyFilter(this.allItems, query, (item) => `${item.label} ${item.description ?? ""}`) + : this.allItems; + const selectedValue = this.selectList.getSelectedItem()?.value as ThinkingLevel | undefined; + const newList = this.buildSelectList(filtered, selectedValue); + this.children[this.selectListChildIndex] = newList; + this.selectList = newList; + } + handleInput(keyData: string): void { if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefault) { const item = this.selectList.getSelectedItem(); @@ -105,7 +125,19 @@ export class ThinkingSelectorComponent extends Container implements Focusable { return; } - this.selectList.handleInput(keyData); + const kb = getKeybindings(); + const isNav = + kb.matches(keyData, "tui.select.up") || + kb.matches(keyData, "tui.select.down") || + kb.matches(keyData, "tui.select.confirm") || + kb.matches(keyData, "tui.select.cancel"); + if (isNav) { + this.selectList.handleInput(keyData); + return; + } + + this.searchInput.handleInput(keyData); + this.applyFilter(this.searchInput.getValue()); } getSelectList(): SelectList { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e95000042ea..0e8587b455f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4791,6 +4791,7 @@ export class InteractiveMode { this.ui.requestRender(); }, (level) => selectLevel(level, true), + this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL, ); return { component: selector, focus: selector }; }); @@ -4961,6 +4962,8 @@ export class InteractiveMode { this.showError(error instanceof Error ? error.message : String(error)); } }; + const defaultProvider = this.settingsManager.getDefaultProvider(); + const defaultModel = this.settingsManager.getDefaultModel(); const selector = new ModelSelectorComponent( this.ui, this.session.model, @@ -4973,6 +4976,7 @@ export class InteractiveMode { }, initialSearchInput, (model) => selectModel(model, true), + defaultProvider && defaultModel ? { provider: defaultProvider, id: defaultModel } : undefined, ); return { component: selector, focus: selector, dispose: () => selector.dispose() }; }); From 768184923ab8f90f049244c2741b051c877a5c2e Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:28:33 +0200 Subject: [PATCH 241/284] fix(settings-selector): nit spacing --- .../src/modes/interactive/components/model-selector.ts | 9 +++++++-- packages/tui/src/components/settings-list.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index f3aa1b01f29..9c268031b55 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -249,6 +249,11 @@ export class ModelSelectorComponent extends Container implements Focusable { return this.defaultModel?.provider === model.provider && this.defaultModel.id === model.id; } + private isDefaultSearch(query: string): boolean { + const normalized = query.trim().toLowerCase(); + return normalized.length > 0 && "default".startsWith(normalized); + } + private setScope(scope: ModelScope): void { if (this.scope === scope) return; this.scope = scope; @@ -264,10 +269,10 @@ export class ModelSelectorComponent extends Container implements Focusable { private filterModels(query: string): void { if (query) { const filtered = fuzzyFilter(this.activeModels, query, (item) => { - const defaultText = this.isDefaultModel(item.model) ? " default startup" : ""; + const defaultText = this.isDefaultModel(item.model) ? " default" : ""; return `${getModelSelectorSearchText({ id: item.id, provider: item.provider, name: item.model.name })}${defaultText}`; }); - if (/\b(default|startup)\b/iu.test(query)) { + if (this.isDefaultSearch(query)) { const defaultItems = this.activeModels.filter((item) => this.isDefaultModel(item.model)); const defaultKeys = new Set(defaultItems.map((item) => `${item.provider}\0${item.id}`)); this.filteredModels = [ diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 72f9aece213..d18732fdbe5 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -132,7 +132,7 @@ export class SettingsList implements Component { const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length); // Calculate max label width for alignment - const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); + const maxLabelWidth = Math.min(36, Math.max(...this.items.map((item) => visibleWidth(item.label)))); // Render visible items for (let i = startIndex; i < endIndex; i++) { From cffe4d776c8fad2b36b4fe6062ebb72c428e0f0f Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:42:47 +0200 Subject: [PATCH 242/284] fix(settings-selector): nit ordering in default t.l. per model --- .../src/modes/interactive/components/model-selector.ts | 6 +++++- .../modes/interactive/components/settings-selector.ts | 10 ++++++---- .../src/modes/interactive/interactive-mode.ts | 1 + 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 9c268031b55..d1f7788b179 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -224,12 +224,16 @@ export class ModelSelectorComponent extends Container implements Focusable { private sortModels(models: ModelItem[]): ModelItem[] { const sorted = [...models]; - // Sort: current model first, then by provider + // Sort: current model first, default model second, then by provider. sorted.sort((a, b) => { const aIsCurrent = modelsAreEqual(this.currentModel, a.model); const bIsCurrent = modelsAreEqual(this.currentModel, b.model); if (aIsCurrent && !bIsCurrent) return -1; if (!aIsCurrent && bIsCurrent) return 1; + const aIsDefault = this.isDefaultModel(a.model); + const bIsDefault = this.isDefaultModel(b.model); + if (aIsDefault && !bIsDefault) return -1; + if (!aIsDefault && bIsDefault) return 1; return a.provider.localeCompare(b.provider); }); return sorted; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 9e6673fc43d..6dac4def309 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -49,6 +49,7 @@ const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( export interface SettingsConfig { autoCompact: boolean; defaultModel: string; + currentModel?: Model; availableDefaultModels: readonly Model[]; showImages: boolean; imageWidthCells: number; @@ -449,6 +450,7 @@ export class SettingsSelectorComponent extends Container { config.availableDefaultModels.map((model) => [modelSettingKey(model), model]), ); const currentDefaultModelKey = defaultModelByValue.has(config.defaultModel) ? config.defaultModel : undefined; + const currentModelKey = config.currentModel ? modelSettingKey(config.currentModel) : undefined; const items: SettingItem[] = [ { @@ -581,11 +583,11 @@ export class SettingsSelectorComponent extends Container { const sorted = [...config.availableDefaultModels].sort((a, b) => { const aKey = modelSettingKey(a); const bKey = modelSettingKey(b); + if (aKey === currentModelKey) return -1; + if (bKey === currentModelKey) return 1; if (aKey === currentDefaultModelKey) return -1; if (bKey === currentDefaultModelKey) return 1; - const pc = a.provider.localeCompare(b.provider); - if (pc !== 0) return pc; - return (a.name || a.id).localeCompare(b.name || b.id); + return a.provider.localeCompare(b.provider); }); const items: SelectItem[] = sorted.map((model) => { const key = modelSettingKey(model); @@ -605,7 +607,7 @@ export class SettingsSelectorComponent extends Container { } return items; }, - preselect: () => currentDefaultModelKey, + preselect: () => currentModelKey ?? currentDefaultModelKey, searchable: true, layout: MODEL_PICKER_LAYOUT, }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0e8587b455f..f68606ab86b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4523,6 +4523,7 @@ export class InteractiveMode { { autoCompact: this.session.autoCompactionEnabled, defaultModel, + currentModel: this.session.model, availableDefaultModels: this.session.modelRuntime.getAvailableSnapshot(), showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), From 8f2ae3faddb9f4eea565bbb67190ea126fac0691 Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Thu, 20 Aug 2026 15:58:47 +0200 Subject: [PATCH 243/284] fix(tui): prevent wrapped table link color leaks (#8363) --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/markdown.ts | 13 ++- packages/tui/test/markdown.test.ts | 123 +++++++++++++++++++++--- 3 files changed, 118 insertions(+), 19 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 33dfa5812db..3bd2a2fda65 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). - Fixed padded text exceeding narrow terminal widths ([#8252](https://github.com/earendil-works/pi/issues/8252)). +- Fixed wrapped Markdown table links leaking color into borders and neighboring cells, including tables inside blockquotes ([#8335](https://github.com/earendil-works/pi/issues/8335)). ## [0.84.2] - 2026-08-14 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 608936c7bc3..f66666ea981 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -826,8 +826,13 @@ export class Markdown implements Component { * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled * consistently with the rest of the renderer. */ - private wrapCellText(text: string, maxWidth: number): string[] { - return wrapTextWithAnsi(text, Math.max(1, maxWidth)); + private wrapCellText(text: string, maxWidth: number, stylePrefix = ""): string[] { + const lines = wrapTextWithAnsi(text, Math.max(1, maxWidth)); + return lines.map((line, index) => { + // Reset text styles after each non-final fragment, then restore the surrounding style before padding and borders. + const styleReset = index < lines.length - 1 ? "\x1b[22;23;24;25;27;28;29;39m" : ""; + return `${line}${styleReset}${stylePrefix}`; + }); } /** @@ -958,7 +963,7 @@ export class Markdown implements Component { // Render header with wrapping const headerCellLines: string[][] = token.header.map((cell, i) => { const text = this.renderInlineTokens(cell.tokens || [], styleContext); - return this.wrapCellText(text, columnWidths[i]); + return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix); }); const headerLineCount = Math.max(...headerCellLines.map((c) => c.length)); @@ -981,7 +986,7 @@ export class Markdown implements Component { const row = token.rows[rowIndex]; const rowCellLines: string[][] = row.map((cell, i) => { const text = this.renderInlineTokens(cell.tokens || [], styleContext); - return this.wrapCellText(text, columnWidths[i]); + return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix); }); const rowLineCount = Math.max(...rowCellLines.map((c) => c.length)); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index d45b7716d29..6347aa5d762 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert"; import { afterEach, describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; -import { Markdown } from "../src/components/markdown.ts"; +import { Markdown, type MarkdownTheme } from "../src/components/markdown.ts"; import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.ts"; import type { Component, TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; @@ -12,24 +12,14 @@ import { VirtualTerminal } from "./virtual-terminal.ts"; // Force full color in CI so ANSI assertions are deterministic const chalk = new Chalk({ level: 3 }); -function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { +function getCell(terminal: VirtualTerminal, row: number, col: number) { const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; const buffer = xterm.buffer.active; const line = buffer.getLine(buffer.viewportY + row); assert.ok(line, `Missing buffer line at row ${row}`); const cell = line.getCell(col); assert.ok(cell, `Missing cell at row ${row} col ${col}`); - return cell.isItalic(); -} - -function getCellUnderline(terminal: VirtualTerminal, row: number, col: number): number { - const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; - const buffer = xterm.buffer.active; - const line = buffer.getLine(buffer.viewportY + row); - assert.ok(line, `Missing buffer line at row ${row}`); - const cell = line.getCell(col); - assert.ok(cell, `Missing cell at row ${row} col ${col}`); - return cell.isUnderline(); + return cell; } function stripAnsi(line: string): string { @@ -479,6 +469,105 @@ describe("Markdown component", () => { assert.ok(allText.includes("Install"), "Should contain 'Install'"); }); + it("should not leak wrapped link styles into table borders or plain cells", async () => { + const source = `| Link | Plain | +| --- | --- | +| [**one two three four five six**](https://example.com) | normal text |`; + + try { + for (const hyperlinks of [true, false]) { + setCapabilities({ images: null, trueColor: false, hyperlinks }); + const terminal = new VirtualTerminal(24, 16); + const tui: TUI = new TuiMainScreen(terminal); + tui.addChild(new Markdown(source, 0, 0, defaultMarkdownTheme)); + tui.start(); + + try { + await terminal.waitForRender(); + const viewport = terminal.getViewport(); + const row = viewport.findIndex((line) => line.includes("one") && line.includes("norm")); + assert.notStrictEqual(row, -1, `Missing wrapped table row: ${JSON.stringify(viewport)}`); + const line = viewport[row]; + const linkCol = line.indexOf("one"); + const separatorCol = line.indexOf("│", linkCol); + const plainCol = line.indexOf("norm"); + assert.ok(linkCol >= 0 && separatorCol > linkCol && plainCol > separatorCol); + assert.strictEqual(getCell(terminal, row, linkCol).isFgDefault(), false); + assert.strictEqual(getCell(terminal, row, separatorCol).isFgDefault(), true); + assert.strictEqual(getCell(terminal, row, plainCol).isFgDefault(), true); + assert.notStrictEqual(getCell(terminal, row, linkCol).isBold(), 0); + assert.strictEqual(getCell(terminal, row, separatorCol).isBold(), 0); + assert.strictEqual(getCell(terminal, row, plainCol).isBold(), 0); + + if (!hyperlinks) { + const urlRow = viewport.findIndex((viewportLine) => viewportLine.includes("https")); + assert.notStrictEqual(urlRow, -1, `Missing fallback URL row: ${JSON.stringify(viewport)}`); + const urlLine = viewport[urlRow]; + const urlCol = urlLine.indexOf("https"); + const urlSeparatorCol = urlLine.indexOf("│", urlCol); + const urlBorderCol = urlLine.lastIndexOf("│"); + assert.ok(urlCol >= 0 && urlSeparatorCol > urlCol && urlBorderCol > urlSeparatorCol); + assert.notStrictEqual(getCell(terminal, urlRow, urlCol).isDim(), 0); + assert.strictEqual(getCell(terminal, urlRow, urlSeparatorCol).isDim(), 0); + assert.strictEqual(getCell(terminal, urlRow, urlBorderCol).isDim(), 0); + } + } finally { + tui.stop(); + } + } + } finally { + resetCapabilitiesCache(); + } + }); + + it("should restore the enclosing style after a wrapped table link", async () => { + const quoteColor = 0x123456; + const theme: MarkdownTheme = { + ...defaultMarkdownTheme, + // Use a basic wrapper that does not automatically reopen itself after nested resets. + quote: (text) => `\x1b[38;2;18;52;86m${text}\x1b[39m`, + link: (text) => `\x1b[38;2;129;162;190m${text}\x1b[39m`, + }; + const source = `> | Link | Plain | +> | --- | --- | +> | [one two three four five six](https://example.com) | normal text |`; + + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const terminal = new VirtualTerminal(28, 10); + const tui: TUI = new TuiMainScreen(terminal); + tui.addChild(new Markdown(source, 0, 0, theme)); + tui.start(); + + try { + await terminal.waitForRender(); + const viewport = terminal.getViewport(); + const row = viewport.findIndex((line) => line.includes("one") && line.includes("normal")); + assert.notStrictEqual(row, -1, `Missing wrapped blockquote table row: ${JSON.stringify(viewport)}`); + const line = viewport[row]; + const linkCol = line.indexOf("one"); + const separatorCol = line.indexOf("│", linkCol); + const plainCol = line.indexOf("normal"); + assert.ok(linkCol >= 0 && separatorCol > linkCol && plainCol > separatorCol); + + assert.notStrictEqual(getCell(terminal, row, linkCol).getFgColor(), quoteColor); + assert.strictEqual(getCell(terminal, row, separatorCol).getFgColor(), quoteColor); + assert.strictEqual(getCell(terminal, row, plainCol).getFgColor(), quoteColor); + + const finalRow = viewport.findIndex((line) => line.includes("five six")); + assert.notStrictEqual(finalRow, -1, `Missing final wrapped link row: ${JSON.stringify(viewport)}`); + const finalLine = viewport[finalRow]; + const finalLinkCol = finalLine.indexOf("five six"); + const finalSeparatorCol = finalLine.indexOf("│", finalLinkCol); + const finalBorderCol = finalLine.lastIndexOf("│"); + assert.ok(finalLinkCol >= 0 && finalSeparatorCol > finalLinkCol && finalBorderCol > finalSeparatorCol); + assert.strictEqual(getCell(terminal, finalRow, finalSeparatorCol).getFgColor(), quoteColor); + assert.strictEqual(getCell(terminal, finalRow, finalBorderCol).getFgColor(), quoteColor); + } finally { + tui.stop(); + resetCapabilitiesCache(); + } + }); + it("should wrap long cell content to multiple lines", () => { const markdown = new Markdown( `| Header | @@ -988,7 +1077,7 @@ A= assert.ok(component.markdownLineCount > 0); const inputRow = component.markdownLineCount; - assert.strictEqual(getCellItalic(terminal, inputRow, 0), 0); + assert.strictEqual(getCell(terminal, inputRow, 0).isItalic(), 0); tui.stop(); }); }); @@ -1432,7 +1521,11 @@ bar`, assert.ok(contentWidth > 0, "Should have visible heading content"); for (let col = contentWidth; col < 80; col++) { - assert.strictEqual(getCellUnderline(terminal, 0, col), 0, `Expected no underline in padding at col ${col}`); + assert.strictEqual( + getCell(terminal, 0, col).isUnderline(), + 0, + `Expected no underline in padding at col ${col}`, + ); } tui.stop(); From 5cd93f688aaab89dbb6dfa4aca535f21796ae185 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 20 Aug 2026 15:59:12 +0200 Subject: [PATCH 244/284] feat(coding-agent): add development pi wrapper --- scripts/auto-pi.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 scripts/auto-pi.sh diff --git a/scripts/auto-pi.sh b/scripts/auto-pi.sh new file mode 100755 index 00000000000..2cab40be18b --- /dev/null +++ b/scripts/auto-pi.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Developer wrapper that runs pi from this checkout's latest `npm run build`. +# Development invocations use PI_EXPERIMENTAL=1 by default. Pass --stable to use +# the next pi executable on PATH; `pi update` also uses stable so self-update +# works. +# +# From the repository root, install with: +# mkdir -p "$HOME/.local/bin" +# ln -s "$PWD/scripts/auto-pi.sh" "$HOME/.local/bin/pi" +# +# ~/.local/bin must appear before the stable pi installation on PATH. + +# Resolve this script through symlinks so repo_dir points at the development +# checkout rather than the directory containing the `pi` symlink. +script_path="${BASH_SOURCE[0]}" +while [[ -L "$script_path" ]]; do + script_dir="$(cd -P "$(dirname "$script_path")" && pwd)" + link_target="$(readlink "$script_path")" + if [[ "$link_target" == /* ]]; then + script_path="$link_target" + else + script_path="$script_dir/$link_target" + fi +done +script_dir="$(cd -P "$(dirname "$script_path")" && pwd)" +repo_dir="$(cd "$script_dir/.." && pwd)" + +find_stable_pi() { + local path_entry candidate candidate_dir + local -a path_entries + IFS=: read -r -a path_entries <<< "${PATH:-}" + for path_entry in "${path_entries[@]}"; do + [[ -n "$path_entry" ]] || path_entry=. + candidate="$path_entry/pi" + [[ -x "$candidate" && ! -d "$candidate" ]] || continue + [[ "$candidate" -ef "$script_path" ]] && continue + candidate_dir="$(cd -P "$(dirname "$candidate")" && pwd)" || continue + printf '%s/%s\n' "$candidate_dir" "$(basename "$candidate")" + return 0 + done + return 1 +} + +use_stable=false +args=() +for arg in "$@"; do + if [[ "$arg" == "--stable" ]]; then + use_stable=true + else + args+=("$arg") + fi +done + +if [[ "${args[0]:-}" == "update" ]]; then + use_stable=true +fi + +if [[ "$use_stable" == true ]]; then + if ! stable_pi="$(find_stable_pi)"; then + echo "error: could not find a stable pi executable after the auto-pi wrapper on PATH" >&2 + exit 1 + fi + exec "$stable_pi" ${args[@]+"${args[@]}"} +fi + +dev_pi="$repo_dir/packages/coding-agent/dist/cli.js" +if [[ ! -x "$dev_pi" ]]; then + echo "error: development pi build not found; run \`npm run build\` in $repo_dir" >&2 + exit 1 +fi + +export PI_EXPERIMENTAL="${PI_EXPERIMENTAL:-1}" +exec "$dev_pi" ${args[@]+"${args[@]}"} From 686f3487f5ef3ce290432e22f8b3296e1694d9dd Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:05:01 +0200 Subject: [PATCH 245/284] feat(interactive-mode): share via radius artifacts under experimental (#8443) --- packages/coding-agent/src/config.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 176 +++++++++++++++--- 2 files changed, 153 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 3a252e5c8c3..55a7bd132cc 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -502,7 +502,7 @@ export function expandTildePath(path: string): string { const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/"; -/** Get the share viewer URL for a gist ID */ +/** Get the share viewer URL for a gist ID or Radius artifact reference. */ export function getShareViewerUrl(gistId: string): string { const baseUrl = process.env.PI_SHARE_VIEWER_URL || DEFAULT_SHARE_VIEWER_URL; return `${baseUrl}#${gistId}`; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f68606ab86b..2abd9dd4ae4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -10,6 +10,7 @@ import * as path from "node:path"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat"; +import { DEFAULT_RADIUS_GATEWAY } from "@earendil-works/pi-ai/providers/radius-config"; import type { AutocompleteItem, AutocompleteProvider, @@ -45,6 +46,7 @@ import { } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { spawn, spawnSync } from "child_process"; +import { getAuthCredential } from "../../cli/auth-command.ts"; import { APP_NAME, APP_TITLE, @@ -6081,6 +6083,126 @@ export class InteractiveMode { } private async handleShareCommand(): Promise { + // Radius artifacts natively support JSONL sessions. Gist fallback keeps the legacy HTML upload. + const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); + const htmlFile = path.join(os.tmpdir(), "session.html"); + const useRadiusShare = process.env.PI_EXPERIMENTAL === "1"; + + try { + if (useRadiusShare) { + try { + this.session.exportToJsonl(jsonlFile); + } catch (error: unknown) { + this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + const radiusResult = await this.tryShareViaRadius(jsonlFile); + if (radiusResult !== "radius-unavailable") return; + } + + try { + await this.session.exportToHtml(htmlFile, { themeName: theme.name }); + } catch (error: unknown) { + this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + await this.shareViaGist(htmlFile); + } finally { + for (const tmpFile of [jsonlFile, htmlFile]) { + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + } + } + } + + private async tryShareViaRadius(tmpFile: string): Promise<"shared" | "radius-unavailable"> { + const provider = this.session.modelRuntime.getProvider("radius"); + if (!provider) return "radius-unavailable"; + + const gatewayUrl = DEFAULT_RADIUS_GATEWAY; + + let token = getAuthCredential( + await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 30 * 60_000 }), + ); + if (!token) { + this.showStatus("Signing in to Radius..."); + await this.showLoginDialog("radius", provider.name || "Radius"); + token = getAuthCredential( + await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 30 * 60_000 }), + ); + } + if (!token) { + this.showError("Radius is not logged in. Run `/login radius` and try /share again."); + return "shared"; + } + + const loader = new BorderedLoader(this.ui, theme, "Uploading to Radius..."); + this.editorContainer.clear(); + this.editorContainer.addChild(loader); + this.ui.setFocus(loader); + this.ui.requestRender(); + loader.onAbort = () => { + this.restoreShareEditor(loader); + this.showStatus("Share cancelled"); + }; + + try { + const body = fs.readFileSync(tmpFile); + const url = new URL("/v1/artifacts", gatewayUrl); + url.searchParams.set("visibility", "organization"); + url.searchParams.set("title", "Pi session"); + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-ndjson", + "Content-Length": String(body.byteLength), + }, + body, + signal: loader.signal, + }); + if (loader.signal.aborted) return "shared"; + const json = (await response.json().catch(() => null)) as { + artifact?: { canonical_url?: string; url?: string; visibility?: string }; + error?: string; + } | null; + if (loader.signal.aborted) return "shared"; + this.restoreShareEditor(loader); + if (!response.ok || !json?.artifact) { + this.showError( + `Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`, + ); + return "shared"; + } + const shareUrl = json.artifact.canonical_url || json.artifact.url; + if (!shareUrl) { + this.showError("Failed to upload Radius artifact: response did not include a share URL"); + return "shared"; + } + const themedShareUrl = this.withUrlParam(shareUrl, "theme", "dark"); + const artifactId = this.radiusArtifactIdFromUrl(shareUrl); + const previewUrl = + json.artifact.visibility === "public" && artifactId + ? getShareViewerUrl(`radius/${artifactId}&theme=dark`) + : themedShareUrl; + const visibilityNote = json.artifact.visibility === "public" ? "" : " (private; sign-in required)"; + this.showStatus(`Share URL: ${previewUrl}${visibilityNote}\nRadius artifact: ${themedShareUrl}`); + return "shared"; + } catch (error: unknown) { + if (!loader.signal.aborted) { + this.restoreShareEditor(loader); + this.showError( + `Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + return "shared"; + } + } + + private async shareViaGist(tmpFile: string): Promise { // Check if gh is available and logged in try { const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); @@ -6093,15 +6215,6 @@ export class InteractiveMode { return; } - // Export to a temp file - const tmpFile = path.join(os.tmpdir(), "session.html"); - try { - await this.session.exportToHtml(tmpFile, { themeName: theme.name }); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - // Show cancellable loader, replacing the editor const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); this.editorContainer.clear(); @@ -6109,24 +6222,12 @@ export class InteractiveMode { this.ui.setFocus(loader); this.ui.requestRender(); - const restoreEditor = () => { - loader.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - try { - fs.unlinkSync(tmpFile); - } catch { - // Ignore cleanup errors - } - }; - // Create a secret gist asynchronously let proc: ReturnType | null = null; loader.onAbort = () => { proc?.kill(); - restoreEditor(); + this.restoreShareEditor(loader); this.showStatus("Share cancelled"); }; @@ -6146,7 +6247,7 @@ export class InteractiveMode { if (loader.signal.aborted) return; - restoreEditor(); + this.restoreShareEditor(loader); if (result.code !== 0) { const errorMsg = result.stderr?.trim() || "Unknown error"; @@ -6168,12 +6269,39 @@ export class InteractiveMode { this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); } catch (error: unknown) { if (!loader.signal.aborted) { - restoreEditor(); + this.restoreShareEditor(loader); this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); } } } + private restoreShareEditor(loader: BorderedLoader): void { + loader.dispose(); + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + } + + private withUrlParam(value: string, name: string, paramValue: string): string { + try { + const url = new URL(value); + url.searchParams.set(name, paramValue); + return url.toString(); + } catch { + return value; + } + } + + private radiusArtifactIdFromUrl(value: string): string | null { + try { + const url = new URL(value); + const match = /^\/artifact\/([^/?#]+)\/?$/iu.exec(url.pathname); + return match ? match[1] : null; + } catch { + return null; + } + } + private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise { const text = this.session.getLastAssistantText(); if (!text) { From a2f369d63a3e412a68a2570583dd60cadb7d63a5 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:14:10 +0200 Subject: [PATCH 246/284] fix(slash-commands): order tree above thinking --- packages/coding-agent/src/core/slash-commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 57c3e23d6e7..13326d2dabd 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -19,6 +19,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, + { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "thinking", description: "Set thinking level", argumentHint: "" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, @@ -31,7 +32,6 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "fork", description: "Create a new fork from a previous user message" }, { name: "clone", description: "Duplicate the current session at the current position" }, - { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "trust", description: "Save project trust decision for future sessions" }, { name: "login", description: "Configure provider authentication", argumentHint: "" }, { name: "logout", description: "Remove provider authentication" }, From 77f2d1235ee2992c6072b9dcb6e99439a70c6f45 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:47:08 +0200 Subject: [PATCH 247/284] chore(interactive-mode): get rid of theme, only share via radius if logged in --- .../src/modes/interactive/interactive-mode.ts | 50 +++++-------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 2abd9dd4ae4..cda9e317e74 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6086,19 +6086,16 @@ export class InteractiveMode { // Radius artifacts natively support JSONL sessions. Gist fallback keeps the legacy HTML upload. const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); const htmlFile = path.join(os.tmpdir(), "session.html"); - const useRadiusShare = process.env.PI_EXPERIMENTAL === "1"; try { - if (useRadiusShare) { - try { - this.session.exportToJsonl(jsonlFile); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - const radiusResult = await this.tryShareViaRadius(jsonlFile); - if (radiusResult !== "radius-unavailable") return; + try { + this.session.exportToJsonl(jsonlFile); + } catch (error: unknown) { + this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; } + const radiusResult = await this.tryShareViaRadius(jsonlFile); + if (radiusResult !== "radius-unavailable") return; try { await this.session.exportToHtml(htmlFile, { themeName: theme.name }); @@ -6124,20 +6121,10 @@ export class InteractiveMode { const gatewayUrl = DEFAULT_RADIUS_GATEWAY; - let token = getAuthCredential( - await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 30 * 60_000 }), + const token = getAuthCredential( + await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }), ); - if (!token) { - this.showStatus("Signing in to Radius..."); - await this.showLoginDialog("radius", provider.name || "Radius"); - token = getAuthCredential( - await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 30 * 60_000 }), - ); - } - if (!token) { - this.showError("Radius is not logged in. Run `/login radius` and try /share again."); - return "shared"; - } + if (!token) return "radius-unavailable"; const loader = new BorderedLoader(this.ui, theme, "Uploading to Radius..."); this.editorContainer.clear(); @@ -6182,14 +6169,11 @@ export class InteractiveMode { this.showError("Failed to upload Radius artifact: response did not include a share URL"); return "shared"; } - const themedShareUrl = this.withUrlParam(shareUrl, "theme", "dark"); const artifactId = this.radiusArtifactIdFromUrl(shareUrl); const previewUrl = - json.artifact.visibility === "public" && artifactId - ? getShareViewerUrl(`radius/${artifactId}&theme=dark`) - : themedShareUrl; + json.artifact.visibility === "public" && artifactId ? getShareViewerUrl(`radius/${artifactId}`) : shareUrl; const visibilityNote = json.artifact.visibility === "public" ? "" : " (private; sign-in required)"; - this.showStatus(`Share URL: ${previewUrl}${visibilityNote}\nRadius artifact: ${themedShareUrl}`); + this.showStatus(`Share URL: ${previewUrl}${visibilityNote}\nRadius artifact: ${shareUrl}`); return "shared"; } catch (error: unknown) { if (!loader.signal.aborted) { @@ -6282,16 +6266,6 @@ export class InteractiveMode { this.ui.setFocus(this.editor); } - private withUrlParam(value: string, name: string, paramValue: string): string { - try { - const url = new URL(value); - url.searchParams.set(name, paramValue); - return url.toString(); - } catch { - return value; - } - } - private radiusArtifactIdFromUrl(value: string): string | null { try { const url = new URL(value); From f4585b8bec581d005cbb1edfc07edfcce723d0ae Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 21 Aug 2026 18:33:27 +0200 Subject: [PATCH 248/284] fix(coding-agent): simplify session sharing links --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/config.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 77 ++++++++----------- 3 files changed, 35 insertions(+), 48 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3744e2db809..e8d163ec203 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled. +### Changed + +- Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL. + ### Fixed - Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 55a7bd132cc..dddda3d4e53 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -502,7 +502,7 @@ export function expandTildePath(path: string): string { const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/"; -/** Get the share viewer URL for a gist ID or Radius artifact reference. */ +/** Get the share viewer URL for a gist ID. */ export function getShareViewerUrl(gistId: string): string { const baseUrl = process.env.PI_SHARE_VIEWER_URL || DEFAULT_SHARE_VIEWER_URL; return `${baseUrl}#${gistId}`; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index cda9e317e74..3035ad4c5b5 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6085,7 +6085,7 @@ export class InteractiveMode { private async handleShareCommand(): Promise { // Radius artifacts natively support JSONL sessions. Gist fallback keeps the legacy HTML upload. const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); - const htmlFile = path.join(os.tmpdir(), "session.html"); + let htmlFile = null; try { try { @@ -6094,10 +6094,21 @@ export class InteractiveMode { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; } - const radiusResult = await this.tryShareViaRadius(jsonlFile); - if (radiusResult !== "radius-unavailable") return; + if (await this.tryShareViaRadius(jsonlFile)) return; try { + const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + if (authResult.status !== 0) { + this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); + return; + } + } catch { + this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); + return; + } + + try { + htmlFile = path.join(os.tmpdir(), "session.html"); await this.session.exportToHtml(htmlFile, { themeName: theme.name }); } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); @@ -6107,7 +6118,9 @@ export class InteractiveMode { } finally { for (const tmpFile of [jsonlFile, htmlFile]) { try { - fs.unlinkSync(tmpFile); + if (tmpFile !== null) { + fs.unlinkSync(tmpFile); + } } catch { // Ignore cleanup errors } @@ -6115,16 +6128,16 @@ export class InteractiveMode { } } - private async tryShareViaRadius(tmpFile: string): Promise<"shared" | "radius-unavailable"> { + private async tryShareViaRadius(tmpFile: string): Promise { const provider = this.session.modelRuntime.getProvider("radius"); - if (!provider) return "radius-unavailable"; + if (!provider) return false; const gatewayUrl = DEFAULT_RADIUS_GATEWAY; const token = getAuthCredential( await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }), ); - if (!token) return "radius-unavailable"; + if (!token) return false; const loader = new BorderedLoader(this.ui, theme, "Uploading to Radius..."); this.editorContainer.clear(); @@ -6151,30 +6164,22 @@ export class InteractiveMode { body, signal: loader.signal, }); - if (loader.signal.aborted) return "shared"; + if (loader.signal.aborted) return true; const json = (await response.json().catch(() => null)) as { - artifact?: { canonical_url?: string; url?: string; visibility?: string }; + artifact?: { canonical_url: string }; error?: string; } | null; - if (loader.signal.aborted) return "shared"; + if (loader.signal.aborted) return true; this.restoreShareEditor(loader); if (!response.ok || !json?.artifact) { this.showError( `Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`, ); - return "shared"; - } - const shareUrl = json.artifact.canonical_url || json.artifact.url; - if (!shareUrl) { - this.showError("Failed to upload Radius artifact: response did not include a share URL"); - return "shared"; - } - const artifactId = this.radiusArtifactIdFromUrl(shareUrl); - const previewUrl = - json.artifact.visibility === "public" && artifactId ? getShareViewerUrl(`radius/${artifactId}`) : shareUrl; - const visibilityNote = json.artifact.visibility === "public" ? "" : " (private; sign-in required)"; - this.showStatus(`Share URL: ${previewUrl}${visibilityNote}\nRadius artifact: ${shareUrl}`); - return "shared"; + return true; + } + const shareUrl = json.artifact.canonical_url; + this.showStatus(`Share URL: ${hyperlink(shareUrl, shareUrl)}`); + return true; } catch (error: unknown) { if (!loader.signal.aborted) { this.restoreShareEditor(loader); @@ -6182,23 +6187,11 @@ export class InteractiveMode { `Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`, ); } - return "shared"; + return true; } } private async shareViaGist(tmpFile: string): Promise { - // Check if gh is available and logged in - try { - const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); - if (authResult.status !== 0) { - this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); - return; - } - } catch { - this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); - return; - } - // Show cancellable loader, replacing the editor const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); this.editorContainer.clear(); @@ -6250,7 +6243,7 @@ export class InteractiveMode { // Create the preview URL const previewUrl = getShareViewerUrl(gistId); - this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); + this.showStatus(`Share URL: ${hyperlink(previewUrl, previewUrl)}\nGist: ${hyperlink(gistUrl, gistUrl)}`); } catch (error: unknown) { if (!loader.signal.aborted) { this.restoreShareEditor(loader); @@ -6266,16 +6259,6 @@ export class InteractiveMode { this.ui.setFocus(this.editor); } - private radiusArtifactIdFromUrl(value: string): string | null { - try { - const url = new URL(value); - const match = /^\/artifact\/([^/?#]+)\/?$/iu.exec(url.pathname); - return match ? match[1] : null; - } catch { - return null; - } - } - private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise { const text = this.session.getLastAssistantText(); if (!text) { From c49906ec77788625aacbdc53ebca6fbe65bd20f5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 22 Aug 2026 01:19:25 +0200 Subject: [PATCH 249/284] fix(coding-agent): preserve managed state file permissions Closes #7779 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/auth-storage.ts | 6 ++---- .../coding-agent/test/auth-storage.test.ts | 18 +++++++++++++++++- .../coding-agent/test/models-store.test.ts | 13 ++++++++++++- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e8d163ec203..f7ceb283d41 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed writes to `auth.json` and `models-store.json` overriding administrator-managed file permissions and ACLs ([#7779](https://github.com/earendil-works/pi/issues/7779)). - Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). - Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 71be9d022ed..9d64959194d 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -4,7 +4,7 @@ */ import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { setTimeout as sleep } from "timers/promises"; @@ -21,6 +21,7 @@ type LockResult = { next?: string; }; +// The mode applies only on creation so administrator-managed modes and ACLs remain intact. const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const; type AuthFileReload = { @@ -62,7 +63,6 @@ export class FileAuthStorageBackend implements AuthStorageBackend { private ensureFileExists(): void { if (!existsSync(this.authPath)) { writeFileSync(this.authPath, "{}", AUTH_FILE_WRITE_OPTIONS); - chmodSync(this.authPath, 0o600); } } @@ -104,7 +104,6 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const { result, next } = fn(current); if (next !== undefined) { writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); - chmodSync(this.authPath, 0o600); } return result; } finally { @@ -186,7 +185,6 @@ export class FileAuthStorageBackend implements AuthStorageBackend { options?.signal?.throwIfAborted(); if (next !== undefined) { writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); - chmodSync(this.authPath, 0o600); } throwIfCompromised(); return result; diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 801697ee280..61cdbefba0f 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type CredentialStore, createModels, type Provider } from "@earendil-works/pi-ai"; @@ -139,6 +139,22 @@ describe("AuthStorage", () => { expect(release).toHaveBeenCalledTimes(1); }); + test.skipIf(process.platform === "win32")("creates new auth files with owner-only permissions", () => { + AuthStorage.create(authJsonPath); + + expect(statSync(authJsonPath).mode & 0o777).toBe(0o600); + }); + + test.skipIf(process.platform === "win32")("preserves the mode of an existing auth file", async () => { + writeAuthJson({ anthropic: { type: "api_key", key: "old" } }); + chmodSync(authJsonPath, 0o660); + const storage = AuthStorage.create(authJsonPath); + + await storage.modify("anthropic", async () => ({ type: "api_key", key: "new" })); + + expect(statSync(authJsonPath).mode & 0o777).toBe(0o660); + }); + test("modify persists a credential while preserving unrelated external edits", async () => { writeAuthJson({ anthropic: { type: "api_key", key: "old" } }); const storage = AuthStorage.create(authJsonPath); diff --git a/packages/coding-agent/test/models-store.test.ts b/packages/coding-agent/test/models-store.test.ts index 30e592f7c00..bf645042c8e 100644 --- a/packages/coding-agent/test/models-store.test.ts +++ b/packages/coding-agent/test/models-store.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Model } from "@earendil-works/pi-ai"; @@ -53,6 +53,17 @@ describe("FileModelsStore", () => { expect((await reloaded.read("two"))?.models.map((entry) => entry.id)).toEqual(["m2"]); }); + it.skipIf(process.platform === "win32")("preserves the mode of an existing models file", async () => { + const managedModelsPath = join(sharedTempDir, "managed-mode.json"); + writeFileSync(managedModelsPath, "{}"); + chmodSync(managedModelsPath, 0o660); + const store = new FileModelsStore(managedModelsPath); + + await store.write("one", { models: [model("one", "m1")], checkedAt: 100 }); + + expect(statSync(managedModelsPath).mode & 0o777).toBe(0o660); + }); + it("coalesces file reloads across concurrent readers and interleaved storage instances", async () => { writeFileSync( sharedModelsPath, From 7d4c0e05dd31545de2b1043142a89d90ce1ae736 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 22 Aug 2026 21:19:38 +0200 Subject: [PATCH 250/284] feat(coding-agent): bundle Node runtime (#8474) --- package-lock.json | 2 +- packages/coding-agent/CHANGELOG.md | 1 + .../install-lock/package-lock.json | 2 +- packages/coding-agent/npm-shrinkwrap.json | 2 +- packages/coding-agent/package.json | 9 +- packages/coding-agent/src/config.ts | 32 ++-- .../src/core/extensions/loader.ts | 9 +- packages/coding-agent/test/config.test.ts | 14 ++ .../test/package-distribution.test.ts | 26 +++ packages/tui/src/native-modifiers.ts | 11 +- packages/tui/src/native-module-path.ts | 31 ++++ packages/tui/src/terminal.ts | 14 +- packages/tui/test/native-module-path.test.ts | 45 +++++ scripts/build-coding-agent-bundle.mjs | 154 ++++++++++++++++++ scripts/profile-coding-agent-node.mjs | 137 ++++++++++------ 15 files changed, 397 insertions(+), 92 deletions(-) create mode 100644 packages/coding-agent/test/package-distribution.test.ts create mode 100644 packages/tui/src/native-module-path.ts create mode 100644 packages/tui/test/native-module-path.test.ts create mode 100644 scripts/build-coding-agent-bundle.mjs diff --git a/package-lock.json b/package-lock.json index 7042de0da61..f9b9ee73da3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5511,7 +5511,7 @@ "yaml": "2.9.0" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "devDependencies": { "@types/cross-spawn": "6.0.6", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7ceb283d41..d5300771401 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. - Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL. ### Fixed diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 1e2f28d28b4..b95f0bb820f 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -530,7 +530,7 @@ "@mariozechner/clipboard": "0.3.9" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index e58a04fa769..acd50e996d0 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -35,7 +35,7 @@ "@mariozechner/clipboard": "0.3.9" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 58d9dd64d57..0073ac0f8da 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -7,7 +7,7 @@ "configDir": ".pi" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -17,7 +17,7 @@ "import": "./dist/index.js" }, "./rpc-entry": { - "import": "./dist/rpc-entry.js" + "import": "./dist/bundle/rpc-entry.js" }, "./client": { "types": "./dist/client/index.d.ts", @@ -34,8 +34,9 @@ ], "scripts": { "clean": "shx rm -rf dist", - "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", + "build": "npm run build:unbundled && node ../../scripts/build-coding-agent-bundle.mjs", + "build:unbundled": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./src/bun/cli.ts ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index dddda3d4e53..21e7cd2a3ed 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -362,9 +362,26 @@ export function getUpdateInstruction(packageName: string): string { /** * Get the base directory for resolving package assets (themes, package.json, README.md, CHANGELOG.md). * - For Bun binary: returns the directory containing the executable - * - For Node.js (dist/): returns __dirname (the dist/ directory) - * - For tsx (src/): returns parent directory (the package root) + * - For Node.js and tsx: returns the package root containing package.json + * - Ignores Bun binary metadata copied into dist/ when the package root is available */ +export function findNodePackageDir(startDir: string): string { + let dir = startDir; + while (dir !== dirname(dir)) { + if (existsSync(join(dir, "package.json"))) { + const parent = dirname(dir); + // build:binary places Bun's metadata inside dist/. Node still needs the + // package root so its dist-relative asset paths do not become dist/dist/. + if (basename(dir) === "dist" && existsSync(join(parent, "package.json"))) { + return parent; + } + return dir; + } + dir = dirname(dir); + } + return startDir; +} + export function getPackageDir(): string { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; @@ -376,16 +393,7 @@ export function getPackageDir(): string { // Bun binary: process.execPath points to the compiled executable return dirname(process.execPath); } - // Node.js: walk up from __dirname until we find package.json - let dir = __dirname; - while (dir !== dirname(dir)) { - if (existsSync(join(dir, "package.json"))) { - return dir; - } - dir = dirname(dir); - } - // Fallback (shouldn't happen) - return __dirname; + return findNodePackageDir(__dirname); } /** diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 2f00807f29c..d560e67c42d 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -78,6 +78,8 @@ const require = createRequire(import.meta.url); const isNodeSeaBinary = ("sea" in process.features && process.features.sea === true) || process.getBuiltinModule("node:sea")?.isSea() === true; +declare const PI_BUNDLED_NODE: boolean; +const isBundledNode = typeof PI_BUNDLED_NODE !== "undefined" && PI_BUNDLED_NODE; const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts"; /** @@ -451,9 +453,10 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension const jiti = createJiti(import.meta.url, { moduleCache: false, - // Compiled binaries use modules embedded in the executable. Source TypeScript - // reuses host modules and root tsconfig paths. Built Node uses dist aliases. - ...(isBunBinary || isNodeSeaBinary + // Compiled binaries and the bundled Node distribution use embedded modules. + // Source TypeScript reuses host modules and root tsconfig paths. Unbundled + // Node builds use dist aliases. + ...(isBunBinary || isNodeSeaBinary || isBundledNode ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true } diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 49448cf119e..300f9e06ab1 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -4,6 +4,7 @@ import { delimiter, join } from "path"; import { afterEach, describe, expect, test } from "vitest"; import { detectInstallMethod, + findNodePackageDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, getUpdateInstruction, @@ -145,6 +146,19 @@ function createFakeBunScript(bunBin: string): string { return `#!/bin/sh\nif [ "$1" = "pm" ] && [ "$2" = "bin" ] && [ "$3" = "-g" ]; then\n\tprintf '%s\\n' '${escapedBunBin}'\n\texit 0\nfi\nexit 1\n`; } +describe("findNodePackageDir", () => { + test("skips binary metadata copied into dist", () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-package-dir-")); + const distDir = join(tempDir, "dist"); + const bundleDir = join(distDir, "bundle"); + mkdirSync(bundleDir, { recursive: true }); + writeFileSync(join(tempDir, "package.json"), "{}"); + writeFileSync(join(distDir, "package.json"), "{}"); + + expect(findNodePackageDir(bundleDir)).toBe(tempDir); + }); +}); + describe("detectInstallMethod", () => { test("detects pnpm from Windows .pnpm install paths", () => { setExecPath( diff --git a/packages/coding-agent/test/package-distribution.test.ts b/packages/coding-agent/test/package-distribution.test.ts new file mode 100644 index 00000000000..2fe5380ec8f --- /dev/null +++ b/packages/coding-agent/test/package-distribution.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +interface CodingAgentPackageJson { + bin: { pi: string }; + main: string; + exports: { + ".": { import: string; types: string }; + "./client": { import: string; types: string }; + "./rpc-entry": { import: string }; + }; +} + +const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as CodingAgentPackageJson; + +describe("package distribution entrypoints", () => { + test("uses the bundle for executables and modular output for libraries", () => { + expect(packageJson.bin.pi).toBe("dist/bundle/cli.js"); + expect(packageJson.main).toBe("./dist/index.js"); + expect(packageJson.exports["."].import).toBe("./dist/index.js"); + expect(packageJson.exports["./client"].import).toBe("./dist/client/index.js"); + expect(packageJson.exports["./rpc-entry"].import).toBe("./dist/bundle/rpc-entry.js"); + }); +}); diff --git a/packages/tui/src/native-modifiers.ts b/packages/tui/src/native-modifiers.ts index 549ce47a93f..88684cc10f2 100644 --- a/packages/tui/src/native-modifiers.ts +++ b/packages/tui/src/native-modifiers.ts @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; +import { getNativeModuleCandidates } from "./native-module-path.ts"; const cjsRequire = createRequire(import.meta.url); @@ -33,14 +33,7 @@ function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { return undefined; } - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ - path.join(moduleDir, "..", nativePath), - path.join(moduleDir, nativePath), - path.join(path.dirname(process.execPath), nativePath), - ]; - - for (const modulePath of candidates) { + for (const modulePath of getNativeModuleCandidates(nativePath)) { try { const helper = cjsRequire(modulePath) as unknown; if (isNativeModifiersHelper(helper)) { diff --git a/packages/tui/src/native-module-path.ts b/packages/tui/src/native-module-path.ts new file mode 100644 index 00000000000..b75117feb73 --- /dev/null +++ b/packages/tui/src/native-module-path.ts @@ -0,0 +1,31 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const moduleRequire = createRequire(import.meta.url); +const TUI_PACKAGE_NAME = "@earendil-works/pi-tui"; + +export interface NativeModuleCandidateOptions { + moduleUrl?: string; + execPath?: string; + resolvePackage?: (specifier: string) => string; +} + +export function getNativeModuleCandidates(nativePath: string, options: NativeModuleCandidateOptions = {}): string[] { + const moduleDir = dirname(fileURLToPath(options.moduleUrl ?? import.meta.url)); + const candidates: string[] = []; + + try { + const packageEntry = (options.resolvePackage ?? moduleRequire.resolve)(TUI_PACKAGE_NAME); + candidates.push(join(dirname(packageEntry), "..", nativePath)); + } catch { + // Standalone binaries do not have an installed TUI package. + } + + candidates.push( + join(moduleDir, "..", nativePath), + join(moduleDir, nativePath), + join(dirname(options.execPath ?? process.execPath), nativePath), + ); + return Array.from(new Set(candidates)); +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 08d8cbbcfe0..f858bf9f252 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,9 +1,9 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; import { setKittyProtocolActive } from "./keys.ts"; import { isNativeModifierPressed } from "./native-modifiers.ts"; +import { getNativeModuleCandidates } from "./native-module-path.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; const cjsRequire = createRequire(import.meta.url); @@ -370,16 +370,10 @@ export class ProcessTerminal implements Terminal { if (arch !== "x64" && arch !== "arm64") return; // Dynamic require so non-Windows and bundled/browser paths never load the - // native helper. In the npm package native/ is next to dist/; in compiled - // binary archives native/ is copied next to the executable. - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + // native helper. Installed packages resolve it from pi-tui; standalone + // binaries resolve the copy next to the executable. const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); - const candidates = [ - path.join(moduleDir, "..", nativePath), - path.join(moduleDir, nativePath), - path.join(path.dirname(process.execPath), nativePath), - ]; - for (const modulePath of candidates) { + for (const modulePath of getNativeModuleCandidates(nativePath)) { try { const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean }; helper.enableVirtualTerminalInput?.(); diff --git a/packages/tui/test/native-module-path.test.ts b/packages/tui/test/native-module-path.test.ts new file mode 100644 index 00000000000..02245ef12f9 --- /dev/null +++ b/packages/tui/test/native-module-path.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert"; +import { dirname, join, resolve } from "node:path"; +import { describe, it } from "node:test"; +import { pathToFileURL } from "node:url"; +import { getNativeModuleCandidates } from "../src/native-module-path.ts"; + +describe("getNativeModuleCandidates", () => { + it("resolves native helpers from the installed TUI package when the module is bundled elsewhere", () => { + const packageRoot = resolve("virtual", "node_modules", "@earendil-works", "pi-tui"); + const bundledModule = resolve("virtual", "pi-coding-agent", "dist", "bundle", "chunks", "chunk.js"); + const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-console-mode.node"); + + const candidates = getNativeModuleCandidates(nativePath, { + moduleUrl: pathToFileURL(bundledModule).href, + execPath: resolve("virtual", "node", "node.exe"), + resolvePackage: (specifier) => { + assert.equal(specifier, "@earendil-works/pi-tui"); + return join(packageRoot, "dist", "index.js"); + }, + }); + + assert.equal(candidates[0], join(packageRoot, nativePath)); + assert.ok(candidates.includes(join(dirname(bundledModule), "..", nativePath))); + }); + + it("keeps standalone binary fallbacks when the TUI package is unavailable", () => { + const bundledModule = resolve("virtual", "pi", "bundle", "chunks", "chunk.js"); + const execPath = resolve("virtual", "pi", "pi.exe"); + const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-modifiers.node"); + + const candidates = getNativeModuleCandidates(nativePath, { + moduleUrl: pathToFileURL(bundledModule).href, + execPath, + resolvePackage: () => { + throw new Error("not installed"); + }, + }); + + assert.deepEqual(candidates, [ + join(dirname(bundledModule), "..", nativePath), + join(dirname(bundledModule), nativePath), + join(dirname(execPath), nativePath), + ]); + }); +}); diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs new file mode 100644 index 00000000000..5c4fc62c6c3 --- /dev/null +++ b/scripts/build-coding-agent-bundle.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); +const codingAgentDir = join(repoRoot, "packages", "coding-agent"); +const aiDistDir = join(repoRoot, "packages", "ai", "dist"); +const codingAgentDistDir = join(codingAgentDir, "dist"); +const bundleDir = join(codingAgentDistDir, "bundle"); +const banner = { + js: 'import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);', +}; +const allowedExternalPackages = new Set([ + "@silvia-odwyer/photon-node", + // Optional native accelerators. Their callers fall back to JavaScript when absent. + "bufferutil", + "utf-8-validate", + // Optional debug output coloring. + "supports-color", +]); + +function commonBuildOptions() { + return { + absWorkingDir: repoRoot, + banner, + bundle: true, + define: { PI_BUNDLED_NODE: "true" }, + external: ["@silvia-odwyer/photon-node"], + format: "esm", + legalComments: "none", + logLevel: "warning", + metafile: true, + minifySyntax: true, + minifyWhitespace: true, + platform: "node", + sourcemap: false, + target: "node22.19", + // Do not apply the monorepo's source-oriented path aliases while bundling + // compiled output. Release builds must resolve the same package entries as + // an installed npm package. + tsconfigRaw: { compilerOptions: {} }, + }; +} + +function validateExternalImports(metafiles) { + const unexpected = new Set(); + for (const metafile of metafiles) { + for (const input of Object.values(metafile.inputs)) { + for (const imported of input.imports) { + if (!imported.external || isBuiltin(imported.path) || allowedExternalPackages.has(imported.path)) { + continue; + } + unexpected.add(imported.path); + } + } + } + if (unexpected.size > 0) { + throw new Error(`Bundle left unexpected external imports: ${Array.from(unexpected).sort().join(", ")}`); + } +} + +function findContainingOutput(metafile, inputSuffix) { + const normalizedSuffix = inputSuffix.replaceAll("\\", "/"); + for (const [outputPath, output] of Object.entries(metafile.outputs)) { + if (Object.keys(output.inputs).some((inputPath) => inputPath.replaceAll("\\", "/").endsWith(normalizedSuffix))) { + return resolve(repoRoot, outputPath); + } + } + throw new Error(`Could not locate bundled output containing ${inputSuffix}`); +} + +function outputBytes(metafiles) { + return metafiles.reduce( + (total, metafile) => total + Object.values(metafile.outputs).reduce((subtotal, output) => subtotal + output.bytes, 0), + 0, + ); +} + +for (const entry of [ + join(codingAgentDistDir, "cli.js"), + join(codingAgentDistDir, "index.js"), + join(codingAgentDistDir, "rpc-entry.js"), + join(codingAgentDistDir, "client", "index.js"), + join(codingAgentDistDir, "utils", "image-resize-worker.js"), + join(aiDistDir, "api", "bedrock-converse-stream.js"), + join(aiDistDir, "auth", "oauth", "anthropic.js"), +]) { + if (!existsSync(entry)) { + throw new Error(`Bundle input is missing: ${relative(repoRoot, entry)}. Build the workspace packages first.`); + } +} + +rmSync(bundleDir, { force: true, recursive: true }); +mkdirSync(bundleDir, { recursive: true }); + +const mainResult = await build({ + ...commonBuildOptions(), + entryNames: "[name]", + entryPoints: { + cli: join(codingAgentDistDir, "cli.js"), + client: join(codingAgentDistDir, "client", "index.js"), + index: join(codingAgentDistDir, "index.js"), + "rpc-entry": join(codingAgentDistDir, "rpc-entry.js"), + }, + outdir: bundleDir, + chunkNames: "chunks/[name]-[hash]", + splitting: true, +}); + +const bedrockLoaderOutput = findContainingOutput(mainResult.metafile, "packages/ai/dist/api/bedrock-converse-stream.lazy.js"); +const oauthLoaderOutput = findContainingOutput(mainResult.metafile, "packages/ai/dist/auth/oauth/load.js"); +const imageResizeOutput = findContainingOutput(mainResult.metafile, "packages/coding-agent/dist/utils/image-resize.js"); +if (dirname(bedrockLoaderOutput) !== dirname(oauthLoaderOutput)) { + throw new Error("Bedrock and OAuth lazy loaders were emitted into different directories"); +} + +// These implementations are reached through variable-specifier imports or a +// worker URL, so the main bundle cannot follow them. Emit one self-contained +// file per implementation beside the code that resolves it. +const lazyResult = await build({ + ...commonBuildOptions(), + entryNames: "[name]", + entryPoints: { + anthropic: join(aiDistDir, "auth", "oauth", "anthropic.js"), + "bedrock-converse-stream": join(aiDistDir, "api", "bedrock-converse-stream.js"), + "github-copilot": join(aiDistDir, "auth", "oauth", "github-copilot.js"), + "image-resize-worker": join(codingAgentDistDir, "utils", "image-resize-worker.js"), + "kimi-coding": join(aiDistDir, "auth", "oauth", "kimi-coding.js"), + "openai-codex": join(aiDistDir, "auth", "oauth", "openai-codex.js"), + openrouter: join(aiDistDir, "auth", "oauth", "openrouter.js"), + radius: join(aiDistDir, "auth", "oauth", "radius.js"), + xai: join(aiDistDir, "auth", "oauth", "xai.js"), + }, + outdir: dirname(bedrockLoaderOutput), + splitting: false, +}); + +const imageResizeWorkerOutput = resolve(dirname(bedrockLoaderOutput), "image-resize-worker.js"); +if (dirname(imageResizeOutput) !== dirname(imageResizeWorkerOutput)) { + throw new Error("Image resize implementation and worker were emitted into different directories"); +} + +validateExternalImports([mainResult.metafile, lazyResult.metafile]); +chmodSync(join(bundleDir, "cli.js"), 0o755); +chmodSync(join(bundleDir, "rpc-entry.js"), 0o755); + +const files = new Set([...Object.keys(mainResult.metafile.outputs), ...Object.keys(lazyResult.metafile.outputs)]).size; +const mib = outputBytes([mainResult.metafile, lazyResult.metafile]) / (1024 * 1024); +console.log(`Built ${relative(repoRoot, bundleDir)} (${files} files, ${mib.toFixed(1)} MiB)`); diff --git a/scripts/profile-coding-agent-node.mjs b/scripts/profile-coding-agent-node.mjs index 79d8d6a6bfc..3d2fb5b78f0 100644 --- a/scripts/profile-coding-agent-node.mjs +++ b/scripts/profile-coding-agent-node.mjs @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; @@ -9,6 +9,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); const packageDir = join(repoRoot, "packages", "coding-agent"); const distCliPath = join(packageDir, "dist", "cli.js"); +const bundledDistCliPath = join(packageDir, "dist", "bundle", "cli.js"); const srcCliPath = join(packageDir, "src", "cli.ts"); const defaultNodeProfileDir = join(repoRoot, "profiles-node"); const defaultBunProfileDir = join(repoRoot, "profiles-bun"); @@ -35,8 +36,9 @@ Options: --runtime node, bun, or auto (default: auto) --agent-dir

Use a specific PI_CODING_AGENT_DIR for the benchmark run --isolated-agent-dir Use a fresh temporary agent dir instead of the normal one + --bundle Build and profile the bundled Node entrypoint instead of dist/cli.js --no-offline Do not force PI_OFFLINE=1 / PI_SKIP_VERSION_CHECK=1 - --skip-build Reuse the current dist/cli.js without rebuilding first (Node only) + --skip-build Reuse the selected build output without rebuilding first (Node only) --cpu-profile Write CPU profiles for benchmark runs --help Show this help @@ -73,6 +75,7 @@ function parseMode(value) { function parseArgs(argv) { const options = { mode: "tui", + bundle: false, runs: 1, warmup: 0, profileDir: undefined, @@ -103,6 +106,11 @@ function parseArgs(argv) { continue; } + if (arg === "--bundle") { + options.bundle = true; + continue; + } + if (arg === "--skip-build") { options.build = false; continue; @@ -221,7 +229,7 @@ function parseStartupTimings(stderr) { let inBlock = false; for (const line of lines) { - if (line.includes("--- Startup Timings ---")) { + if (/^--- Startup Timings(?:: [^-]+)? ---$/.test(line.trim())) { inBlock = true; continue; } @@ -278,59 +286,72 @@ async function waitForExit(child, errorPrefix) { }); } -async function runBuild() { - process.stdout.write("Building packages/tui, packages/telemetry, packages/ai, packages/agent, and packages/coding-agent...\n"); +async function runBuild(bundle) { + process.stdout.write( + `Building dependencies and the ${bundle ? "bundled" : "unbundled"} coding-agent Node entrypoint...\n`, + ); const startedAt = performance.now(); - const child = spawn( - "npm", - [ - "run", - "build", - "--workspace", - "packages/tui", - "--workspace", - "packages/telemetry", - "--workspace", - "packages/ai", - "--workspace", - "packages/agent", - "--workspace", - "packages/coding-agent", - ], + const commands = [ { + label: "Dependency build", + args: [ + "run", + "build", + "--workspace", + "packages/tui", + "--workspace", + "packages/telemetry", + "--workspace", + "packages/ai", + "--workspace", + "packages/agent", + "--workspace", + "packages/protocol", + "--workspace", + "packages/client", + ], + }, + { + label: "Coding-agent build", + args: ["run", bundle ? "build" : "build:unbundled", "--workspace", "packages/coding-agent"], + }, + ]; + + for (const command of commands) { + const child = spawn("npm", command.args, { cwd: repoRoot, env: process.env, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", - }, - ); + }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); - const exitCode = await waitForExit(child, "Build"); - if (exitCode !== 0) { - if (stdout.trim()) { - process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`); - } - if (stderr.trim()) { - process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`); + const exitCode = await waitForExit(child, command.label); + if (exitCode !== 0) { + if (stdout.trim()) { + process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`); + } + if (stderr.trim()) { + process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`); + } + throw new Error(`${command.label} failed with exit code ${exitCode}`); } - throw new Error(`Build failed with exit code ${exitCode}`); } process.stdout.write(`Build completed in ${formatMs(performance.now() - startedAt)}\n`); } -function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { +function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile, nodeEntryPath) { const benchmarkArgs = ["--no-session"]; if (mode === "rpc") { benchmarkArgs.push("--mode", "rpc"); @@ -352,7 +373,7 @@ function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { if (cpuProfile) { args.push("--cpu-prof", `--cpu-prof-dir=${profileDir}`, `--cpu-prof-name=${profileName}`); } - args.push(distCliPath, ...benchmarkArgs); + args.push(nodeEntryPath, ...benchmarkArgs); return { executable: process.execPath, args, @@ -360,7 +381,7 @@ function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { } function createBenchmarkEnv(options, isolatedAgentDir) { - const env = { ...process.env }; + const env = { ...process.env, PI_TIMING: "1" }; if (options.agentDir) { env[agentDirEnvName] = options.agentDir; } else if (isolatedAgentDir) { @@ -386,11 +407,12 @@ async function runTuiBenchmarkRun({ runtime, runIndex, measuredIndex, options, p mkdirSync(isolatedAgentDir, { recursive: true }); } - const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile); + const nodeEntryPath = options.bundle ? bundledDistCliPath : distCliPath; + const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile, nodeEntryPath); const child = spawn(command.executable, command.args, { cwd: packageDir, env: createBenchmarkEnv(options, isolatedAgentDir), - stdio: ["inherit", "ignore", "pipe"], + stdio: ["inherit", "inherit", "pipe"], shell: process.platform === "win32" && runtime === "bun", }); @@ -445,7 +467,8 @@ async function runRpcBenchmarkRun({ runtime, runIndex, measuredIndex, options, p mkdirSync(isolatedAgentDir, { recursive: true }); } - const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile); + const nodeEntryPath = options.bundle ? bundledDistCliPath : distCliPath; + const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile, nodeEntryPath); const child = spawn(command.executable, command.args, { cwd: packageDir, env: createBenchmarkEnv(options, isolatedAgentDir), @@ -547,11 +570,14 @@ async function main() { } const runtime = resolveRuntime(options.runtime); + if (options.bundle && runtime !== "node") { + throw new Error("--bundle only supports the Node runtime"); + } options.label = resolveLabel(options.mode, options.label); const profileDir = resolveProfileDir(runtime, options.profileDir); if (runtime === "node" && options.build) { - await runBuild(); + await runBuild(options.bundle); } if (runtime === "bun") { process.stdout.write( @@ -559,7 +585,16 @@ async function main() { ); } - const entryPath = runtime === "bun" ? srcCliPath : distCliPath; + const entryPath = runtime === "bun" ? srcCliPath : options.bundle ? bundledDistCliPath : distCliPath; + if ( + runtime === "node" && + !options.bundle && + !options.build && + existsSync(distCliPath) && + readFileSync(distCliPath, "utf8").includes('import "./bundle/cli.js";') + ) { + throw new Error("dist/cli.js is a bundled facade; rerun without --skip-build for an unbundled profile"); + } if (!existsSync(entryPath)) { throw new Error(`CLI entrypoint not found: ${entryPath}`); } @@ -597,7 +632,7 @@ async function main() { const maxElapsedRun = measuredRuns.reduce((slowest, run) => (run.elapsedMs > slowest.elapsedMs ? run : slowest)); if (measuredRuns.length === 1) { process.stdout.write("\nResult\n"); - process.stdout.write(` runtime: ${runtime}\n`); + process.stdout.write(` runtime: ${runtime}${options.bundle ? " (bundle)" : ""}\n`); process.stdout.write(` mode: ${options.mode}\n`); process.stdout.write(` elapsed: ${formatMs(measuredRuns[0].elapsedMs)}\n`); for (const [label, summary] of timingSummaries.entries()) { @@ -615,7 +650,7 @@ async function main() { } process.stdout.write("\nSummary\n"); - process.stdout.write(` runtime: ${runtime}\n`); + process.stdout.write(` runtime: ${runtime}${options.bundle ? " (bundle)" : ""}\n`); process.stdout.write(` mode: ${options.mode}\n`); process.stdout.write(` elapsed min: ${formatMs(elapsedSummary.min)}\n`); process.stdout.write(` elapsed median: ${formatMs(elapsedSummary.median)}\n`); From 39d869f02a331717df2f800d29f9fed19596494e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 22 Aug 2026 21:21:37 +0200 Subject: [PATCH 251/284] fix: publish installer artifacts --- .github/workflows/build-binaries.yml | 4 +- scripts/publish-release-announcement.mjs | 116 +++++++++++++++++++++-- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index d76e6f6e993..2fefa77a0d9 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -307,7 +307,9 @@ jobs: node scripts/publish-release-announcement.mjs \ --version "${RELEASE_TAG#v}" \ --bucket pi-artifacts \ - --endpoint "$R2_ENDPOINT" + --endpoint "$R2_ENDPOINT" \ + --installer-package-json packages/coding-agent/install-lock/package.json \ + --installer-package-lock packages/coding-agent/install-lock/package-lock.json publish-github-release: runs-on: ubuntu-latest diff --git a/scripts/publish-release-announcement.mjs b/scripts/publish-release-announcement.mjs index 238d868f84e..79d34e4e938 100644 --- a/scripts/publish-release-announcement.mjs +++ b/scripts/publish-release-announcement.mjs @@ -8,6 +8,8 @@ import { pathToFileURL } from "node:url"; import { getPublicWorkspacePackages } from "./release-packages.mjs"; const RELEASES_PREFIX = "releases/v1"; +const INSTALLER_PREFIX = "installer/v1"; +const INSTALLER_PACKAGE_NAME = "@earendil-works/pi-coding-agent-install"; const REGISTRY_URL = "https://registry.npmjs.org"; const RETRY_DELAY_MS = 5000; const RETRY_TIMEOUT_MS = 10 * 60 * 1000; @@ -18,19 +20,35 @@ function parseArgs(args) { const options = { bucket: undefined, endpoint: undefined, + installerPackageJson: undefined, + installerPackageLock: undefined, sourceCommit: undefined, version: undefined, }; for (let index = 0; index < args.length; index++) { const arg = args[index]; - if (arg !== "--bucket" && arg !== "--endpoint" && arg !== "--source-commit" && arg !== "--version") { + if ( + arg !== "--bucket" && + arg !== "--endpoint" && + arg !== "--installer-package-json" && + arg !== "--installer-package-lock" && + arg !== "--source-commit" && + arg !== "--version" + ) { throw new Error(`Unknown argument: ${arg}`); } const value = args[++index]; if (!value) throw new Error(`${arg} requires a value`); options[ - { "--bucket": "bucket", "--endpoint": "endpoint", "--source-commit": "sourceCommit", "--version": "version" }[arg] + { + "--bucket": "bucket", + "--endpoint": "endpoint", + "--installer-package-json": "installerPackageJson", + "--installer-package-lock": "installerPackageLock", + "--source-commit": "sourceCommit", + "--version": "version", + }[arg] ] = value; } @@ -39,6 +57,9 @@ function parseArgs(args) { if (!options.version || !STABLE_SEMVER_RE.test(options.version)) { throw new Error("--version must be a stable semver version"); } + if (Boolean(options.installerPackageJson) !== Boolean(options.installerPackageLock)) { + throw new Error("--installer-package-json and --installer-package-lock must be provided together"); + } return options; } @@ -130,7 +151,7 @@ function runAws(args, { allowNotFound = false, allowPreconditionFailure = false throw new Error(`aws ${args.slice(0, 2).join(" ")} failed:\n${message}`); } -function readLatestRelease(bucket, endpoint, outputPath) { +function readLatestRelease(bucket, endpoint, key, outputPath) { const head = runAws( [ "s3api", @@ -138,7 +159,7 @@ function readLatestRelease(bucket, endpoint, outputPath) { "--bucket", bucket, "--key", - `${RELEASES_PREFIX}/latest.json`, + key, "--endpoint-url", endpoint, ], @@ -156,7 +177,7 @@ function readLatestRelease(bucket, endpoint, outputPath) { "--bucket", bucket, "--key", - `${RELEASES_PREFIX}/latest.json`, + key, "--endpoint-url", endpoint, outputPath, @@ -174,7 +195,7 @@ function readLatestRelease(bucket, endpoint, outputPath) { return { etag: metadata.ETag, version: release.version }; } -function putJson(bucket, endpoint, path, key, cacheControl, condition) { +function putObject(bucket, endpoint, path, key, cacheControl, condition) { const args = [ "s3api", "put-object", @@ -196,6 +217,28 @@ function putJson(bucket, endpoint, path, key, cacheControl, condition) { return runAws(args, { allowPreconditionFailure: Boolean(condition) }) !== undefined; } +function putJson(bucket, endpoint, path, key, cacheControl, condition) { + return putObject(bucket, endpoint, path, key, cacheControl, condition); +} + +function validateInstallerArtifacts(packageJsonPath, packageLockPath, version) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const packageLock = JSON.parse(readFileSync(packageLockPath, "utf8")); + const root = packageLock.packages?.[""]; + + if (packageJson.name !== INSTALLER_PACKAGE_NAME || packageJson.version !== version) { + throw new Error(`Installer package.json must describe ${INSTALLER_PACKAGE_NAME}@${version}`); + } + if ( + packageLock.lockfileVersion !== 3 || + packageLock.version !== version || + root?.version !== version || + root.dependencies?.["@earendil-works/pi-coding-agent"] !== version + ) { + throw new Error(`Installer package-lock.json must describe Pi ${version}`); + } +} + export function compareReleaseVersions(left, right) { const leftMatch = STABLE_SEMVER_RE.exec(left); const rightMatch = STABLE_SEMVER_RE.exec(right); @@ -261,7 +304,13 @@ async function main() { writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`); const result = await advanceLatestRelease( options.version, - () => readLatestRelease(options.bucket, options.endpoint, join(temporaryDirectory, "latest-current.json")), + () => + readLatestRelease( + options.bucket, + options.endpoint, + `${RELEASES_PREFIX}/latest.json`, + join(temporaryDirectory, "latest-current.json"), + ), (condition) => putJson( options.bucket, @@ -277,6 +326,59 @@ async function main() { ? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json` : `Pi ${result.version} is already the latest announced release.`, ); + + if (options.installerPackageJson) { + validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version); + const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`; + putObject( + options.bucket, + options.endpoint, + options.installerPackageJson, + `${installerReleasePrefix}/package.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putObject( + options.bucket, + options.endpoint, + options.installerPackageLock, + `${installerReleasePrefix}/package-lock.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putJson( + options.bucket, + options.endpoint, + releasePath, + `${installerReleasePrefix}/metadata.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + const installerLatest = await advanceLatestRelease( + options.version, + () => + readLatestRelease( + options.bucket, + options.endpoint, + `${INSTALLER_PREFIX}/latest.json`, + join(temporaryDirectory, "installer-latest-current.json"), + ), + (condition) => + putJson( + options.bucket, + options.endpoint, + latestPath, + `${INSTALLER_PREFIX}/latest.json`, + "no-store", + condition, + ), + ); + console.log( + installerLatest.advanced + ? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json` + : `Pi ${installerLatest.version} is already the latest installer release.`, + ); + } } finally { rmSync(temporaryDirectory, { force: true, recursive: true }); } From cec3a91c029e453c4bebdd02d84335a0f52503d7 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 22 Aug 2026 22:50:12 +0200 Subject: [PATCH 252/284] feat(coding-agent): defer uncommon syntax grammars --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 9 +++ .../src/utils/highlight-js-lib-index.d.ts | 19 ----- .../coding-agent/src/utils/highlight-js.d.ts | 36 +++++++++ .../src/utils/syntax-highlight.ts | 79 ++++++++++++++++++- .../test/syntax-highlight.test.ts | 39 ++++++++- 6 files changed, 162 insertions(+), 21 deletions(-) delete mode 100644 packages/coding-agent/src/utils/highlight-js-lib-index.d.ts create mode 100644 packages/coding-agent/src/utils/highlight-js.d.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5300771401..969b2e80715 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time. - Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. - Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3035ad4c5b5..10dbb7935e7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -111,6 +111,7 @@ import { openBrowser } from "../../utils/open-browser.ts"; import { getCwdRelativePath } from "../../utils/paths.ts"; import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; +import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts"; import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts"; import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts"; import { ArminComponent } from "./components/armin.ts"; @@ -1051,6 +1052,14 @@ export class InteractiveMode { // Initialize available provider count for footer display await this.updateAvailableProviderCount(); + + // Flush the completed startup state before loading the remaining syntax grammars. + this.ui.renderNow(); + void loadAllHighlightLanguages().then(() => { + if (!this.isInitialized) return; + this.ui.invalidate(); + this.ui.requestRender(); + }); } /** diff --git a/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts b/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts deleted file mode 100644 index 75e31da2873..00000000000 --- a/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -declare module "highlight.js/lib/index.js" { - interface HighlightResult { - value: string; - } - - interface HighlightOptions { - language: string; - ignoreIllegals?: boolean; - } - - interface HighlightJs { - highlight(code: string, options: HighlightOptions): HighlightResult; - highlightAuto(code: string, languageSubset?: string[]): HighlightResult; - getLanguage(name: string): unknown; - } - - const hljs: HighlightJs; - export default hljs; -} diff --git a/packages/coding-agent/src/utils/highlight-js.d.ts b/packages/coding-agent/src/utils/highlight-js.d.ts new file mode 100644 index 00000000000..d4b71174e7f --- /dev/null +++ b/packages/coding-agent/src/utils/highlight-js.d.ts @@ -0,0 +1,36 @@ +interface HighlightJsResult { + value: string; +} + +interface HighlightJsOptions { + language: string; + ignoreIllegals?: boolean; +} + +interface HighlightJsLanguageDefinition { + readonly name?: string; +} + +type HighlightJsLanguageFactory = (hljs: HighlightJsApi) => HighlightJsLanguageDefinition; + +interface HighlightJsApi { + highlight(code: string, options: HighlightJsOptions): HighlightJsResult; + highlightAuto(code: string, languageSubset?: string[]): HighlightJsResult; + getLanguage(name: string): HighlightJsLanguageDefinition | undefined; + registerLanguage(name: string, language: HighlightJsLanguageFactory): void; +} + +declare module "highlight.js/lib/core.js" { + const hljs: HighlightJsApi; + export default hljs; +} + +declare module "highlight.js/lib/index.js" { + const hljs: HighlightJsApi; + export default hljs; +} + +declare module "highlight.js/lib/languages/*.js" { + const language: HighlightJsLanguageFactory; + export default language; +} diff --git a/packages/coding-agent/src/utils/syntax-highlight.ts b/packages/coding-agent/src/utils/syntax-highlight.ts index bcc4add1c37..e08c4d894f6 100644 --- a/packages/coding-agent/src/utils/syntax-highlight.ts +++ b/packages/coding-agent/src/utils/syntax-highlight.ts @@ -1,6 +1,83 @@ -import hljs from "highlight.js/lib/index.js"; +import { createRequire } from "node:module"; +import hljs from "highlight.js/lib/core.js"; +import bash from "highlight.js/lib/languages/bash.js"; +import c from "highlight.js/lib/languages/c.js"; +import cpp from "highlight.js/lib/languages/cpp.js"; +import csharp from "highlight.js/lib/languages/csharp.js"; +import dart from "highlight.js/lib/languages/dart.js"; +import go from "highlight.js/lib/languages/go.js"; +import groovy from "highlight.js/lib/languages/groovy.js"; +import java from "highlight.js/lib/languages/java.js"; +import javascript from "highlight.js/lib/languages/javascript.js"; +import kotlin from "highlight.js/lib/languages/kotlin.js"; +import lua from "highlight.js/lib/languages/lua.js"; +import nix from "highlight.js/lib/languages/nix.js"; +import perl from "highlight.js/lib/languages/perl.js"; +import php from "highlight.js/lib/languages/php.js"; +import python from "highlight.js/lib/languages/python.js"; +import ruby from "highlight.js/lib/languages/ruby.js"; +import rust from "highlight.js/lib/languages/rust.js"; +import scala from "highlight.js/lib/languages/scala.js"; +import swift from "highlight.js/lib/languages/swift.js"; +import typescript from "highlight.js/lib/languages/typescript.js"; import { decodeHtmlEntityAt } from "./html.ts"; +const moduleRequire = createRequire(import.meta.url); +declare const PI_BUNDLED_NODE: boolean; +declare const Bun: unknown; +declare const require: (specifier: string) => unknown; + +const eagerLanguages = { + python, + java, + go, + javascript, + cpp, + typescript, + php, + ruby, + c, + csharp, + nix, + bash, + rust, + scala, + kotlin, + swift, + dart, + groovy, + perl, + lua, +}; + +for (const [name, language] of Object.entries(eagerLanguages)) { + hljs.registerLanguage(name, language); +} + +let allLanguagesPromise: Promise | undefined; + +export function loadAllHighlightLanguages(): Promise { + if (!allLanguagesPromise) { + allLanguagesPromise = new Promise((resolve) => { + setImmediate(() => { + try { + // Static require lets esbuild and Bun include this deferred module in + // standalone bundles. Source and unbundled Node runtimes need createRequire. + if ((typeof PI_BUNDLED_NODE !== "undefined" && PI_BUNDLED_NODE) || typeof Bun !== "undefined") { + require("highlight.js/lib/index.js"); + } else { + moduleRequire("highlight.js/lib/index.js"); + } + } catch { + // Eager languages and plaintext fallback remain available. + } + resolve(); + }); + }); + } + return allLanguagesPromise; +} + export type HighlightFormatter = (text: string) => string; export type HighlightTheme = Partial>; diff --git a/packages/coding-agent/test/syntax-highlight.test.ts b/packages/coding-agent/test/syntax-highlight.test.ts index 6f311e4949f..62fe5f025fb 100644 --- a/packages/coding-agent/test/syntax-highlight.test.ts +++ b/packages/coding-agent/test/syntax-highlight.test.ts @@ -1,9 +1,46 @@ import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { highlightCode, initTheme } from "../src/modes/interactive/theme/theme.ts"; -import { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.ts"; +import { + highlight, + loadAllHighlightLanguages, + renderHighlightedHtml, + supportsLanguage, +} from "../src/utils/syntax-highlight.ts"; + +const eagerLanguages = [ + "python", + "java", + "go", + "javascript", + "cpp", + "typescript", + "php", + "ruby", + "c", + "csharp", + "nix", + "bash", + "rust", + "scala", + "kotlin", + "swift", + "dart", + "groovy", + "perl", + "lua", +]; +const eagerLanguagesLoadedAtStartup = eagerLanguages.every(supportsLanguage); +const uncommonLanguageLoadedAtStartup = supportsLanguage("ada"); describe("syntax highlight renderer", () => { + it("loads the twenty most common languages at startup and defers the rest", async () => { + expect(eagerLanguagesLoadedAtStartup).toBe(true); + expect(uncommonLanguageLoadedAtStartup).toBe(false); + await loadAllHighlightLanguages(); + expect(supportsLanguage("ada")).toBe(true); + }); + it("renders highlighted spans with the provided theme", () => { const rendered = renderHighlightedHtml('const value', { keyword: (text) => `[keyword:${text}]`, From 74786a748f5314cc2127ebbcfa2d732e9b8433f5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 22 Aug 2026 23:15:49 +0200 Subject: [PATCH 253/284] fix(coding-agent): support -- end-of-options, closes #7269 --- packages/coding-agent/src/cli/args.ts | 11 +++++- .../7269-cli-end-of-options.test.ts | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/suite/regressions/7269-cli-end-of-options.test.ts diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index bfb0ff6e285..5f111812bf6 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -79,7 +79,16 @@ export function parseArgs(args: string[]): Args { for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === "--help" || arg === "-h") { + if (arg === "--") { + for (const positionalArg of args.slice(i + 1)) { + if (positionalArg.startsWith("@")) { + result.fileArgs.push(positionalArg.slice(1)); + } else { + result.messages.push(positionalArg); + } + } + break; + } else if (arg === "--help" || arg === "-h") { result.help = true; } else if (arg === "--version" || arg === "-v") { result.version = true; diff --git a/packages/coding-agent/test/suite/regressions/7269-cli-end-of-options.test.ts b/packages/coding-agent/test/suite/regressions/7269-cli-end-of-options.test.ts new file mode 100644 index 00000000000..cd012c28056 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7269-cli-end-of-options.test.ts @@ -0,0 +1,39 @@ +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseArgs } from "../../../src/cli/args.ts"; +import { createHarness, getUserTexts, type Harness } from "../harness.ts"; + +describe("issue #7269 CLI end-of-options delimiter", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + harness = undefined; + }); + + it.each(["- summarize the following points for me", "--answer my question briefly"])( + "passes %j as a prompt after --", + async (prompt) => { + const parsed = parseArgs(["-ne", "--no-session", "-p", "--", prompt]); + expect(parsed.messages).toEqual([prompt]); + expect(parsed.unknownFlags.size).toBe(0); + expect(parsed.diagnostics).toEqual([]); + + harness = await createHarness(); + harness.setResponses([fauxAssistantMessage("ok")]); + await harness.session.prompt(parsed.messages[0]); + expect(getUserTexts(harness)).toEqual([prompt]); + }, + ); + + it("stops parsing options while retaining @file handling", () => { + const parsed = parseArgs(["--unknown-flag", "value", "--", "--provider", "openai", "-c", "@prompt.md"]); + + expect(parsed.unknownFlags.get("unknown-flag")).toBe("value"); + expect(parsed.provider).toBeUndefined(); + expect(parsed.continue).toBeUndefined(); + expect(parsed.messages).toEqual(["--provider", "openai", "-c"]); + expect(parsed.fileArgs).toEqual(["prompt.md"]); + expect(parsed.diagnostics).toEqual([]); + }); +}); From 62bcbf6be0206cc4fd2ca0e35dd5eb879ca6c8e7 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 00:01:33 +0200 Subject: [PATCH 254/284] docs(coding-agent): document -- end-of-options delimiter --- packages/coding-agent/README.md | 6 +++++- packages/coding-agent/docs/usage.md | 6 +++++- packages/coding-agent/src/cli/args.ts | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e6e43c2eb8b..66a07900703 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -514,7 +514,7 @@ Read the [blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/) ## CLI Reference ```bash -pi [options] [@files...] [messages...] +pi [options] [--] [@files...] [messages...] ``` ### Package Commands @@ -613,6 +613,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | +| `--` | Stop option parsing; remaining arguments are prompts or `@file` inputs | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | @@ -635,6 +636,9 @@ pi "List all .ts files in src/" # Non-interactive pi -p "Summarize this codebase" +# Prompt beginning with a dash +pi -p -- "- Summarize these points" + # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 41a5c37d9b0..bbf60db1e1d 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -142,7 +142,7 @@ If you use pi for open source work and want to publish sessions for model, promp ## CLI Reference ```bash -pi [options] [@files...] [messages...] +pi [options] [--] [@files...] [messages...] ``` ### Package Commands @@ -246,6 +246,7 @@ pi --no-extensions -e ./my-extension.ts | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | +| `--` | Stop option parsing; remaining arguments are prompts or `@file` inputs | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | @@ -272,6 +273,9 @@ pi "List all .ts files in src/" # Non-interactive pi -p "Summarize this codebase" +# Prompt beginning with a dash +pi -p -- "- Summarize these points" + # Non-interactive with piped stdin cat README.md | pi -p "Summarize this text" diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 5f111812bf6..8e03419e699 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -262,7 +262,7 @@ export function printHelp(extensionFlags?: ExtensionFlag[]): void { console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools ${chalk.bold("Usage:")} - ${APP_NAME} [options] [@files...] [messages...] + ${APP_NAME} [options] [--] [@files...] [messages...] ${chalk.bold("Commands:")} ${APP_NAME} install [-l] Install extension source and add to settings @@ -316,6 +316,7 @@ ${chalk.bold("Options:")} --approve, -a Trust project-local files for this run --no-approve, -na Ignore project-local files for this run --offline Disable startup network operations (same as PI_OFFLINE=1) + -- End option parsing; treat remaining arguments as messages/files --help, -h Show this help --version, -v Show version number @@ -340,6 +341,9 @@ ${chalk.bold("Examples:")} # Non-interactive mode (process and exit) ${APP_NAME} -p "List all .ts files in src/" + # Prompt beginning with a dash + ${APP_NAME} -p -- "- Summarize these points" + # Multiple messages (interactive) ${APP_NAME} "Read package.json" "What dependencies do we have?" From faecac2ca85fa18e039d4c36d24e98023b652db7 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 00:02:45 +0200 Subject: [PATCH 255/284] feat(coding-agent): reduce bundled startup work --- packages/coding-agent/CHANGELOG.md | 1 + .../src/utils/syntax-highlight.ts | 25 ++++++------------- scripts/build-coding-agent-bundle.mjs | 12 +++++++++ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 969b2e80715..95059eb868c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Changed the bundled Node.js runtime to load jiti's Babel transform only when an extension needs it, reducing CLI startup time and bundle size. - Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time. - Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. - Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL. diff --git a/packages/coding-agent/src/utils/syntax-highlight.ts b/packages/coding-agent/src/utils/syntax-highlight.ts index e08c4d894f6..080c128c207 100644 --- a/packages/coding-agent/src/utils/syntax-highlight.ts +++ b/packages/coding-agent/src/utils/syntax-highlight.ts @@ -1,4 +1,3 @@ -import { createRequire } from "node:module"; import hljs from "highlight.js/lib/core.js"; import bash from "highlight.js/lib/languages/bash.js"; import c from "highlight.js/lib/languages/c.js"; @@ -22,11 +21,6 @@ import swift from "highlight.js/lib/languages/swift.js"; import typescript from "highlight.js/lib/languages/typescript.js"; import { decodeHtmlEntityAt } from "./html.ts"; -const moduleRequire = createRequire(import.meta.url); -declare const PI_BUNDLED_NODE: boolean; -declare const Bun: unknown; -declare const require: (specifier: string) => unknown; - const eagerLanguages = { python, java, @@ -60,18 +54,13 @@ export function loadAllHighlightLanguages(): Promise { if (!allLanguagesPromise) { allLanguagesPromise = new Promise((resolve) => { setImmediate(() => { - try { - // Static require lets esbuild and Bun include this deferred module in - // standalone bundles. Source and unbundled Node runtimes need createRequire. - if ((typeof PI_BUNDLED_NODE !== "undefined" && PI_BUNDLED_NODE) || typeof Bun !== "undefined") { - require("highlight.js/lib/index.js"); - } else { - moduleRequire("highlight.js/lib/index.js"); - } - } catch { - // Eager languages and plaintext fallback remain available. - } - resolve(); + void import("highlight.js/lib/index.js").then( + () => resolve(), + () => { + // Eager languages and plaintext fallback remain available. + resolve(); + }, + ); }); }); } diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 5c4fc62c6c3..d683441ae0c 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -17,6 +17,7 @@ const banner = { }; const allowedExternalPackages = new Set([ "@silvia-odwyer/photon-node", + "jiti", // Optional native accelerators. Their callers fall back to JavaScript when absent. "bufferutil", "utf-8-validate", @@ -24,6 +25,13 @@ const allowedExternalPackages = new Set([ "supports-color", ]); +const lazyJitiPlugin = { + name: "lazy-jiti-transform", + setup(build) { + build.onResolve({ filter: /^jiti\/static$/ }, () => ({ external: true, path: "jiti" })); + }, +}; + function commonBuildOptions() { return { absWorkingDir: repoRoot, @@ -38,6 +46,10 @@ function commonBuildOptions() { minifySyntax: true, minifyWhitespace: true, platform: "node", + // The source uses jiti/static so Bun embeds its Babel transform. The Node + // package can use regular jiti from its direct dependency and load Babel + // only when an extension actually needs transformation. + plugins: [lazyJitiPlugin], sourcemap: false, target: "node22.19", // Do not apply the monorepo's source-oriented path aliases while bundling From 77c540704d6872d8491fefdfb4829529635bcf09 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 00:07:31 +0200 Subject: [PATCH 256/284] meta(changelog): Add missing changelog entry --- packages/coding-agent/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 95059eb868c..62c200a4483 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -31,6 +31,7 @@ - Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). - Fixed branch summary entries recording the navigation destination in `fromId` instead of the pre-navigation source leaf. - Fixed threshold auto-compaction being skipped when providers omit streaming usage data ([#8328](https://github.com/earendil-works/pi/issues/8328)). +- Fixed dash-prefixed prompts being parsed as options by supporting `--` as an end-of-options delimiter ([#7269](https://github.com/earendil-works/pi/issues/7269)). ## [0.84.2] - 2026-08-14 From a1f955e9f47fd3379b44f4aace65ab916c80519a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 00:24:15 +0200 Subject: [PATCH 257/284] fix(coding-agent): remove redundant development dependencies --- package-lock.json | 10 ---------- package.json | 1 - packages/coding-agent/package.json | 2 -- 3 files changed, 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index f9b9ee73da3..c774996786d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "@typescript/native-preview": "7.0.0-dev.20260120.1", "esbuild": "0.28.1", "husky": "9.1.7", - "jiti": "2.7.0", "shx": "0.4.0", "tsx": "4.22.1", "typescript": "5.9.3" @@ -2073,13 +2072,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/diff": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", - "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -5515,9 +5507,7 @@ }, "devDependencies": { "@types/cross-spawn": "6.0.6", - "@types/diff": "7.0.2", "@types/hosted-git-info": "3.0.5", - "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/proper-lockfile": "4.1.4", "@types/semver": "7.7.1", diff --git a/package.json b/package.json index 42b69c65ed7..e981d3ab5ac 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "@typescript/native-preview": "7.0.0-dev.20260120.1", "esbuild": "0.28.1", "husky": "9.1.7", - "jiti": "2.7.0", "shx": "0.4.0", "tsx": "4.22.1", "typescript": "5.9.3" diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 0073ac0f8da..31a4163e18e 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -78,9 +78,7 @@ }, "devDependencies": { "@types/cross-spawn": "6.0.6", - "@types/diff": "7.0.2", "@types/hosted-git-info": "3.0.5", - "@types/ms": "2.1.0", "@types/node": "24.12.4", "@types/proper-lockfile": "4.1.4", "@types/semver": "7.7.1", From f8f03460a011c47100bad47bd21c9457fb0f70f0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 09:28:19 +0200 Subject: [PATCH 258/284] fix: reduce workspace dependency tree --- package-lock.json | 138 +----------------- packages/agent/package.json | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/packages.md | 2 +- .../install-lock/package-lock.json | 43 ------ packages/coding-agent/npm-shrinkwrap.json | 43 ------ packages/coding-agent/package.json | 3 +- .../coding-agent/src/core/package-manager.ts | 32 +++- .../coding-agent/test/package-manager.test.ts | 54 +++++++ packages/evals/package.json | 2 +- packages/telemetry/package.json | 2 +- 12 files changed, 90 insertions(+), 234 deletions(-) diff --git a/package-lock.json b/package-lock.json index c774996786d..c2ad9261a0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3180,23 +3180,6 @@ "dev": true, "license": "MIT" }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -3968,15 +3951,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -4228,22 +4202,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5382,7 +5340,7 @@ "yaml": "2.9.0" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", "vitest": "4.1.9" @@ -5398,23 +5356,6 @@ "extraneous": true, "license": "MIT" }, - "packages/agent/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/agent/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/ai": { "name": "@earendil-works/pi-ai", "version": "0.84.2", @@ -5435,7 +5376,7 @@ "pi-ai": "dist/cli.js" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "canvas": "3.2.3", "vitest": "4.1.9" }, @@ -5443,23 +5384,6 @@ "node": ">=22.19.0" } }, - "packages/ai/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/ai/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/client": { "name": "@earendil-works/pi-client", "version": "0.84.2", @@ -5489,7 +5413,6 @@ "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", - "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", @@ -5508,7 +5431,7 @@ "devDependencies": { "@types/cross-spawn": "6.0.6", "@types/hosted-git-info": "3.0.5", - "@types/node": "24.12.4", + "@types/node": "22.19.19", "@types/proper-lockfile": "4.1.4", "@types/semver": "7.7.1", "shx": "0.4.0", @@ -5601,53 +5524,19 @@ "extraneous": true, "license": "MIT" }, - "packages/coding-agent/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/coding-agent/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/evals": { "name": "@earendil-works/pi-evals", "version": "0.84.2", "devDependencies": { "@earendil-works/pi-ai": "^0.84.2", "@earendil-works/pi-coding-agent": "^0.84.2", - "@types/node": "24.12.4", + "@types/node": "22.19.19", "shx": "0.4.0", "typescript": "5.9.3", "vitest": "4.1.9", "vitest-evals": "0.15.0" } }, - "packages/evals/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/evals/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/protocol": { "name": "@earendil-works/pi-protocol", "version": "0.84.2", @@ -5700,30 +5589,13 @@ "version": "0.84.2", "license": "MIT", "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "vitest": "4.1.9" }, "engines": { "node": ">=22.19.0" } }, - "packages/telemetry/node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "packages/telemetry/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, "packages/tui": { "name": "@earendil-works/pi-tui", "version": "0.84.2", diff --git a/packages/agent/package.json b/packages/agent/package.json index f204dcfe704..b1b81d85c1c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -60,7 +60,7 @@ "node": ">=22.19.0" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", "vitest": "4.1.9" diff --git a/packages/ai/package.json b/packages/ai/package.json index e2f4345a0f6..38fc4f46e24 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -92,7 +92,7 @@ "node": ">=22.19.0" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "canvas": "3.2.3", "vitest": "4.1.9" } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 62c200a4483..d4238c6c341 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Changed package resource glob expansion to use Node.js's built-in implementation with deterministic visible-path matching, reducing the installed runtime dependency tree. - Changed the bundled Node.js runtime to load jiti's Babel transform only when an extension needs it, reducing CLI startup time and bundle size. - Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time. - Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index b2f493b4e64..3f141c5141e 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -130,7 +130,7 @@ Add a `pi` manifest to `package.json` or use conventional directories. Include t } ``` -Paths are relative to the package root. Arrays support glob patterns and `!exclusions`. +Paths are relative to the package root. Arrays support glob patterns and `!exclusions`. Positive manifest globs discover visible paths in lexical order. List dot-prefixed paths directly. If a glob would need to continue through a symlink, list the symlinked resource root directly. ### Gallery Metadata diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index b95f0bb820f..c594cf91fbc 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -513,7 +513,6 @@ "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", - "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", @@ -1233,23 +1232,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -1447,15 +1429,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1567,22 +1540,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index acd50e996d0..1b892389f27 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -18,7 +18,6 @@ "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", - "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", @@ -1223,23 +1222,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -1437,15 +1419,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1557,22 +1530,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 31a4163e18e..11b6cd28e20 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -53,7 +53,6 @@ "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", - "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", @@ -79,7 +78,7 @@ "devDependencies": { "@types/cross-spawn": "6.0.6", "@types/hosted-git-info": "3.0.5", - "@types/node": "24.12.4", + "@types/node": "22.19.19", "@types/proper-lockfile": "4.1.4", "@types/semver": "7.7.1", "shx": "0.4.0", diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 805928c0822..d65386c628a 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,6 +1,16 @@ import type { ChildProcess, ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + globSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; function getEnv(): NodeJS.ProcessEnv { @@ -24,7 +34,6 @@ function getEnv(): NodeJS.ProcessEnv { import { basename, dirname, join, relative, resolve, sep } from "node:path"; import type { Readable } from "node:stream"; -import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; import { gt, maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; @@ -275,6 +284,18 @@ function hasGlobPattern(s: string): boolean { return s.includes("*") || s.includes("?"); } +/** Glob entries discover visible paths; exact entries can target dot paths or symlinked trees. */ +function expandPackageGlob(pattern: string, root: string): string[] { + return globSync(pattern, { cwd: root }) + .map((match) => resolve(root, match)) + .filter((path) => + relative(root, path) + .split(sep) + .every((segment) => segment === ".." || !segment.startsWith(".")), + ) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } { const plain: string[] = []; const patterns: string[] = []; @@ -2300,12 +2321,7 @@ export class DefaultPackageManager implements PackageManager { return [resolve(root, entry)]; } - return globSync(entry, { - cwd: root, - absolute: true, - dot: false, - nodir: false, - }).map((match) => resolve(match)); + return expandPackageGlob(entry, root); }); return this.collectFilesFromPaths(resolved, resourceType); } diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 2de47fcf05a..6f4f6b4fab3 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1647,6 +1647,60 @@ Content`, expect(result.skills.some((r) => isEnabled(r, "pdf-to-markdown", "includes"))).toBe(true); expect(result.skills.some((r) => isEnabled(r, "document-processor-api", "includes"))).toBe(true); }); + + it("should sort manifest glob matches and use exact entries for dot paths and symlink traversal", async () => { + const pkgDir = join(tempDir, "manifest-glob-semantics-pkg"); + const extensionFilesDir = join(pkgDir, "extension-files"); + const extensionGroupDir = join(pkgDir, "extension-groups", "group"); + const linkedPluginSource = join(pkgDir, "linked-plugin-source"); + mkdirSync(join(extensionFilesDir, "nested"), { recursive: true }); + mkdirSync(extensionGroupDir, { recursive: true }); + mkdirSync(join(pkgDir, "plugins", "local", "skills", "local-skill"), { recursive: true }); + mkdirSync(join(linkedPluginSource, "skills", "linked-skill"), { recursive: true }); + writeFileSync(join(extensionFilesDir, "z.ts"), "export default function() {}"); + writeFileSync(join(extensionFilesDir, "a.ts"), "export default function() {}"); + writeFileSync(join(extensionFilesDir, ".ignored.ts"), "export default function() {}"); + writeFileSync(join(extensionFilesDir, "nested", ".hidden.ts"), "export default function() {}"); + writeFileSync(join(extensionGroupDir, "index.ts"), "export default function() {}"); + writeFileSync( + join(pkgDir, "plugins", "local", "skills", "local-skill", "SKILL.md"), + "---\nname: local-skill\ndescription: Local\n---\n", + ); + writeFileSync( + join(linkedPluginSource, "skills", "linked-skill", "SKILL.md"), + "---\nname: linked-skill\ndescription: Linked\n---\n", + ); + symlinkSync( + linkedPluginSource, + join(pkgDir, "plugins", "linked"), + process.platform === "win32" ? "junction" : "dir", + ); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "manifest-glob-semantics-pkg", + pi: { + extensions: [ + "./extension-files/*.ts", + "./extension-files/**/.ignored.ts", + "./extension-files/nested/.hidden.ts", + "./extension-groups/*/", + ], + skills: ["./plugins/*/skills", "./plugins/linked/skills"], + }, + }), + ); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + expect(result.extensions.map((resource) => relative(pkgDir, resource.path))).toEqual([ + join("extension-files", "a.ts"), + join("extension-files", "z.ts"), + join("extension-files", "nested", ".hidden.ts"), + join("extension-groups", "group", "index.ts"), + ]); + expect(result.skills.some((resource) => pathEndsWith(resource.path, "local-skill/SKILL.md"))).toBe(true); + expect(result.skills.some((resource) => pathEndsWith(resource.path, "linked-skill/SKILL.md"))).toBe(true); + }); }); describe("pattern filtering in package filters", () => { diff --git a/packages/evals/package.json b/packages/evals/package.json index 3acd48e241e..a6fa94da582 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -11,7 +11,7 @@ "devDependencies": { "@earendil-works/pi-ai": "^0.84.2", "@earendil-works/pi-coding-agent": "^0.84.2", - "@types/node": "24.12.4", + "@types/node": "22.19.19", "shx": "0.4.0", "typescript": "5.9.3", "vitest-evals": "0.15.0", diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 37e1153223e..8b6c27780b1 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -41,7 +41,7 @@ "node": ">=22.19.0" }, "devDependencies": { - "@types/node": "24.12.4", + "@types/node": "22.19.19", "vitest": "4.1.9" } } From 309b524f4fc10ad7005c8ff41d834203b8d37121 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 09:28:36 +0200 Subject: [PATCH 259/284] fix(coding-agent): avoid duplicate clipboard binaries --- .github/workflows/build-binaries.yml | 68 +++++++++++++++++++++++++++- packages/coding-agent/CHANGELOG.md | 1 + scripts/build-binaries.sh | 57 ++++++++++++----------- 3 files changed, 98 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 2fefa77a0d9..f007f26e125 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -128,9 +128,75 @@ jobs: if-no-files-found: error retention-days: 14 + smoke-test-binaries: + runs-on: ${{ matrix.runner }} + needs: build + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-latest + - macos-latest + - windows-latest + permissions: + actions: read + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Download binary archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-assets-${{ env.RELEASE_TAG }} + path: release-assets + + - name: Extract current-platform binary + id: binary + shell: bash + run: | + set -euo pipefail + + runtime_platform="$(node -p '`${process.platform}-${process.arch}`')" + case "${runtime_platform}" in + darwin-arm64|darwin-x64|linux-arm64|linux-x64) + platform="${runtime_platform}" + archive="release-assets/pi-${platform}.tar.gz" + root="extracted/pi" + binary="${root}/pi" + ;; + win32-arm64) + platform="windows-arm64" + archive="release-assets/pi-${platform}.zip" + root="extracted" + binary="${root}/pi.exe" + ;; + win32-x64) + platform="windows-x64" + archive="release-assets/pi-${platform}.zip" + root="extracted" + binary="${root}/pi.exe" + ;; + *) + echo "::error::Unsupported runner platform ${runtime_platform}" + exit 1 + ;; + esac + + mkdir -p extracted + tar -xf "${archive}" -C extracted + echo "root=${root}" >> "${GITHUB_OUTPUT}" + echo "binary=${binary}" >> "${GITHUB_OUTPUT}" + + - name: Smoke-test binary + shell: bash + run: | + "${{ steps.binary.outputs.binary }}" --help + "${{ steps.binary.outputs.binary }}" --version + stage-github-release: runs-on: ubuntu-latest - needs: build + needs: + - build + - smoke-test-binaries permissions: actions: read contents: write diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d4238c6c341..747f5f97d8c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Changed Bun release archives to ship the native clipboard binary only inside the wrapper package, removing a duplicate platform package from each archive. - Changed package resource glob expansion to use Node.js's built-in implementation with deterministic visible-path matching, reducing the installed runtime dependency tree. - Changed the bundled Node.js runtime to load jiti's Babel transform only when an extension needs it, reducing CLI startup time and bundle size. - Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time. diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index c2901978299..bd13cf0f7ee 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -159,6 +159,35 @@ else PLATFORMS=(darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64 windows-arm64) fi +set_clipboard_target() { + case "$1" in + darwin-arm64) + clipboard_native_package="clipboard-darwin-arm64" + clipboard_native_file="clipboard.darwin-arm64.node" + ;; + darwin-x64) + clipboard_native_package="clipboard-darwin-x64" + clipboard_native_file="clipboard.darwin-x64.node" + ;; + linux-x64) + clipboard_native_package="clipboard-linux-x64-gnu" + clipboard_native_file="clipboard.linux-x64-gnu.node" + ;; + linux-arm64) + clipboard_native_package="clipboard-linux-arm64-gnu" + clipboard_native_file="clipboard.linux-arm64-gnu.node" + ;; + windows-x64) + clipboard_native_package="clipboard-win32-x64-msvc" + clipboard_native_file="clipboard.win32-x64-msvc.node" + ;; + windows-arm64) + clipboard_native_package="clipboard-win32-arm64-msvc" + clipboard_native_file="clipboard.win32-arm64-msvc.node" + ;; + esac +} + for platform in "${PLATFORMS[@]}"; do echo "Building for $platform..." bun_target="bun-$platform" @@ -195,35 +224,9 @@ for platform in "${PLATFORMS[@]}"; do cp -r docs "$OUTPUT_DIR/$platform/" cp -r examples "$OUTPUT_DIR/$platform/" - case "$platform" in - darwin-arm64) - clipboard_native_package="clipboard-darwin-arm64" - clipboard_native_file="clipboard.darwin-arm64.node" - ;; - darwin-x64) - clipboard_native_package="clipboard-darwin-x64" - clipboard_native_file="clipboard.darwin-x64.node" - ;; - linux-x64) - clipboard_native_package="clipboard-linux-x64-gnu" - clipboard_native_file="clipboard.linux-x64-gnu.node" - ;; - linux-arm64) - clipboard_native_package="clipboard-linux-arm64-gnu" - clipboard_native_file="clipboard.linux-arm64-gnu.node" - ;; - windows-x64) - clipboard_native_package="clipboard-win32-x64-msvc" - clipboard_native_file="clipboard.win32-x64-msvc.node" - ;; - windows-arm64) - clipboard_native_package="clipboard-win32-arm64-msvc" - clipboard_native_file="clipboard.win32-arm64-msvc.node" - ;; - esac + set_clipboard_target "$platform" mkdir -p "$OUTPUT_DIR/$platform/node_modules/@mariozechner" cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" - cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" cp "../../node_modules/@mariozechner/$clipboard_native_package/$clipboard_native_file" \ "$OUTPUT_DIR/$platform/node_modules/@mariozechner/clipboard/" From c1279a65b3ef6b0b19950ed1771d5933241c240f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 10:36:34 +0200 Subject: [PATCH 260/284] feat(coding-agent): defer jiti until extension loading --- packages/coding-agent/CHANGELOG.md | 2 +- scripts/build-coding-agent-bundle.mjs | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 747f5f97d8c..acb07a6b368 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,7 +10,7 @@ - Changed Bun release archives to ship the native clipboard binary only inside the wrapper package, removing a duplicate platform package from each archive. - Changed package resource glob expansion to use Node.js's built-in implementation with deterministic visible-path matching, reducing the installed runtime dependency tree. -- Changed the bundled Node.js runtime to load jiti's Babel transform only when an extension needs it, reducing CLI startup time and bundle size. +- Changed the bundled Node.js runtime to load jiti only when importing an extension and Babel only when uncached source needs transformation, reducing CLI startup time and bundle size. - Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time. - Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. - Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL. diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index d683441ae0c..1b6d233eb81 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -28,7 +28,24 @@ const allowedExternalPackages = new Set([ const lazyJitiPlugin = { name: "lazy-jiti-transform", setup(build) { - build.onResolve({ filter: /^jiti\/static$/ }, () => ({ external: true, path: "jiti" })); + build.onResolve({ filter: /^jiti\/static$/ }, () => ({ + namespace: "lazy-jiti", + path: "jiti/static", + })); + build.onLoad({ filter: /.*/, namespace: "lazy-jiti" }, () => ({ + contents: ` +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +let createJitiImpl; + +export function createJiti(...args) { + createJitiImpl ??= require("jiti").createJiti; + return createJitiImpl(...args); +} +`, + loader: "js", + })); }, }; @@ -47,8 +64,9 @@ function commonBuildOptions() { minifyWhitespace: true, platform: "node", // The source uses jiti/static so Bun embeds its Babel transform. The Node - // package can use regular jiti from its direct dependency and load Babel - // only when an extension actually needs transformation. + // package replaces it with a synchronous lazy require so jiti loads only + // when importing an extension; Babel remains deferred until a cache miss + // needs transformation. plugins: [lazyJitiPlugin], sourcemap: false, target: "node22.19", From bcad846f938e8f3afcfb6881f1e5a09bd731907f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 12:39:20 +0200 Subject: [PATCH 261/284] fix(coding-agent): update end-of-options CLI test --- .../coding-agent/test/experimental-cli-command.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index 6219e24d1df..70d1b9d92b4 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -92,11 +92,13 @@ describe("experimental CLI commands", () => { const result = experimentalCli.parse(["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"]); expect(result).toMatchObject({ ok: true, - command: { command: "pi", options: { fileArgs: ["prompt.md"] } }, + command: { + command: "pi", + options: { fileArgs: ["prompt.md"], messages: ["--listen", "unix:///tmp/pi.sock"] }, + }, }); if (!result.ok || result.command.command !== "pi") return; - expect(result.command.options.unknownFlags.get("unknown")).toBe(true); - expect(result.command.options.unknownFlags.get("listen")).toBe("unix:///tmp/pi.sock"); + expect(result.command.options.unknownFlags).toEqual(new Map([["unknown", true]])); }); test.each([ From a69bef789bc95abf0acee16f7b4660b70b650bb9 Mon Sep 17 00:00:00 2001 From: yaogangqiang Date: Sun, 23 Aug 2026 18:39:42 +0800 Subject: [PATCH 262/284] fix(coding-agent): discard failed extension factory state (#8424) --- .../src/core/extensions/loader.ts | 146 ++++++++++++------ .../8423-extension-factory-failure.test.ts | 88 +++++++++++ 2 files changed, 190 insertions(+), 44 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/8423-extension-factory-failure.test.ts diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index d560e67c42d..36df2da2161 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -256,18 +256,38 @@ function createExtensionAPI( runtime: ExtensionRuntime, cwd: string, eventBus: EventBus, -): ExtensionAPI { +): { api: ExtensionAPI; commit: () => void; discard: () => void } { + const pendingFlagValues = new Map(); + const pendingRuntimeChanges: Array<() => void> = []; + const loadingUnsubscribers: Array<() => void> = []; + let state: "loading" | "active" | "failed" = "loading"; + const assertActive = () => { + if (state === "failed") { + throw new Error(`Extension "${extension.path}" failed to load and its API is no longer active.`); + } + runtime.assertActive(); + }; + const applyRuntimeChange = (change: () => void) => { + if (state === "loading") pendingRuntimeChanges.push(change); + else change(); + }; + const clearPending = () => { + pendingFlagValues.clear(); + pendingRuntimeChanges.length = 0; + loadingUnsubscribers.length = 0; + }; + const api = { // Registration methods - write to extension on(event: string, handler: HandlerFn): void { - runtime.assertActive(); + assertActive(); const list = extension.handlers.get(event) ?? []; list.push(handler); extension.handlers.set(event, list); }, registerTool(tool: ToolDefinition): void { - runtime.assertActive(); + assertActive(); extension.tools.set(tool.name, { definition: tool, sourceInfo: extension.sourceInfo, @@ -276,7 +296,7 @@ function createExtensionAPI( }, registerCommand(name: string, options: Omit): void { - runtime.assertActive(); + assertActive(); extension.commands.set(name, { name, sourceInfo: extension.sourceInfo, @@ -291,7 +311,7 @@ function createExtensionAPI( handler: (ctx: import("./types.ts").ExtensionContext) => Promise | void; }, ): void { - runtime.assertActive(); + assertActive(); extension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options }); }, @@ -299,7 +319,7 @@ function createExtensionAPI( name: string, options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, ): void { - runtime.assertActive(); + assertActive(); if (options.default !== undefined && typeof options.default !== options.type) { throw new Error( `Invalid default for flag "${name}": expected ${options.type}, got ${typeof options.default}`, @@ -307,132 +327,156 @@ function createExtensionAPI( } extension.flags.set(name, { name, extensionPath: extension.path, ...options }); if (options.default !== undefined && !runtime.flagValues.has(name)) { - runtime.flagValues.set(name, options.default); + if (state === "loading") { + if (!pendingFlagValues.has(name)) pendingFlagValues.set(name, options.default); + } else { + runtime.flagValues.set(name, options.default); + } } }, registerMessageRenderer(customType: string, renderer: MessageRenderer): void { - runtime.assertActive(); + assertActive(); extension.messageRenderers.set(customType, renderer as MessageRenderer); }, registerMarkdownTransformer(transformer: MarkdownTransformer): void { - runtime.assertActive(); + assertActive(); extension.markdownTransformer = transformer; }, registerEntryRenderer(customType: string, renderer: EntryRenderer): void { - runtime.assertActive(); + assertActive(); extension.entryRenderers ??= new Map(); extension.entryRenderers.set(customType, renderer as EntryRenderer); }, // Flag access - checks extension registered it, reads from runtime getFlag(name: string): boolean | string | undefined { - runtime.assertActive(); + assertActive(); if (!extension.flags.has(name)) return undefined; - return runtime.flagValues.get(name); + return runtime.flagValues.has(name) ? runtime.flagValues.get(name) : pendingFlagValues.get(name); }, // Action methods - delegate to shared runtime sendMessage(message, options): void { - runtime.assertActive(); + assertActive(); runtime.sendMessage(message, options); }, sendUserMessage(content, options): void { - runtime.assertActive(); + assertActive(); runtime.sendUserMessage(content, options); }, appendEntry(customType: string, data?: unknown): void { - runtime.assertActive(); + assertActive(); runtime.appendEntry(customType, data); }, setSessionName(name: string): void { - runtime.assertActive(); + assertActive(); runtime.setSessionName(name); }, getSessionName(): string | undefined { - runtime.assertActive(); + assertActive(); return runtime.getSessionName(); }, setLabel(entryId: string, label: string | undefined): void { - runtime.assertActive(); + assertActive(); runtime.setLabel(entryId, label); }, exec(command: string, args: string[], options?: ExecOptions) { - runtime.assertActive(); + assertActive(); return execCommand(command, args, options?.cwd ?? cwd, options); }, getActiveTools(): string[] { - runtime.assertActive(); + assertActive(); return runtime.getActiveTools(); }, getAllTools() { - runtime.assertActive(); + assertActive(); return runtime.getAllTools(); }, setActiveTools(toolNames: string[]): void { - runtime.assertActive(); + assertActive(); runtime.setActiveTools(toolNames); }, getCommands() { - runtime.assertActive(); + assertActive(); return runtime.getCommands(); }, setModel(model) { - runtime.assertActive(); + assertActive(); return runtime.setModel(model); }, getThinkingLevel() { - runtime.assertActive(); + assertActive(); return runtime.getThinkingLevel(); }, setThinkingLevel(level) { - runtime.assertActive(); + assertActive(); runtime.setThinkingLevel(level); }, registerProvider(providerOrName: Provider | string, config?: ProviderConfig) { - runtime.assertActive(); + assertActive(); if (typeof providerOrName === "string") { if (!config) throw new Error("Provider config is required when registering by name"); - runtime.registerProvider(providerOrName, config, extension.path); + applyRuntimeChange(() => runtime.registerProvider(providerOrName, config, extension.path)); return; } - runtime.registerNativeProvider(providerOrName, extension.path); + applyRuntimeChange(() => runtime.registerNativeProvider(providerOrName, extension.path)); }, unregisterProvider(name: string) { - runtime.assertActive(); - runtime.unregisterProvider(name, extension.path); + assertActive(); + applyRuntimeChange(() => runtime.unregisterProvider(name, extension.path)); }, events: { emit(channel, data) { - runtime.assertActive(); + assertActive(); eventBus.emit(channel, data); }, on(channel, handler) { - runtime.assertActive(); - return runtime.trackEventBusSubscription(eventBus.on(channel, handler)); + assertActive(); + const unsubscribe = runtime.trackEventBusSubscription(eventBus.on(channel, handler)); + if (state === "loading") loadingUnsubscribers.push(unsubscribe); + return unsubscribe; }, }, } as ExtensionAPI; - return api; + return { + api, + commit: () => { + if (state !== "loading") return; + runtime.assertActive(); + for (const [name, value] of pendingFlagValues) { + if (!runtime.flagValues.has(name)) runtime.flagValues.set(name, value); + } + for (const apply of pendingRuntimeChanges) apply(); + state = "active"; + clearPending(); + }, + discard: () => { + if (state !== "loading") return; + state = "failed"; + for (const unsubscribe of loadingUnsubscribers) unsubscribe(); + clearPending(); + }, + }; } function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken { @@ -498,6 +542,27 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension }; } +async function initializeExtension( + factory: ExtensionFactory, + extensionPath: string, + resolvedPath: string, + cwd: string, + eventBus: EventBus, + runtime: ExtensionRuntime, +): Promise { + const extension = createExtension(extensionPath, resolvedPath); + const load = createExtensionAPI(extension, runtime, cwd, eventBus); + try { + await factory(load.api); + load.commit(); + } catch (error) { + load.discard(); + throw error; + } + time(`${extensionPath} factory`, "extensions"); + return extension; +} + async function loadExtension( extensionPath: string, cwd: string, @@ -514,10 +579,7 @@ async function loadExtension( return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; } - const extension = createExtension(extensionPath, resolvedPath); - const api = createExtensionAPI(extension, runtime, cwd, eventBus); - await factory(api); - time(`${extensionPath} factory`, "extensions"); + const extension = await initializeExtension(factory, extensionPath, resolvedPath, cwd, eventBus, runtime); return { extension, error: null }; } catch (err) { @@ -536,12 +598,8 @@ export async function loadExtensionFromFactory( runtime: ExtensionRuntime, extensionPath = "", ): Promise { - const extension = createExtension(extensionPath, extensionPath); const resolvedCwd = resolvePath(cwd); - const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus); - await factory(api); - time(`${extensionPath} factory`, "extensions"); - return extension; + return initializeExtension(factory, extensionPath, extensionPath, resolvedCwd, eventBus, runtime); } /** diff --git a/packages/coding-agent/test/suite/regressions/8423-extension-factory-failure.test.ts b/packages/coding-agent/test/suite/regressions/8423-extension-factory-failure.test.ts new file mode 100644 index 00000000000..e6511cbb69e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8423-extension-factory-failure.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { createEventBus } from "../../../src/core/event-bus.ts"; +import { createExtensionRuntime, loadExtensionFromFactory } from "../../../src/core/extensions/loader.ts"; +import type { ExtensionAPI, ProviderConfig } from "../../../src/core/extensions/types.ts"; + +const providerConfig = { + baseUrl: "https://provider.test/v1", + apiKey: "provider-test-key", +} satisfies ProviderConfig; + +describe("issue #8423 extension factory failure", () => { + it("discards runtime changes and disables the failed API", async () => { + const runtime = createExtensionRuntime(); + const eventBus = createEventBus(); + let capturedApi: ExtensionAPI | undefined; + let eventCalls = 0; + let flagDuringLoad: boolean | string | undefined; + + await loadExtensionFromFactory( + (pi) => pi.registerProvider("working-provider", providerConfig), + process.cwd(), + eventBus, + runtime, + "", + ); + await expect( + loadExtensionFromFactory( + (pi) => { + capturedApi = pi; + pi.events.on("factory-failure", () => { + eventCalls++; + }); + pi.registerFlag("failed-flag", { type: "boolean", default: true }); + flagDuringLoad = pi.getFlag("failed-flag"); + pi.unregisterProvider("working-provider"); + pi.registerProvider("failed-provider", providerConfig); + throw new Error("factory failed"); + }, + process.cwd(), + eventBus, + runtime, + "", + ), + ).rejects.toThrow("factory failed"); + + eventBus.emit("factory-failure", undefined); + expect(flagDuringLoad).toBe(true); + expect(runtime.flagValues.has("failed-flag")).toBe(false); + expect(runtime.pendingProviderRegistrations.map(({ name }) => name)).toEqual(["working-provider"]); + expect(eventCalls).toBe(0); + expect(capturedApi).toBeDefined(); + expect(() => capturedApi?.registerFlag("late-flag", { type: "boolean", default: true })).toThrow( + 'Extension "" failed to load and its API is no longer active.', + ); + }); + + it("does not discard a concurrently loaded factory's provider", async () => { + const runtime = createExtensionRuntime(); + const eventBus = createEventBus(); + let releaseFailure!: () => void; + const waitBeforeFailure = new Promise((resolve) => { + releaseFailure = resolve; + }); + const failingLoad = loadExtensionFromFactory( + async (pi) => { + pi.registerProvider("failed-provider", providerConfig); + await waitBeforeFailure; + throw new Error("factory failed"); + }, + process.cwd(), + eventBus, + runtime, + "", + ); + + await loadExtensionFromFactory( + (pi) => pi.registerProvider("working-provider", providerConfig), + process.cwd(), + eventBus, + runtime, + "", + ); + releaseFailure(); + + await expect(failingLoad).rejects.toThrow("factory failed"); + expect(runtime.pendingProviderRegistrations.map(({ name }) => name)).toEqual(["working-provider"]); + }); +}); From 460191cfcf27d60ff81fc0178812f4ff09e8df06 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 20:45:14 +0200 Subject: [PATCH 263/284] feat(coding-agent): include context in Radius session shares --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/core/agent-session.ts | 37 +-- .../coding-agent/src/core/session-export.ts | 42 ++++ .../src/modes/interactive/interactive-mode.ts | 189 +--------------- .../src/modes/interactive/session-share.ts | 210 ++++++++++++++++++ .../test/export-jsonl-share.test.ts | 122 ++++++++++ 6 files changed, 389 insertions(+), 213 deletions(-) create mode 100644 packages/coding-agent/src/core/session-export.ts create mode 100644 packages/coding-agent/src/modes/interactive/session-share.ts create mode 100644 packages/coding-agent/test/export-jsonl-share.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index acb07a6b368..1d87b92d176 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,7 +13,7 @@ - Changed the bundled Node.js runtime to load jiti only when importing an extension and Babel only when uncached source needs transformation, reducing CLI startup time and bundle size. - Changed syntax highlighting to initialize only twenty common languages eagerly and defer the remaining grammars until after the initial TUI render, reducing CLI startup time. - Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. -- Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL. +- Changed session sharing to render clickable terminal links, display only the canonical Radius artifact URL, and include the current system prompt and active tool definitions in Radius session shares. ### Fixed diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 17b5b5909e9..488f5e0aecf 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,7 +13,7 @@ * Modes use this class and add their own I/O layer on top. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { basename, dirname } from "node:path"; import type { Agent, @@ -48,7 +48,6 @@ import { } from "@earendil-works/pi-ai/compat"; import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; -import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; import { normalizeToolResultImages } from "../utils/tool-result-images.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; @@ -101,8 +100,9 @@ import { ModelRegistry } from "./model-registry.ts"; import type { ModelRuntime } from "./model-runtime.ts"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; +import { exportSessionToJsonl } from "./session-export.ts"; import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts"; -import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts"; +import { getLatestCompactionEntry } from "./session-manager.ts"; import type { SettingsManager } from "./settings-manager.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; @@ -3374,36 +3374,7 @@ export class AgentSession { * @returns The resolved output file path. */ exportToJsonl(outputPath?: string): string { - const filePath = resolvePath( - outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, - process.cwd(), - ); - const dir = dirname(filePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const header: SessionHeader = { - type: "session", - version: CURRENT_SESSION_VERSION, - id: this.sessionManager.getSessionId(), - timestamp: new Date().toISOString(), - cwd: this.sessionManager.getCwd(), - }; - - const branchEntries = this.sessionManager.getBranch(); - const lines = [JSON.stringify(header)]; - - // Re-chain parentIds to form a linear sequence - let prevId: string | null = null; - for (const entry of branchEntries) { - const linear = { ...entry, parentId: prevId }; - lines.push(JSON.stringify(linear)); - prevId = entry.id; - } - - writeFileSync(filePath, `${lines.join("\n")}\n`); - return filePath; + return exportSessionToJsonl(this.sessionManager, outputPath); } // ========================================================================= diff --git a/packages/coding-agent/src/core/session-export.ts b/packages/coding-agent/src/core/session-export.ts new file mode 100644 index 00000000000..3607242c373 --- /dev/null +++ b/packages/coding-agent/src/core/session-export.ts @@ -0,0 +1,42 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { resolvePath } from "../utils/paths.ts"; +import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionManager } from "./session-manager.ts"; + +/** Write the current session branch and optional trailing export-only entries as JSONL. */ +export function exportSessionToJsonl( + sessionManager: SessionManager, + outputPath?: string, + createTrailingEntries?: (parentId: string | null, timestamp: string) => readonly object[], +): string { + const filePath = resolvePath( + outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, + process.cwd(), + ); + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const timestamp = new Date().toISOString(); + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: sessionManager.getSessionId(), + timestamp, + cwd: sessionManager.getCwd(), + }; + const lines = [JSON.stringify(header)]; + + let parentId: string | null = null; + for (const entry of sessionManager.getBranch()) { + lines.push(JSON.stringify({ ...entry, parentId })); + parentId = entry.id; + } + for (const entry of createTrailingEntries?.(parentId, timestamp) ?? []) { + lines.push(JSON.stringify(entry)); + } + + writeFileSync(filePath, `${lines.join("\n")}\n`); + return filePath; +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 10dbb7935e7..acf96ae2f77 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -10,7 +10,6 @@ import * as path from "node:path"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat"; -import { DEFAULT_RADIUS_GATEWAY } from "@earendil-works/pi-ai/providers/radius-config"; import type { AutocompleteItem, AutocompleteProvider, @@ -45,8 +44,7 @@ import { visibleWidth, } from "@earendil-works/pi-tui"; import chalk from "chalk"; -import { spawn, spawnSync } from "child_process"; -import { getAuthCredential } from "../../cli/auth-command.ts"; +import { spawn } from "child_process"; import { APP_NAME, APP_TITLE, @@ -55,7 +53,6 @@ import { getAuthPath, getDebugLogPath, getDocsPath, - getShareViewerUrl, VERSION, } from "../../config.ts"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; @@ -117,7 +114,6 @@ import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version- import { ArminComponent } from "./components/armin.ts"; import { AssistantMessageComponent } from "./components/assistant-message.ts"; import { BashExecutionComponent } from "./components/bash-execution.ts"; -import { BorderedLoader } from "./components/bordered-loader.ts"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts"; import { CustomEditor } from "./components/custom-editor.ts"; @@ -160,6 +156,7 @@ import { UserMessageSelectorComponent } from "./components/user-message-selector import { editInExternalEditor } from "./external-editor.ts"; import { refreshModelCatalogs } from "./model-catalog-refresh.ts"; import { getModelSearchText } from "./model-search.ts"; +import { shareSession } from "./session-share.ts"; import { getAvailableThemes, getAvailableThemesWithPaths, @@ -6092,180 +6089,14 @@ export class InteractiveMode { } private async handleShareCommand(): Promise { - // Radius artifacts natively support JSONL sessions. Gist fallback keeps the legacy HTML upload. - const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); - let htmlFile = null; - - try { - try { - this.session.exportToJsonl(jsonlFile); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - if (await this.tryShareViaRadius(jsonlFile)) return; - - try { - const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); - if (authResult.status !== 0) { - this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); - return; - } - } catch { - this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); - return; - } - - try { - htmlFile = path.join(os.tmpdir(), "session.html"); - await this.session.exportToHtml(htmlFile, { themeName: theme.name }); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - await this.shareViaGist(htmlFile); - } finally { - for (const tmpFile of [jsonlFile, htmlFile]) { - try { - if (tmpFile !== null) { - fs.unlinkSync(tmpFile); - } - } catch { - // Ignore cleanup errors - } - } - } - } - - private async tryShareViaRadius(tmpFile: string): Promise { - const provider = this.session.modelRuntime.getProvider("radius"); - if (!provider) return false; - - const gatewayUrl = DEFAULT_RADIUS_GATEWAY; - - const token = getAuthCredential( - await this.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }), - ); - if (!token) return false; - - const loader = new BorderedLoader(this.ui, theme, "Uploading to Radius..."); - this.editorContainer.clear(); - this.editorContainer.addChild(loader); - this.ui.setFocus(loader); - this.ui.requestRender(); - loader.onAbort = () => { - this.restoreShareEditor(loader); - this.showStatus("Share cancelled"); - }; - - try { - const body = fs.readFileSync(tmpFile); - const url = new URL("/v1/artifacts", gatewayUrl); - url.searchParams.set("visibility", "organization"); - url.searchParams.set("title", "Pi session"); - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/x-ndjson", - "Content-Length": String(body.byteLength), - }, - body, - signal: loader.signal, - }); - if (loader.signal.aborted) return true; - const json = (await response.json().catch(() => null)) as { - artifact?: { canonical_url: string }; - error?: string; - } | null; - if (loader.signal.aborted) return true; - this.restoreShareEditor(loader); - if (!response.ok || !json?.artifact) { - this.showError( - `Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`, - ); - return true; - } - const shareUrl = json.artifact.canonical_url; - this.showStatus(`Share URL: ${hyperlink(shareUrl, shareUrl)}`); - return true; - } catch (error: unknown) { - if (!loader.signal.aborted) { - this.restoreShareEditor(loader); - this.showError( - `Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } - return true; - } - } - - private async shareViaGist(tmpFile: string): Promise { - // Show cancellable loader, replacing the editor - const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); - this.editorContainer.clear(); - this.editorContainer.addChild(loader); - this.ui.setFocus(loader); - this.ui.requestRender(); - - // Create a secret gist asynchronously - let proc: ReturnType | null = null; - - loader.onAbort = () => { - proc?.kill(); - this.restoreShareEditor(loader); - this.showStatus("Share cancelled"); - }; - - try { - const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { - proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - proc.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - proc.on("close", (code) => resolve({ stdout, stderr, code })); - }); - - if (loader.signal.aborted) return; - - this.restoreShareEditor(loader); - - if (result.code !== 0) { - const errorMsg = result.stderr?.trim() || "Unknown error"; - this.showError(`Failed to create gist: ${errorMsg}`); - return; - } - - // Extract gist ID from the URL returned by gh - // gh returns something like: https://gist.github.com/username/GIST_ID - const gistUrl = result.stdout?.trim(); - const gistId = gistUrl?.split("/").pop(); - if (!gistId) { - this.showError("Failed to parse gist ID from gh output"); - return; - } - - // Create the preview URL - const previewUrl = getShareViewerUrl(gistId); - this.showStatus(`Share URL: ${hyperlink(previewUrl, previewUrl)}\nGist: ${hyperlink(gistUrl, gistUrl)}`); - } catch (error: unknown) { - if (!loader.signal.aborted) { - this.restoreShareEditor(loader); - this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); - } - } - } - - private restoreShareEditor(loader: BorderedLoader): void { - loader.dispose(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); + await shareSession({ + session: this.session, + ui: this.ui, + editorContainer: this.editorContainer, + editor: this.editor, + showStatus: (message) => this.showStatus(message), + showError: (message) => this.showError(message), + }); } private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise { diff --git a/packages/coding-agent/src/modes/interactive/session-share.ts b/packages/coding-agent/src/modes/interactive/session-share.ts new file mode 100644 index 00000000000..f9c0000e25c --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/session-share.ts @@ -0,0 +1,210 @@ +import { spawn, spawnSync } from "node:child_process"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { DEFAULT_RADIUS_GATEWAY } from "@earendil-works/pi-ai/providers/radius-config"; +import { type Container, type EditorComponent, hyperlink, type TUI } from "@earendil-works/pi-tui"; +import { getAuthCredential } from "../../cli/auth-command.ts"; +import { getShareViewerUrl } from "../../config.ts"; +import type { AgentSession } from "../../core/agent-session.ts"; +import { exportSessionToJsonl } from "../../core/session-export.ts"; +import { BorderedLoader } from "./components/bordered-loader.ts"; +import { theme } from "./theme/theme.ts"; + +interface SessionShareContext { + session: AgentSession; + ui: TUI; + editorContainer: Container; + editor: EditorComponent; + showStatus: (message: string) => void; + showError: (message: string) => void; +} + +/** Export the current branch with presentation metadata for Radius. */ +export function exportSessionForShare(filePath: string, session: AgentSession): void { + exportSessionToJsonl(session.sessionManager, filePath, (parentId, timestamp) => [ + { + type: "custom", + customType: "pi.share", + id: crypto.randomUUID().slice(0, 8), + parentId, + timestamp, + data: { + systemPrompt: session.state.systemPrompt, + tools: session.state.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + })), + }, + }, + ]); +} + +/** Share the current session through Radius, falling back to a private gist. */ +export async function shareSession(context: SessionShareContext): Promise { + const jsonlFile = path.join(os.tmpdir(), "session.jsonl"); + let htmlFile: string | null = null; + + try { + try { + exportSessionForShare(jsonlFile, context.session); + } catch (error: unknown) { + context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + if (await tryShareViaRadius(jsonlFile, context)) return; + + try { + const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + if (authResult.status !== 0) { + context.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); + return; + } + } catch { + context.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); + return; + } + + try { + htmlFile = path.join(os.tmpdir(), "session.html"); + await context.session.exportToHtml(htmlFile, { themeName: theme.name }); + } catch (error: unknown) { + context.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + await shareViaGist(htmlFile, context); + } finally { + for (const tmpFile of [jsonlFile, htmlFile]) { + try { + if (tmpFile !== null) { + fs.unlinkSync(tmpFile); + } + } catch { + // Ignore cleanup errors + } + } + } +} + +async function tryShareViaRadius(tmpFile: string, context: SessionShareContext): Promise { + const provider = context.session.modelRuntime.getProvider("radius"); + if (!provider) return false; + + const token = getAuthCredential( + await context.session.modelRuntime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000 }), + ); + if (!token) return false; + + const loader = new BorderedLoader(context.ui, theme, "Uploading to Radius..."); + context.editorContainer.clear(); + context.editorContainer.addChild(loader); + context.ui.setFocus(loader); + context.ui.requestRender(); + loader.onAbort = () => { + restoreEditor(loader, context); + context.showStatus("Share cancelled"); + }; + + try { + const body = fs.readFileSync(tmpFile); + const url = new URL("/v1/artifacts", DEFAULT_RADIUS_GATEWAY); + url.searchParams.set("visibility", "organization"); + url.searchParams.set("title", "Pi session"); + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-ndjson", + "Content-Length": String(body.byteLength), + }, + body, + signal: loader.signal, + }); + if (loader.signal.aborted) return true; + const json = (await response.json().catch(() => null)) as { + artifact?: { canonical_url: string }; + error?: string; + } | null; + if (loader.signal.aborted) return true; + restoreEditor(loader, context); + if (!response.ok || !json?.artifact) { + context.showError( + `Failed to upload Radius artifact: ${json?.error || response.statusText || response.status}`, + ); + return true; + } + const shareUrl = json.artifact.canonical_url; + context.showStatus(`Share URL: ${hyperlink(shareUrl, shareUrl)}`); + return true; + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(loader, context); + context.showError( + `Failed to upload Radius artifact: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + return true; + } +} + +async function shareViaGist(tmpFile: string, context: SessionShareContext): Promise { + const loader = new BorderedLoader(context.ui, theme, "Creating gist..."); + context.editorContainer.clear(); + context.editorContainer.addChild(loader); + context.ui.setFocus(loader); + context.ui.requestRender(); + + let proc: ReturnType | null = null; + loader.onAbort = () => { + proc?.kill(); + restoreEditor(loader, context); + context.showStatus("Share cancelled"); + }; + + try { + const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + proc.on("close", (code) => resolve({ stdout, stderr, code })); + }); + + if (loader.signal.aborted) return; + restoreEditor(loader, context); + + if (result.code !== 0) { + context.showError(`Failed to create gist: ${result.stderr?.trim() || "Unknown error"}`); + return; + } + + const gistUrl = result.stdout?.trim(); + const gistId = gistUrl?.split("/").pop(); + if (!gistId) { + context.showError("Failed to parse gist ID from gh output"); + return; + } + + const previewUrl = getShareViewerUrl(gistId); + context.showStatus(`Share URL: ${hyperlink(previewUrl, previewUrl)}\nGist: ${hyperlink(gistUrl, gistUrl)}`); + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(loader, context); + context.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } +} + +function restoreEditor(loader: BorderedLoader, context: SessionShareContext): void { + loader.dispose(); + context.editorContainer.clear(); + context.editorContainer.addChild(context.editor); + context.ui.setFocus(context.editor); +} diff --git a/packages/coding-agent/test/export-jsonl-share.test.ts b/packages/coding-agent/test/export-jsonl-share.test.ts new file mode 100644 index 00000000000..5e668593144 --- /dev/null +++ b/packages/coding-agent/test/export-jsonl-share.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai/compat"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import { defineTool } from "../src/core/extensions/types.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { exportSessionForShare } from "../src/modes/interactive/session-share.ts"; +import { assistantMsg, userMsg } from "./utilities.ts"; + +describe("JSONL share export", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("adds presentation data without changing conversation IDs or links", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-jsonl-share-")); + tempDirs.push(tempDir); + const sessionManager = SessionManager.inMemory(tempDir); + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + model: getModel("anthropic", "claude-sonnet-4-5")!, + settingsManager: SettingsManager.inMemory(), + sessionManager, + tools: ["share_tool"], + customTools: [ + defineTool({ + name: "share_tool", + label: "Share Tool", + description: "Render a value for sharing", + parameters: Type.Object({ value: Type.String({ description: "Value to render" }) }), + execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }), + }), + ], + }); + + try { + const userId = sessionManager.appendMessage(userMsg("hello")); + const assistant: AssistantMessage = { + ...assistantMsg(""), + content: [{ type: "toolCall", id: "call-1", name: "share_tool", arguments: { value: "example" } }], + stopReason: "toolUse", + }; + const assistantId = sessionManager.appendMessage(assistant); + const result: ToolResultMessage = { + role: "toolResult", + toolCallId: "call-1", + toolName: "share_tool", + content: [{ type: "text", text: "done" }], + details: {}, + isError: false, + timestamp: Date.now(), + }; + const resultId = sessionManager.appendMessage(result); + const originalEntryIds = sessionManager.getBranch().map((entry) => entry.id); + + const normalPath = join(tempDir, "normal.jsonl"); + session.exportToJsonl(normalPath); + const normalRecords = readFileSync(normalPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(normalRecords.some((record) => record.type === "custom" && record.customType === "pi.share")).toBe( + false, + ); + + const sharePath = join(tempDir, "share.jsonl"); + exportSessionForShare(sharePath, session); + const records = readFileSync(sharePath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const conversationRecords = records.slice(1, -1); + expect(conversationRecords.map((record) => record.id)).toEqual(originalEntryIds); + expect(conversationRecords.map((record) => record.parentId)).toEqual([null, ...originalEntryIds.slice(0, -1)]); + expect(conversationRecords.slice(-3).map((record) => record.id)).toEqual([userId, assistantId, resultId]); + + const shareEntry = records.at(-1) as { + id: string; + data?: { + systemPrompt?: string; + tools?: Array>; + }; + }; + expect(shareEntry).toMatchObject({ + type: "custom", + customType: "pi.share", + parentId: resultId, + timestamp: expect.any(String), + }); + expect(shareEntry.data?.systemPrompt).toBe(session.state.systemPrompt); + expect(shareEntry.data?.tools).toEqual([ + expect.objectContaining({ + name: "share_tool", + description: "Render a value for sharing", + }), + ]); + expect(shareEntry.data).not.toHaveProperty("renderedTools"); + expect(shareEntry.data).not.toHaveProperty("theme"); + expect(shareEntry.data).not.toHaveProperty("version"); + + const imported = SessionManager.open(sharePath); + expect(imported.getLeafId()).toBe(shareEntry.id); + expect(imported.buildSessionContext().messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "toolResult", + ]); + } finally { + session.dispose(); + } + }); +}); From 27b7a626de0e36845feb0c21d6da77352dd4834a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 23 Aug 2026 23:30:16 +0200 Subject: [PATCH 264/284] fix(coding-agent): use Windows-friendly keybinding defaults closes #8372 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/keybindings.md | 16 +++---- packages/coding-agent/docs/terminal-setup.md | 20 +++++---- packages/coding-agent/src/core/keybindings.ts | 33 +++++++++++++-- .../coding-agent/test/keybindings.test.ts | 42 +++++++++++++++++++ packages/tui/src/keybindings.ts | 4 +- packages/tui/test/keybindings.test.ts | 4 +- 7 files changed, 95 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/keybindings.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1d87b92d176..138b4e27b14 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Changed Windows and WSL keybinding defaults to avoid terminal-reserved shortcuts for image paste, model cycling, editor undo, fullscreen transcript navigation and search, and message queueing ([#8372](https://github.com/earendil-works/pi/issues/8372)). - Changed Bun release archives to ship the native clipboard binary only inside the wrapper package, removing a duplicate platform package from each archive. - Changed package resource glob expansion to use Node.js's built-in implementation with deterministic visible-path matching, reducing the installed runtime dependency tree. - Changed the bundled Node.js runtime to load jiti only when importing an extension and Babel only when uncached source needs transformation, reducing CLI startup time and bundle size. diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 2f833dc513f..9da435172f9 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -70,7 +70,7 @@ The dedicated history actions always change history entries, regardless of the c |--------|---------|-------------| | `tui.editor.yank` | `ctrl+y` | Paste most recently deleted text | | `tui.editor.yankPop` | `alt+y` | Cycle through deleted text after yank | -| `tui.editor.undo` | `ctrl+-` | Undo last edit | +| `tui.editor.undo` | `ctrl+-` (`ctrl+z` on Windows; `alt+z` on WSL) | Undo last edit | ### TUI Clipboard and Selection @@ -107,9 +107,9 @@ This routing remains configurable through the ordinary action bindings. For exam | `tui.altScreen.halfPageDown` | *(none)* | Scroll the transcript down by half a page | | `tui.altScreen.lineUp` | *(none)* | Scroll the transcript up by one line | | `tui.altScreen.lineDown` | *(none)* | Scroll the transcript down by one line | -| `tui.altScreen.previousPrompt` | `ctrl+shift+up` | Jump to the previous marked message | -| `tui.altScreen.nextPrompt` | `ctrl+shift+down` | Jump to the next marked message | -| `tui.altScreen.search` | `ctrl+shift+f` | Search the rendered transcript | +| `tui.altScreen.previousPrompt` | `ctrl+shift+up`, `ctrl+up` (`ctrl+up` only on Windows and WSL) | Jump to the previous marked message | +| `tui.altScreen.nextPrompt` | `ctrl+shift+down`, `ctrl+down` (`ctrl+down` only on Windows and WSL) | Jump to the next marked message | +| `tui.altScreen.search` | `ctrl+shift+f` (`ctrl+f` on Windows and WSL) | Search the rendered transcript | | `tui.altScreen.searchNext` | `enter`, `ctrl+g` | Select the next search match while searching | | `tui.altScreen.searchPrevious` | `shift+enter`, `ctrl+shift+g` | Select the previous search match while searching | | `tui.altScreen.searchClose` | `escape` | Close transcript search | @@ -125,7 +125,7 @@ This routing remains configurable through the ordinary action bindings. For exam | `app.exit` | `ctrl+d` | Exit (when editor empty) | | `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background | | `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) | -| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image or text from clipboard | +| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows and WSL) | Paste image or text from clipboard | ### Sessions @@ -148,7 +148,7 @@ This routing remains configurable through the ordinary action bindings. For exam |--------|---------|-------------| | `app.model.select` | `ctrl+l` | Open model selector | | `app.model.cycleForward` | `ctrl+p` | Cycle to next model | -| `app.model.cycleBackward` | `shift+ctrl+p` | Cycle to previous model | +| `app.model.cycleBackward` | `shift+ctrl+p` (`alt+p` on Windows and WSL) | Cycle to previous model | | `app.thinking.cycle` | `shift+tab` | Cycle thinking level | | `app.thinking.toggle` | `ctrl+t` | Collapse or expand thinking blocks | @@ -158,8 +158,8 @@ This routing remains configurable through the ordinary action bindings. For exam |--------|---------|-------------| | `app.tools.expand` | `ctrl+o` | Collapse or expand tool output | | `app.message.copy` | `ctrl+x` | Copy the last assistant message, or the selected message in `/tree` | -| `app.message.followUp` | `alt+enter` | Queue follow-up message | -| `app.message.dequeue` | `alt+up` | Restore queued messages to editor | +| `app.message.followUp` | `alt+enter` (`ctrl+q` on Windows and WSL) | Queue follow-up message | +| `app.message.dequeue` | `alt+up` (`alt+q` on Windows and WSL) | Restore queued messages to editor | ### Tree Navigation diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 08839256847..5db077828ca 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -120,7 +120,15 @@ Add to `keybindings.json`: ## Windows Terminal -Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward the modified Enter keys pi uses: +Pi uses Windows-style keybindings when running natively on Windows or in WSL: + +- `Alt+V` pastes an image or clipboard text. +- `Ctrl+F` searches the transcript in fullscreen mode, and `Ctrl+Up`/`Ctrl+Down` jump between marked messages. +- `Alt+P` cycles to the previous model. +- `Ctrl+Z` undoes editing on native Windows; WSL uses `Alt+Z` so `Ctrl+Z` can suspend pi. +- `Ctrl+Q` queues a follow-up message and `Alt+Q` restores queued messages. + +Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward `Shift+Enter` for inserting a new line: ```json { @@ -128,20 +136,14 @@ Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward { "command": { "action": "sendInput", "input": "\u001b[13;2u" }, "keys": "shift+enter" - }, - { - "command": { "action": "sendInput", "input": "\u001b[13;3u" }, - "keys": "alt+enter" } ] } ``` -- `Shift+Enter` inserts a new line. -- Windows Terminal binds `Alt+Enter` to fullscreen by default. That prevents pi from receiving `Alt+Enter` for follow-up queueing. -- Remapping `Alt+Enter` to `sendInput` forwards the real key chord to pi instead. +Windows Terminal binds `Alt+Enter` to fullscreen by default. To use it instead of pi's `Ctrl+Q` default for follow-up queueing, configure Windows Terminal to send the key and bind `app.message.followUp` to `alt+enter` in pi. -If you already have an `actions` array, add the objects to it. If the old fullscreen behavior persists, fully close and reopen Windows Terminal. +If you already have an `actions` array, add the object to it. Fully close and reopen Windows Terminal after changing its settings. ## xfce4-terminal, terminator diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 0524b2a567e..4022bdacef5 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -58,12 +58,37 @@ export interface AppKeybindings { export type AppKeybinding = keyof AppKeybindings; +export function useWindowsKeybindings( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return platform === "win32" || (platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)); +} + declare module "@earendil-works/pi-tui" { interface Keybindings extends AppKeybindings {} } +const windowsKeybindings = useWindowsKeybindings(); + export const KEYBINDINGS = { ...TUI_KEYBINDINGS, + "tui.editor.undo": { + ...TUI_KEYBINDINGS["tui.editor.undo"], + defaultKeys: process.platform === "win32" ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-", + }, + "tui.altScreen.previousPrompt": { + ...TUI_KEYBINDINGS["tui.altScreen.previousPrompt"], + defaultKeys: windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"], + }, + "tui.altScreen.nextPrompt": { + ...TUI_KEYBINDINGS["tui.altScreen.nextPrompt"], + defaultKeys: windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"], + }, + "tui.altScreen.search": { + ...TUI_KEYBINDINGS["tui.altScreen.search"], + defaultKeys: windowsKeybindings ? "ctrl+f" : "ctrl+shift+f", + }, "app.interrupt": { defaultKeys: "escape", description: "Cancel or abort" }, "app.clear": { defaultKeys: "ctrl+c", description: "Clear editor" }, "app.exit": { defaultKeys: "ctrl+d", description: "Exit when editor is empty" }, @@ -80,7 +105,7 @@ export const KEYBINDINGS = { description: "Cycle to next model", }, "app.model.cycleBackward": { - defaultKeys: "shift+ctrl+p", + defaultKeys: windowsKeybindings ? "alt+p" : "shift+ctrl+p", description: "Cycle to previous model", }, "app.model.select": { defaultKeys: "ctrl+l", description: "Open model selector" }, @@ -102,15 +127,15 @@ export const KEYBINDINGS = { description: "Copy message to clipboard", }, "app.message.followUp": { - defaultKeys: "alt+enter", + defaultKeys: windowsKeybindings ? "ctrl+q" : "alt+enter", description: "Queue follow-up message", }, "app.message.dequeue": { - defaultKeys: "alt+up", + defaultKeys: windowsKeybindings ? "alt+q" : "alt+up", description: "Restore queued messages", }, "app.clipboard.pasteImage": { - defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", + defaultKeys: windowsKeybindings ? "alt+v" : "ctrl+v", description: "Paste image from clipboard (text fallback)", }, "app.session.new": { defaultKeys: [], description: "Start a new session" }, diff --git a/packages/coding-agent/test/keybindings.test.ts b/packages/coding-agent/test/keybindings.test.ts new file mode 100644 index 00000000000..cb505eede7e --- /dev/null +++ b/packages/coding-agent/test/keybindings.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { KEYBINDINGS, useWindowsKeybindings } from "../src/core/keybindings.ts"; + +describe("Windows keybinding defaults", () => { + it("uses Windows keybindings on native Windows", () => { + expect(useWindowsKeybindings("win32", {})).toBe(true); + }); + + it("uses Windows keybindings in WSL without relying on Windows Terminal detection", () => { + expect(useWindowsKeybindings("linux", { WSL_DISTRO_NAME: "Ubuntu" })).toBe(true); + expect(useWindowsKeybindings("linux", { WSL_INTEROP: "/run/WSL/123_interop" })).toBe(true); + }); + + it("does not use Windows keybindings from WT_SESSION alone", () => { + expect(useWindowsKeybindings("linux", { WT_SESSION: "session" })).toBe(false); + }); + + it("keeps non-Windows defaults on other platforms", () => { + expect(useWindowsKeybindings("linux", {})).toBe(false); + expect(useWindowsKeybindings("darwin", {})).toBe(false); + }); + + it("applies the detected defaults consistently", () => { + const windowsKeybindings = useWindowsKeybindings(); + const nativeWindows = process.platform === "win32"; + + expect(KEYBINDINGS["app.clipboard.pasteImage"].defaultKeys).toBe(windowsKeybindings ? "alt+v" : "ctrl+v"); + expect(KEYBINDINGS["tui.altScreen.search"].defaultKeys).toBe(windowsKeybindings ? "ctrl+f" : "ctrl+shift+f"); + expect(KEYBINDINGS["app.message.followUp"].defaultKeys).toBe(windowsKeybindings ? "ctrl+q" : "alt+enter"); + expect(KEYBINDINGS["app.model.cycleBackward"].defaultKeys).toBe(windowsKeybindings ? "alt+p" : "shift+ctrl+p"); + expect(KEYBINDINGS["tui.editor.undo"].defaultKeys).toBe( + nativeWindows ? "ctrl+z" : windowsKeybindings ? "alt+z" : "ctrl+-", + ); + expect(KEYBINDINGS["tui.altScreen.previousPrompt"].defaultKeys).toEqual( + windowsKeybindings ? "ctrl+up" : ["ctrl+shift+up", "ctrl+up"], + ); + expect(KEYBINDINGS["tui.altScreen.nextPrompt"].defaultKeys).toEqual( + windowsKeybindings ? "ctrl+down" : ["ctrl+shift+down", "ctrl+down"], + ); + expect(KEYBINDINGS["app.message.dequeue"].defaultKeys).toBe(windowsKeybindings ? "alt+q" : "alt+up"); + }); +}); diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index c1afc5df73e..d6afb5ec396 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -182,11 +182,11 @@ export const TUI_KEYBINDINGS = { description: "Scroll viewport down one line", }, "tui.altScreen.previousPrompt": { - defaultKeys: "ctrl+shift+up", + defaultKeys: ["ctrl+shift+up", "ctrl+up"], description: "Jump to previous semantic prompt", }, "tui.altScreen.nextPrompt": { - defaultKeys: "ctrl+shift+down", + defaultKeys: ["ctrl+shift+down", "ctrl+down"], description: "Jump to next semantic prompt", }, "tui.altScreen.search": { diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index 9a4ecb8817e..b0c827dde18 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -36,8 +36,8 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineUp"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineDown"), []); - assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); - assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up", "ctrl+up"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down", "ctrl+down"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.search"), ["ctrl+shift+f"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchNext"), ["enter", "ctrl+g"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]); From 81152d88bb4cc25364f34cbd2d480f4a09529742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Sun, 23 Aug 2026 23:34:08 +0200 Subject: [PATCH 265/284] docs(coding-agent): clarify custom footer usage APIs (#8482) Fixes #8392 --- packages/coding-agent/src/core/extensions/types.ts | 4 ++-- packages/coding-agent/src/core/footer-data-provider.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 9008d621240..9303c0bc4e4 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -177,8 +177,8 @@ export interface ExtensionUIContext { /** Set a custom footer component, or undefined to restore the built-in footer. * * The factory receives a FooterDataProvider for data not otherwise accessible: - * git branch and extension statuses from setStatus(). Token stats, model info, - * etc. are available via ctx.sessionManager and ctx.model. + * git branch and extension statuses from setStatus(). Context usage is on + * ctx.getContextUsage(), token stats on ctx.sessionManager.getEntries(), model info on ctx.model. */ setFooter( factory: diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index edee25caa2a..3119d63d35a 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -94,7 +94,7 @@ function shouldPollGitHead(repoDir: string): boolean { /** * Provides git branch and extension statuses - data not otherwise accessible to extensions. - * Token stats, model info available via ctx.sessionManager and ctx.model. + * Context usage on ctx.getContextUsage(), token stats on ctx.sessionManager.getEntries(), model info on ctx.model. */ export class FooterDataProvider { private cwd: string; From a470b121bf683b4c2b9fc0b3a7c807de7e0cfe9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Sun, 23 Aug 2026 23:34:30 +0200 Subject: [PATCH 266/284] fix(coding-agent): expose finish reason compatibility override (#8487) Closes #8460 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/model-config.ts | 1 + .../coding-agent/test/model-registry.test.ts | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 138b4e27b14..5394de918c5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixed +- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8460](https://github.com/earendil-works/pi/issues/8460)). - Fixed writes to `auth.json` and `models-store.json` overriding administrator-managed file permissions and ACLs ([#7779](https://github.com/earendil-works/pi/issues/7779)). - Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). - Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index f24a2793707..6036d5af939 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -75,6 +75,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ supportsDeveloperRole: Type.Optional(Type.Boolean()), supportsReasoningEffort: Type.Optional(Type.Boolean()), supportsUsageInStreaming: Type.Optional(Type.Boolean()), + supportsFinishReason: Type.Optional(Type.Boolean()), maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), requiresToolResultName: Type.Optional(Type.Boolean()), requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index dae695c337c..18bf921b337 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -11,6 +11,7 @@ import type { import { getApiProvider, getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; +import type { ModelsJsonProvider } from "../src/core/model-config.ts"; import { clearApiKeyCache, type ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.ts"; import { createModelRegistry } from "./model-runtime-test-utils.ts"; @@ -767,6 +768,26 @@ describe("ModelRegistry", () => { expect(compat?.openRouterRouting).toEqual({ only: ["amazon-bedrock"] }); }); + test("supportsFinishReason can be configured at provider and model levels", async () => { + const provider: ModelsJsonProvider = { + compat: { supportsFinishReason: true }, + modelOverrides: { + "anthropic/claude-sonnet-4": { + compat: { supportsFinishReason: false }, + }, + }, + }; + writeRawModelsJson({ openrouter: provider }); + + const registry = await createModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + const sonnet = models.find((model) => model.id === "anthropic/claude-sonnet-4"); + const opus = models.find((model) => model.id === "anthropic/claude-opus-4"); + + expect((sonnet?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(false); + expect((opus?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(true); + }); + test("model override deep merges compat settings", async () => { writeRawModelsJson({ openrouter: { From 4af9d21d3b4d664e4a29fcabfec85171077248e3 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 09:34:34 +0200 Subject: [PATCH 267/284] feat(coding-agent): update managed installations in place --- packages/coding-agent/docs/packages.md | 2 +- packages/coding-agent/src/main.ts | 3 +- .../coding-agent/src/package-manager-cli.ts | 217 +++++++++++++++++- .../test/package-command-paths.test.ts | 180 ++++++++++++++- scripts/publish-release-announcement.mjs | 108 +++++---- 5 files changed, 449 insertions(+), 61 deletions(-) diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 3f141c5141e..cadeb18673a 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -38,7 +38,7 @@ pi update npm:@foo/bar # update one package pi update --extension npm:@foo/bar ``` -These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). +These commands manage pi packages and `pi update` can update the pi CLI installation. For experimental installer-managed installations, `pi update` installs the exact checked version into a staged, lockfile-backed release and activates it only after verification, leaving the current release intact if the update fails. Managed installations do not support `--force`; rerun the installer to repair one. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted. diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index ac8b8cf718e..23b4d42e2e3 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -64,7 +64,7 @@ import { builtInExtensions } from "./extensions/index.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; -import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; +import { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; @@ -573,6 +573,7 @@ export async function main(args: string[], options?: MainOptions) { if (process.platform === "win32") { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } + cleanupManagedInstall(); const cwd = process.cwd(); const agentDir = getAgentDir(); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 0a05f315936..53239daf852 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,6 +1,17 @@ -import { join } from "node:path"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; +import lockfile from "proper-lockfile"; import { selectConfig } from "./cli/config-selector.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { @@ -23,7 +34,9 @@ import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; -import { spawnProcess } from "./utils/child-process.ts"; +import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.ts"; +import { canonicalizePath, getCwdRelativePath } from "./utils/paths.ts"; +import { getPiUserAgent } from "./utils/pi-user-agent.ts"; import { formatVersionCheckError, getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { cleanupWindowsSelfUpdateQuarantine, @@ -34,6 +47,179 @@ export type PackageCommand = "install" | "remove" | "update" | "list"; type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string } | { type: "models" }; +const DEFAULT_INSTALLER_API_BASE = "https://pi.dev/api/installer/releases"; +const MANAGED_INSTALL_MARKER = "managed-install.json"; +const MANAGED_RELEASE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function getActiveManagedInstallRoot(): string | undefined { + const configuredRoot = process.env.PI_MANAGED_INSTALL_ROOT?.trim(); + if (!configuredRoot) return undefined; + + const managedRoot = resolve(configuredRoot); + const releasesDir = canonicalizePath(join(managedRoot, "releases")); + // The launcher environment is inherited by child processes. Do not classify a + // source checkout or another Pi installation launched from managed Pi as managed. + if (getCwdRelativePath(canonicalizePath(getPackageDir()), releasesDir) === undefined) return undefined; + + const markerPath = join(managedRoot, MANAGED_INSTALL_MARKER); + try { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as { + kind?: unknown; + layout?: unknown; + schemaVersion?: unknown; + }; + if (marker.kind !== "pi-managed-install" || marker.schemaVersion !== 1 || marker.layout !== "releases-v1") { + throw new Error(); + } + } catch { + throw new Error(`Managed install marker is missing or invalid: ${markerPath}`); + } + + return managedRoot; +} + +async function fetchInstallerArtifact(url: string, label: string): Promise { + const response = await fetch(url, { headers: { "User-Agent": getPiUserAgent(VERSION) } }); + if (!response.ok) { + throw new Error(`Could not download managed installer ${label} from ${url}: HTTP ${response.status}`); + } + return await response.text(); +} + +async function runManagedNpmCi(stageDir: string): Promise { + const args = [ + "ci", + "--ignore-scripts", + "--min-release-age=0", + "--omit=dev", + "--include=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + "--progress=false", + ]; + const code = await waitForChildProcess(spawnProcess("npm", args, { cwd: stageDir, stdio: "inherit" })); + if (code !== 0) throw new Error(`npm ${args.join(" ")} exited with code ${code ?? "unknown"}`); +} + +function verifyManagedRelease(releaseDir: string, expectedVersion: string): void { + const binPath = join( + releaseDir, + "node_modules", + ".bin", + process.platform === "win32" ? `${APP_NAME}.cmd` : APP_NAME, + ); + const result = spawnProcessSync(binPath, ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0) { + const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; + throw new Error(`Could not verify managed Pi ${expectedVersion}: ${reason}`); + } + const installedVersion = result.stdout.trim(); + if (installedVersion !== expectedVersion) { + throw new Error(`Managed Pi smoke test returned version ${installedVersion}; expected ${expectedVersion}.`); + } +} + +function activateManagedRelease(managedRoot: string, version: string): void { + const currentPath = join(managedRoot, "current-version"); + const temporaryPath = join(managedRoot, `current-version.tmp.${process.pid}-${Date.now()}`); + try { + writeFileSync(temporaryPath, `${version}\n`); + renameSync(temporaryPath, currentPath); + } finally { + rmSync(temporaryPath, { force: true }); + } +} + +function cleanupManagedStaging(managedRoot: string): void { + const stagingRoot = join(managedRoot, "staging"); + try { + for (const entry of readdirSync(stagingRoot)) { + if (entry.startsWith("update-")) { + rmSync(join(stagingRoot, entry), { force: true, recursive: true }); + } + } + } catch { + // The staging directory does not exist yet or is not writable. + } +} + +export function cleanupManagedInstall(): void { + let managedRoot: string | undefined; + try { + managedRoot = getActiveManagedInstallRoot(); + } catch { + return; + } + if (!managedRoot) return; + + try { + const releaseLock = lockfile.lockSync(join(managedRoot, "update"), { realpath: false }); + try { + cleanupManagedStaging(managedRoot); + } finally { + releaseLock(); + } + } catch { + // A live update owns the staging directory, or cleanup is unavailable. + } +} + +async function runManagedSelfUpdate(managedRoot: string, version: string): Promise { + if (!MANAGED_RELEASE_VERSION_RE.test(version)) { + throw new Error(`Invalid managed release version: ${version}`); + } + + let releaseLock: () => Promise; + try { + releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code === "ELOCKED") { + throw new Error("Another managed Pi update is already running."); + } + throw error; + } + + let stageDir: string | undefined; + try { + cleanupManagedStaging(managedRoot); + const installerApiBase = (process.env.PI_INSTALLER_API_BASE?.trim() || DEFAULT_INSTALLER_API_BASE).replace( + /\/+$/, + "", + ); + const releaseUrl = `${installerApiBase}/${encodeURIComponent(version)}`; + const stagingRoot = join(managedRoot, "staging"); + const releasesRoot = join(managedRoot, "releases"); + mkdirSync(releasesRoot, { recursive: true }); + const releaseDir = join(releasesRoot, version); + if (existsSync(releaseDir)) { + verifyManagedRelease(releaseDir, version); + activateManagedRelease(managedRoot, version); + return; + } + + mkdirSync(stagingRoot, { recursive: true }); + stageDir = mkdtempSync(join(stagingRoot, "update-")); + const [packageJsonContent, packageLockContent] = await Promise.all([ + fetchInstallerArtifact(`${releaseUrl}/package.json`, "package.json"), + fetchInstallerArtifact(`${releaseUrl}/package-lock.json`, "package-lock.json"), + ]); + writeFileSync(join(stageDir, "package.json"), packageJsonContent); + writeFileSync(join(stageDir, "package-lock.json"), packageLockContent); + + await runManagedNpmCi(stageDir); + verifyManagedRelease(stageDir, version); + renameSync(stageDir, releaseDir); + activateManagedRelease(managedRoot, version); + } finally { + if (stageDir) rmSync(stageDir, { force: true, recursive: true }); + await releaseLock(); + } +} + const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = { heading: (text) => chalk.bold(chalk.yellow(text)), link: (text) => chalk.cyan(text), @@ -834,10 +1020,37 @@ export async function handlePackageCommand( } } if (updateTargetIncludesSelf(target)) { + const managedInstallRoot = getActiveManagedInstallRoot(); + if (managedInstallRoot && options.force) { + console.error( + chalk.red( + `Managed ${APP_NAME} installations do not support --force; rerun the installer to repair this installation.`, + ), + ); + process.exitCode = 1; + return true; + } const selfUpdatePlan = await getSelfUpdatePlan(options.force); if (!selfUpdatePlan.shouldRun) { return true; } + if (managedInstallRoot) { + if (selfUpdatePlan.note) { + printSelfUpdateNote(selfUpdatePlan.note); + } + try { + console.log(chalk.dim(`Updating managed ${APP_NAME} installation...`)); + await runManagedSelfUpdate(managedInstallRoot, selfUpdatePlan.version); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown managed update error"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + return true; + } + console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`)); + return true; + } + const installMethod = detectInstallMethod(); if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") { console.error( diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index a804b40e41e..1d534349298 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,6 +1,17 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; +import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; @@ -29,6 +40,77 @@ describe("package commands", () => { return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; } + function prepareManagedInstall( + targetVersion: string, + npmExitCode = 0, + ): { managedRoot: string; npmRecordPath: string } { + const managedRoot = join(agentDir, "install"); + const activeRelease = join(managedRoot, "releases", VERSION); + const selfPackageDir = join(activeRelease, "node_modules", ...PACKAGE_NAME.split("/")); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync(join(activeRelease, "active.txt"), "active"); + writeFileSync(join(managedRoot, "current-version"), `${VERSION}\n`); + writeFileSync( + join(managedRoot, "managed-install.json"), + `${JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" })}\n`, + ); + + const binDir = join(tempDir, "managed-bin"); + const fakeNpmPath = join(tempDir, "managed-npm.cjs"); + const npmRecordPath = join(tempDir, "managed-npm-record.json"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +fs.writeFileSync(${JSON.stringify(npmRecordPath)}, JSON.stringify(args)); +if (${npmExitCode} !== 0) process.exit(${npmExitCode}); +const binDir = path.join(process.cwd(), "node_modules", ".bin"); +fs.mkdirSync(binDir, { recursive: true }); +const piPath = path.join(binDir, process.platform === "win32" ? "pi.cmd" : "pi"); +fs.writeFileSync( + piPath, + process.platform === "win32" + ? "@echo off\\r\\necho ${targetVersion}\\r\\n" + : "#!/bin/sh\\nprintf '%s\\n' ${targetVersion}\\n", +); +if (process.platform !== "win32") fs.chmodSync(piPath, 0o755); +`, + ); + const npmPath = join(binDir, process.platform === "win32" ? "npm.cmd" : "npm"); + writeFileSync( + npmPath, + process.platform === "win32" + ? `@echo off\r\n"${originalExecPath}" "${fakeNpmPath}" %*\r\n` + : `#!/bin/sh\nexec "${originalExecPath}" "${fakeNpmPath}" "$@"\n`, + ); + chmodSync(npmPath, 0o755); + + vi.stubEnv("PI_INSTALLER_API_BASE", "https://example.test/api/installer/releases"); + vi.stubEnv("PI_MANAGED_INSTALL_ROOT", managedRoot); + process.env.PI_PACKAGE_DIR = selfPackageDir; + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + return { managedRoot, npmRecordPath }; + } + + function mockManagedUpdate(targetVersion: string): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === "https://pi.dev/api/latest-version") { + return Response.json({ packageName: PACKAGE_NAME, version: targetVersion }); + } + const releaseUrl = `https://example.test/api/installer/releases/${targetVersion}`; + if (url === `${releaseUrl}/package.json` || url === `${releaseUrl}/package-lock.json`) { + return Response.json({}); + } + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + } + async function runPackageCommandDirectly(args: string[]): Promise { expect(await handlePackageCommand(args)).toBe(true); } @@ -82,6 +164,7 @@ describe("package commands", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); vi.restoreAllMocks(); process.chdir(originalCwd); process.exitCode = originalExitCode; @@ -523,10 +606,103 @@ describe("package commands", () => { } }); - it("uses the update check version for forced self updates even when current", async () => { + it("updates installer-managed Pi through a staged immutable release", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion); + const abandonedStage = join(managedRoot, "staging", "update-abandoned"); + mkdirSync(abandonedStage, { recursive: true }); + writeFileSync(join(abandonedStage, "partial"), "partial"); + const abandonedLock = join(managedRoot, "update.lock"); + mkdirSync(abandonedLock); + utimesSync(abandonedLock, new Date(0), new Date(0)); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${targetVersion}\n`); + expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(true); + expect(existsSync(join(managedRoot, "releases", VERSION, "active.txt"))).toBe(true); + expect(readdirSync(join(managedRoot, "staging"))).toEqual([]); + expect(JSON.parse(readFileSync(npmRecordPath, "utf8")) as string[]).toEqual( + expect.arrayContaining(["ci", "--ignore-scripts"]), + ); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + `Updated pi from ${VERSION} to ${targetVersion}`, + ); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("rejects a concurrent managed update", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion); + const releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + } finally { + await releaseLock(); + } + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`); + expect(existsSync(npmRecordPath)).toBe(false); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from"); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "Another managed Pi update is already running.", + ); + expect(process.exitCode).toBe(1); + }); + + it("rejects forced managed reinstalls", async () => { + const targetVersion = getNewerPatchVersion(); + const { npmRecordPath } = prepareManagedInstall(targetVersion); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(existsSync(npmRecordPath)).toBe(false); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "Managed pi installations do not support --force", + ); + expect(process.exitCode).toBe(1); + }); + + it("keeps the managed release active when its update fails", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot } = prepareManagedInstall(targetVersion, 23); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`); + expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(false); + expect(readdirSync(join(managedRoot, "staging"))).toEqual([]); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from"); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("exited with code 23"); + expect(process.exitCode).toBe(1); + }); + + it("keeps npm self-updates non-managed when the managed environment is inherited", async () => { const globalPrefix = join(tempDir, "global-prefix"); const projectPrefix = join(tempDir, "project-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const inheritedManagedRoot = join(tempDir, "inherited-managed-install"); + mkdirSync(join(inheritedManagedRoot, "releases"), { recursive: true }); + writeFileSync( + join(inheritedManagedRoot, "managed-install.json"), + JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" }), + ); + vi.stubEnv("PI_MANAGED_INSTALL_ROOT", inheritedManagedRoot); const fakeNpmPath = join(tempDir, "fake-npm.cjs"); const recordPath = join(tempDir, "self-update.json"); mkdirSync(selfPackageDir, { recursive: true }); diff --git a/scripts/publish-release-announcement.mjs b/scripts/publish-release-announcement.mjs index 79d34e4e938..a0a090f2709 100644 --- a/scripts/publish-release-announcement.mjs +++ b/scripts/publish-release-announcement.mjs @@ -57,8 +57,8 @@ function parseArgs(args) { if (!options.version || !STABLE_SEMVER_RE.test(options.version)) { throw new Error("--version must be a stable semver version"); } - if (Boolean(options.installerPackageJson) !== Boolean(options.installerPackageLock)) { - throw new Error("--installer-package-json and --installer-package-lock must be provided together"); + if (!options.installerPackageJson || !options.installerPackageLock) { + throw new Error("--installer-package-json and --installer-package-lock are required"); } return options; } @@ -302,6 +302,57 @@ async function main() { } writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`); + validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version); + const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`; + putObject( + options.bucket, + options.endpoint, + options.installerPackageJson, + `${installerReleasePrefix}/package.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putObject( + options.bucket, + options.endpoint, + options.installerPackageLock, + `${installerReleasePrefix}/package-lock.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putJson( + options.bucket, + options.endpoint, + releasePath, + `${installerReleasePrefix}/metadata.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + const installerLatest = await advanceLatestRelease( + options.version, + () => + readLatestRelease( + options.bucket, + options.endpoint, + `${INSTALLER_PREFIX}/latest.json`, + join(temporaryDirectory, "installer-latest-current.json"), + ), + (condition) => + putJson( + options.bucket, + options.endpoint, + latestPath, + `${INSTALLER_PREFIX}/latest.json`, + "no-store", + condition, + ), + ); + console.log( + installerLatest.advanced + ? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json` + : `Pi ${installerLatest.version} is already the latest installer release.`, + ); + const result = await advanceLatestRelease( options.version, () => @@ -326,59 +377,6 @@ async function main() { ? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json` : `Pi ${result.version} is already the latest announced release.`, ); - - if (options.installerPackageJson) { - validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version); - const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`; - putObject( - options.bucket, - options.endpoint, - options.installerPackageJson, - `${installerReleasePrefix}/package.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - putObject( - options.bucket, - options.endpoint, - options.installerPackageLock, - `${installerReleasePrefix}/package-lock.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - putJson( - options.bucket, - options.endpoint, - releasePath, - `${installerReleasePrefix}/metadata.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - const installerLatest = await advanceLatestRelease( - options.version, - () => - readLatestRelease( - options.bucket, - options.endpoint, - `${INSTALLER_PREFIX}/latest.json`, - join(temporaryDirectory, "installer-latest-current.json"), - ), - (condition) => - putJson( - options.bucket, - options.endpoint, - latestPath, - `${INSTALLER_PREFIX}/latest.json`, - "no-store", - condition, - ), - ); - console.log( - installerLatest.advanced - ? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json` - : `Pi ${installerLatest.version} is already the latest installer release.`, - ); - } } finally { rmSync(temporaryDirectory, { force: true, recursive: true }); } From 80e62761f7251a104f1b21d9c73920c720f0ec00 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:27:03 +0200 Subject: [PATCH 268/284] feat(coding-agent): add optional PowerShell tool (#8512) --- packages/coding-agent/README.md | 6 +- .../docs/environment-variables.md | 16 ++-- packages/coding-agent/docs/extensions.md | 11 +-- packages/coding-agent/docs/sdk.md | 9 ++- packages/coding-agent/docs/settings.md | 10 ++- packages/coding-agent/docs/usage.md | 2 +- packages/coding-agent/docs/windows.md | 26 ++++++- packages/coding-agent/src/cli/args.ts | 15 ++-- .../coding-agent/src/core/extensions/index.ts | 3 + .../coding-agent/src/core/extensions/types.ts | 18 +++++ packages/coding-agent/src/core/sdk.ts | 2 + .../coding-agent/src/core/system-prompt.ts | 11 ++- packages/coding-agent/src/core/tools/bash.ts | 76 ++++++++++++++----- packages/coding-agent/src/core/tools/index.ts | 32 +++++++- .../coding-agent/src/core/tools/powershell.ts | 67 ++++++++++++++++ packages/coding-agent/src/index.ts | 13 +++- packages/coding-agent/src/utils/shell.ts | 26 +++++-- .../test/default-tools-setting.test.ts | 13 +++- .../experimental-tool-strict-mode.test.ts | 2 + .../coding-agent/test/powershell-tool.test.ts | 30 ++++++++ ...uiltin-tools-keeps-extension-tools.test.ts | 2 +- .../coding-agent/test/system-prompt.test.ts | 14 ++++ .../tool-system-prompt-contributions.test.ts | 12 ++- 23 files changed, 351 insertions(+), 65 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/powershell.ts create mode 100644 packages/coding-agent/test/powershell-tool.test.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 66a07900703..0a6c1e14fb4 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -584,7 +584,7 @@ cat README.md | pi -p "Summarize this text" | `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools by default | -Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +Available built-in tools: `read`, `bash`, `powershell` (Windows), `edit`, `write`, `grep`, `find`, `ls` ### Resource Options @@ -682,7 +682,7 @@ pi --thinking high "Solve this complex problem" | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | | `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere | -Commands run by the LLM-callable bash tool also receive current session metadata: +Commands run by the LLM-callable `bash` and `powershell` tools also receive current session metadata: | Variable | Description | |----------|-------------| @@ -692,7 +692,7 @@ Commands run by the LLM-callable bash tool also receive current session metadata | `PI_MODEL` | Currently selected model ID | | `PI_REASONING_LEVEL` | Current effective reasoning level | -These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#bash-tool-session-environment) for semantics, examples, and custom-tool opt-out. +These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#shell-tool-session-environment) for semantics, examples, and custom-tool opt-out. --- diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 29c324f63fd..fe9cafa8b14 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -4,7 +4,7 @@ Pi uses environment variables in three ways: - Variables such as `PI_OFFLINE` configure the Pi process. - Pi sets process markers so child processes can identify Pi as the launching agent. -- Commands run by the LLM-callable bash tool receive `PI_*` variables describing the current session. +- Commands run by the LLM-callable shell tools receive `PI_*` variables describing the current session. Provider API-key variables are documented separately in [Providers](providers.md#environment-variables-or-auth-file). @@ -17,9 +17,9 @@ The CLI and RPC entry points set two process markers: Child processes inherit both markers. They are not session-specific and are not set automatically when Pi is embedded through the SDK. -## Bash Tool Session Environment +## Shell Tool Session Environment -Commands run by the bash tool receive the current Pi session state: +Commands run by the `bash` and `powershell` tools receive the current Pi session state: | Variable | Description | |----------|-------------| @@ -29,7 +29,7 @@ Commands run by the bash tool receive the current Pi session state: | `PI_MODEL` | Currently selected model ID | | `PI_REASONING_LEVEL` | Current effective reasoning level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | -The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next bash command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally. +The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next shell command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally. When asked which model or provider is running, inspect these variables instead of inferring the answer from the system prompt: @@ -46,11 +46,11 @@ if [ -n "$PI_SESSION_FILE" ]; then fi ``` -These variables are injected into the LLM-callable bash tool. They are not injected into user-entered `!` or `!!` commands. +These variables are injected into the LLM-callable `bash` and `powershell` tools. They are not injected into user-entered `!` or `!!` commands. -### Custom Bash Tools +### Custom Shell Tools -Bash tools created with `createBashTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`: +Tools created with `createBashTool()` or `createPowerShellTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`: ```typescript const bashTool = createBashTool(cwd, { @@ -64,7 +64,7 @@ const bashTool = createBashTool(cwd, { Disable session metadata independently of the spawn hook: ```typescript -const bashTool = createBashTool(cwd, { +const powershellTool = createPowerShellTool(cwd, { exposeSessionEnvironment: false, spawnHook: (ctx) => ctx, }); diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 2e069acdf40..7643856be8e 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2059,7 +2059,7 @@ pi.registerTool({ ### Overriding Built-in Tools -Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. +Extensions can override built-in tools (`read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. ```bash # Extension's read tool replaces built-in read @@ -2083,6 +2083,7 @@ See [examples/extensions/tool-override.ts](../examples/extensions/tool-override. Built-in tool implementations: - [read.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails` - [bash.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails` +- [powershell.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/powershell.ts) - `PowerShellToolDetails` - [edit.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts) - [write.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts) - [grep.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails` @@ -2118,11 +2119,11 @@ pi.registerTool({ }); ``` -**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `PowerShellOperations`, `LsOperations`, `GrepOperations`, `FindOperations` For `user_bash`, extensions can reuse pi's local shell backend via `createLocalBashOperations()` instead of reimplementing local process spawning, shell resolution, and process-tree termination. -The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution: +The `bash` and `powershell` tools also support a spawn hook to adjust the command, cwd, or env before execution: ```typescript import { createBashTool } from "@earendil-works/pi-coding-agent"; @@ -2136,7 +2137,7 @@ const bashTool = createBashTool(cwd, { }); ``` -`createBashTool()` exposes the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them: +`createBashTool()` and `createPowerShellTool()` expose the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them: ```typescript const bashTool = createBashTool(cwd, { @@ -2144,7 +2145,7 @@ const bashTool = createBashTool(cwd, { }); ``` -See [Bash tool session environment](environment-variables.md#bash-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. +See [Shell tool session environment](environment-variables.md#shell-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. ### Output Truncation diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a4e4f2276e5..5b75b2f8a93 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -519,7 +519,7 @@ const { session } = await createAgentSession({ resourceLoader: loader }); Specify which built-in tools to enable: -- Built-in tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +- Built-in tool names: `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls` - Default built-ins: `read`, `bash`, `edit`, `write` - `noTools: "all"` disables all tools - `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled @@ -540,6 +540,11 @@ const { session } = await createAgentSession({ tools: ["read", "bash", "grep"], }); +// Use PowerShell instead of Bash on Windows +const { session } = await createAgentSession({ + tools: ["read", "powershell", "edit", "write"], +}); + // Disable one tool while keeping the rest available const { session } = await createAgentSession({ excludeTools: ["ask_question"], @@ -1196,7 +1201,7 @@ SettingsManager // Tool factories createCodingTools createReadOnlyTools -createReadTool, createBashTool, createEditTool, createWriteTool +createReadTool, createBashTool, createPowerShellTool, createEditTool, createWriteTool createGrepTool, createFindTool, createLsTool // Types diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index b2339284353..d0ded5f6857 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -220,7 +220,7 @@ Windows paths in JSON must use forward slashes or escaped backslashes: |---------|------|---------|-------------| | `defaultTools` | string[] | - | Built-in tools enabled initially. When omitted, Pi uses its standard defaults | -`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled: +`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled. Available built-ins are `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls`: ```json { @@ -228,6 +228,14 @@ Windows paths in JSON must use forward slashes or escaped backslashes: } ``` +On Windows, select `powershell` instead of `bash`, or include both: + +```json +{ + "defaultTools": ["read", "powershell", "edit", "write"] +} +``` + An empty array starts with no built-in tools while preserving extension and SDK custom tools. `--tools` replaces this behavior with a strict allowlist for all tools, `--no-tools` disables all tools, and `--no-builtin-tools` disables the built-in defaults. `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. ### Sessions diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index bbf60db1e1d..4db22b8c5a1 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -213,7 +213,7 @@ cat README.md | pi -p "Summarize this text" | `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools | -Built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`. +Built-in tools: `read`, `bash`, `powershell` (Windows), `edit`, `write`, `grep`, `find`, `ls`. ### Resource Options diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 007f649c125..2517fd946d5 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -1,6 +1,6 @@ # Windows Setup -Pi requires a bash shell on Windows. Checked locations (in order): +Pi uses Git Bash by default on Windows. Checked locations (in order): 1. Custom path from `~/.pi/agent/settings.json` 2. Git Bash (`C:\Program Files\Git\bin\bash.exe`) @@ -8,7 +8,29 @@ Pi requires a bash shell on Windows. Checked locations (in order): For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient. -## Custom Shell Path +## PowerShell Tool + +The optional `powershell` tool runs commands through `pwsh.exe` when available, otherwise Windows PowerShell. It starts PowerShell with `-NoProfile -NonInteractive -ExecutionPolicy Bypass`. Administrator-enforced execution policies can still take precedence. + +Use `defaultTools` to replace the model-facing `bash` tool: + +```json +{ + "defaultTools": ["read", "powershell", "edit", "write"] +} +``` + +Or enable both while comparing behavior: + +```json +{ + "defaultTools": ["read", "bash", "powershell", "edit", "write"] +} +``` + +The `!` and `!!` editor commands still use Bash. + +## Custom Bash Path ```json { diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 8e03419e699..8ad5da63e5c 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -435,12 +435,13 @@ ${chalk.bold("Environment Variables:")} PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) ${chalk.bold("Built-in Tool Names:")} - read - Read file contents - bash - Execute bash commands - edit - Edit files with find/replace - write - Write files (creates/overwrites) - grep - Search file contents (read-only, off by default) - find - Find files by glob pattern (read-only, off by default) - ls - List directory contents (read-only, off by default) + read - Read file contents + bash - Execute bash commands + powershell - Execute PowerShell commands on Windows + edit - Edit files with find/replace + write - Write files (creates/overwrites) + grep - Search file contents (read-only, off by default) + find - Find files by glob pattern (read-only, off by default) + ls - List directory contents (read-only, off by default) `); } diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 4ef89cff983..90c7860412f 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -105,6 +105,8 @@ export type { MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, + PowerShellToolCallEvent, + PowerShellToolResultEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -180,6 +182,7 @@ export { isFindToolResult, isGrepToolResult, isLsToolResult, + isPowerShellToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 9303c0bc4e4..bade4641951 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -78,6 +78,8 @@ import type { GrepToolInput, LsToolDetails, LsToolInput, + PowerShellToolDetails, + PowerShellToolInput, ReadToolDetails, ReadToolInput, WriteToolInput, @@ -876,6 +878,11 @@ export interface BashToolCallEvent extends ToolCallEventBase { input: BashToolInput; } +export interface PowerShellToolCallEvent extends ToolCallEventBase { + toolName: "powershell"; + input: PowerShellToolInput; +} + export interface ReadToolCallEvent extends ToolCallEventBase { toolName: "read"; input: ReadToolInput; @@ -919,6 +926,7 @@ export interface CustomToolCallEvent extends ToolCallEventBase { */ export type ToolCallEvent = | BashToolCallEvent + | PowerShellToolCallEvent | ReadToolCallEvent | EditToolCallEvent | WriteToolCallEvent @@ -942,6 +950,11 @@ export interface BashToolResultEvent extends ToolResultEventBase { details: BashToolDetails | undefined; } +export interface PowerShellToolResultEvent extends ToolResultEventBase { + toolName: "powershell"; + details: PowerShellToolDetails | undefined; +} + export interface ReadToolResultEvent extends ToolResultEventBase { toolName: "read"; details: ReadToolDetails | undefined; @@ -980,6 +993,7 @@ export interface CustomToolResultEvent extends ToolResultEventBase { /** Fired after a tool executes. Can modify result. */ export type ToolResultEvent = | BashToolResultEvent + | PowerShellToolResultEvent | ReadToolResultEvent | EditToolResultEvent | WriteToolResultEvent @@ -992,6 +1006,9 @@ export type ToolResultEvent = export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { return e.toolName === "bash"; } +export function isPowerShellToolResult(e: ToolResultEvent): e is PowerShellToolResultEvent { + return e.toolName === "powershell"; +} export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent { return e.toolName === "read"; } @@ -1032,6 +1049,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { * CustomToolCallEvent.toolName is `string` which overlaps with all literals. */ export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent; +export function isToolCallEventType(toolName: "powershell", event: ToolCallEvent): event is PowerShellToolCallEvent; export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent; export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent; export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8af041a57b3..adbf102a398 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -23,6 +23,7 @@ import { createFindTool, createGrepTool, createLsTool, + createPowerShellTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -125,6 +126,7 @@ export { createGrepTool, createFindTool, createLsTool, + createPowerShellTool, }; // Helper Functions diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 3b1aa63340b..ec450678a35 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -95,14 +95,21 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { }; const hasBash = tools.includes("bash"); + const hasPowerShell = tools.includes("powershell"); const hasGrep = tools.includes("grep"); const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); const hasRead = tools.includes("read"); // File exploration guidelines - if (hasBash && !hasGrep && !hasFind && !hasLs) { - addGuideline("Use bash for file operations like ls, rg, find"); + if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) { + if (hasBash && hasPowerShell) { + addGuideline("Use bash or PowerShell for file operations like listing, searching, and finding files"); + } else if (hasPowerShell) { + addGuideline("Use PowerShell for file operations like listing, searching, and finding files"); + } else { + addGuideline("Use bash for file operations like ls, rg, find"); + } } for (const guideline of promptGuidelines ?? []) { diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 9c745df595e..b3139b85db7 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -12,6 +12,7 @@ import { getShellConfig, getShellEnv, killProcessTree, + type ShellConfig, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.ts"; @@ -39,7 +40,7 @@ function resolveTimeoutMs(timeout: number | undefined): number | undefined { } const bashSchema = Type.Object({ - command: Type.String({ description: "Bash command to execute" }), + command: Type.String({ description: "Shell command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), }); @@ -79,24 +80,19 @@ export interface BashOperations { ) => Promise<{ exitCode: number | null }>; } -/** - * Create bash operations using pi's built-in local shell execution backend. - * - * This is useful for extensions that intercept user_bash and still want pi's - * standard local shell behavior while wrapping or rewriting commands. - */ -export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { +/** Shared process execution used by the built-in shell tools. */ +export function createLocalShellOperations(shellName: string, resolveShellConfig: () => ShellConfig): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { const timeoutMs = resolveTimeoutMs(timeout); if (signal?.aborted) { throw new Error("aborted"); } - const shellConfig = getShellConfig(options?.shellPath); + const shellConfig = resolveShellConfig(); try { await fsAccess(cwd, constants.F_OK); } catch { - throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`); + throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`); } const commandFromStdin = shellConfig.commandTransport === "stdin"; @@ -153,6 +149,16 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas }; } +/** + * Create bash operations using pi's built-in local shell execution backend. + * + * This is useful for extensions that intercept user_bash and still want pi's + * standard local shell behavior while wrapping or rewriting commands. + */ +export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { + return createLocalShellOperations("bash", () => getShellConfig(options?.shellPath)); +} + export interface BashSpawnContext { command: string; cwd: string; @@ -205,7 +211,7 @@ export interface BashToolOptions { const BASH_PREVIEW_LINES = 5; const BASH_UPDATE_THROTTLE_MS = 100; -type BashRenderState = { +export type BashRenderState = { startedAt: number | undefined; endedAt: number | undefined; interval: NodeJS.Timeout | undefined; @@ -229,12 +235,12 @@ function formatDuration(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } -function formatBashCall(args: { command?: string; timeout?: number } | undefined): string { +function formatShellCall(args: { command?: string; timeout?: number } | undefined, prompt: string): string { const command = str(args?.command); const timeout = args?.timeout as number | undefined; const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); - return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix; + return theme.fg("toolTitle", theme.bold(`${prompt} ${commandDisplay}`)) + timeoutSuffix; } function rebuildBashResultRenderComponent( @@ -319,8 +325,19 @@ function rebuildBashResultRenderComponent( } } -export function createBashToolDefinition( +export interface ShellToolConfig { + name: string; + label: string; + shellName: string; + prompt: string; + promptSnippet: string; + promptGuidelines?: readonly string[]; + tempFilePrefix: string; +} + +export function createShellToolDefinition( cwd: string, + config: ShellToolConfig, options?: BashToolOptions, ): ToolDefinition { const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath }); @@ -328,11 +345,11 @@ export function createBashToolDefinition( const exposeSessionEnvironment = options?.exposeSessionEnvironment ?? true; const spawnHook = options?.spawnHook; return { - name: "bash", - label: "bash", - description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, - promptSnippet: bashToolSystemPromptContribution.snippet, - promptGuidelines: exposeSessionEnvironment ? [...bashToolSystemPromptContribution.guidelines] : undefined, + name: config.name, + label: config.label, + description: `Execute a ${config.shellName} command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + promptSnippet: config.promptSnippet, + promptGuidelines: exposeSessionEnvironment && config.promptGuidelines ? [...config.promptGuidelines] : undefined, parameters: bashSchema, constrainedSampling: getExperimentalToolSampling(), async execute( @@ -344,7 +361,7 @@ export function createBashToolDefinition( ) { const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook, exposeSessionEnvironment, ctx); - const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" }); + const output = new OutputAccumulator({ tempFilePrefix: config.tempFilePrefix }); let acceptingOutput = true; let updateTimer: NodeJS.Timeout | undefined; let updateDirty = false; @@ -468,7 +485,7 @@ export function createBashToolDefinition( state.endedAt = undefined; } const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatBashCall(args)); + text.setText(formatShellCall(args, config.prompt)); return text; }, renderResult(result, options, _theme, context) { @@ -499,6 +516,23 @@ export function createBashToolDefinition( }; } +const bashToolConfig: ShellToolConfig = { + name: "bash", + label: "bash", + shellName: "bash", + prompt: "$", + promptSnippet: bashToolSystemPromptContribution.snippet, + promptGuidelines: bashToolSystemPromptContribution.guidelines, + tempFilePrefix: "pi-bash", +}; + +export function createBashToolDefinition( + cwd: string, + options?: BashToolOptions, +): ToolDefinition { + return createShellToolDefinition(cwd, bashToolConfig, options); +} + export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { const definition = createBashToolDefinition(cwd, options); const tool = wrapToolDefinition(definition); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index e55f914062d..5e8fd5b3691 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -42,6 +42,17 @@ export { type LsToolInput, type LsToolOptions, } from "./ls.ts"; +export { + createLocalPowerShellOperations, + createPowerShellTool, + createPowerShellToolDefinition, + type PowerShellOperations, + type PowerShellSpawnContext, + type PowerShellSpawnHook, + type PowerShellToolDetails, + type PowerShellToolInput, + type PowerShellToolOptions, +} from "./powershell.ts"; export { createReadTool, createReadToolDefinition, @@ -75,17 +86,28 @@ import { createEditTool, createEditToolDefinition, type EditToolOptions } from " import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts"; import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; +import { createPowerShellTool, createPowerShellToolDefinition, type PowerShellToolOptions } from "./powershell.ts"; import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; -export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); +export type ToolName = "read" | "bash" | "powershell" | "edit" | "write" | "grep" | "find" | "ls"; +export const allToolNames: Set = new Set([ + "read", + "bash", + "powershell", + "edit", + "write", + "grep", + "find", + "ls", +]); export interface ToolsOptions { read?: ReadToolOptions; bash?: BashToolOptions; + powershell?: PowerShellToolOptions; write?: WriteToolOptions; edit?: EditToolOptions; grep?: GrepToolOptions; @@ -99,6 +121,8 @@ export function createToolDefinition(toolName: ToolName, cwd: string, options?: return createReadToolDefinition(cwd, options?.read); case "bash": return createBashToolDefinition(cwd, options?.bash); + case "powershell": + return createPowerShellToolDefinition(cwd, options?.powershell); case "edit": return createEditToolDefinition(cwd, options?.edit); case "write": @@ -120,6 +144,8 @@ export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptio return createReadTool(cwd, options?.read); case "bash": return createBashTool(cwd, options?.bash); + case "powershell": + return createPowerShellTool(cwd, options?.powershell); case "edit": return createEditTool(cwd, options?.edit); case "write": @@ -157,6 +183,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R return { read: createReadToolDefinition(cwd, options?.read), bash: createBashToolDefinition(cwd, options?.bash), + powershell: createPowerShellToolDefinition(cwd, options?.powershell), edit: createEditToolDefinition(cwd, options?.edit), write: createWriteToolDefinition(cwd, options?.write), grep: createGrepToolDefinition(cwd, options?.grep), @@ -187,6 +214,7 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record {} + +export function createLocalPowerShellOperations(): PowerShellOperations { + const operations = createLocalShellOperations("PowerShell", getPowerShellConfig); + return { + exec: (command, cwd, options) => operations.exec(`${UTF8_OUTPUT_PREFIX}${command}`, cwd, options), + }; +} + +const powershellToolConfig: ShellToolConfig = { + name: "powershell", + label: "powershell", + shellName: "PowerShell", + prompt: "PS>", + promptSnippet: powershellToolSystemPromptContribution.snippet, + promptGuidelines: powershellToolSystemPromptContribution.guidelines, + tempFilePrefix: "pi-powershell", +}; + +export function createPowerShellToolDefinition( + cwd: string, + options?: PowerShellToolOptions, +): ReturnType { + return createShellToolDefinition(cwd, powershellToolConfig, { + ...options, + operations: options?.operations ?? createLocalPowerShellOperations(), + }); +} + +export function createPowerShellTool(cwd: string, options?: PowerShellToolOptions): ReturnType { + const definition = createPowerShellToolDefinition(cwd, options); + const tool = wrapToolDefinition(definition); + Object.assign(tool, { + promptSnippet: definition.promptSnippet, + promptGuidelines: definition.promptGuidelines, + }); + return tool; +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 6d64d70c87c..210b84010d2 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -107,6 +107,7 @@ export type { MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, + PowerShellToolCallEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -159,6 +160,7 @@ export { isFindToolResult, isGrepToolResult, isLsToolResult, + isPowerShellToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, @@ -218,6 +220,7 @@ export { createFindTool, createGrepTool, createLsTool, + createPowerShellTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -285,7 +288,9 @@ export { createFindToolDefinition, createGrepToolDefinition, createLocalBashOperations, + createLocalPowerShellOperations, createLsToolDefinition, + createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, @@ -307,6 +312,12 @@ export { type LsToolDetails, type LsToolInput, type LsToolOptions, + type PowerShellOperations, + type PowerShellSpawnContext, + type PowerShellSpawnHook, + type PowerShellToolDetails, + type PowerShellToolInput, + type PowerShellToolOptions, type ReadOperations, type ReadToolDetails, type ReadToolInput, @@ -405,4 +416,4 @@ export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; export { convertToPng } from "./utils/image-convert.ts"; export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; // Shell utilities -export { getShellConfig } from "./utils/shell.ts"; +export { getPowerShellConfig, getShellConfig } from "./utils/shell.ts"; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 2cafa595eab..fae75267ec0 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -21,11 +21,11 @@ function getBashShellConfig(shell: string): ShellConfig { return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; } -function findBashOnPath(): string | null { +function findExecutableOnPath(executable: string): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) try { - const result = spawnSync("where", ["bash.exe"], { + const result = spawnSync("where", [executable], { encoding: "utf-8", timeout: 5000, windowsHide: true, @@ -44,7 +44,7 @@ function findBashOnPath(): string | null { // Unix: Use 'which' and trust its output (handles Termux and special filesystems) try { - const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("which", [executable], { encoding: "utf-8", timeout: 5000 }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch) { @@ -92,7 +92,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { } // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) - const bashOnPath = findBashOnPath(); + const bashOnPath = findExecutableOnPath("bash.exe"); if (bashOnPath) { return getBashShellConfig(bashOnPath); } @@ -111,7 +111,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { return getBashShellConfig("/bin/bash"); } - const bashOnPath = findBashOnPath(); + const bashOnPath = findExecutableOnPath("bash"); if (bashOnPath) { return getBashShellConfig(bashOnPath); } @@ -119,6 +119,22 @@ export function getShellConfig(customShellPath?: string): ShellConfig { return { shell: "sh", args: ["-c"] }; } +export const POWERSHELL_ARGS = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"] as const; + +/** Resolve PowerShell on Windows, preferring PowerShell 7 when available. */ +export function getPowerShellConfig(): ShellConfig { + if (process.platform !== "win32") { + throw new Error("The powershell tool is only available on Windows."); + } + + const shell = findExecutableOnPath("pwsh.exe") ?? findExecutableOnPath("powershell.exe"); + if (!shell) { + throw new Error("No PowerShell executable found. Install PowerShell or add powershell.exe/pwsh.exe to PATH."); + } + + return { shell, args: [...POWERSHELL_ARGS] }; +} + export function getShellEnv(): NodeJS.ProcessEnv { const binDir = getBinDir(); const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index 69adbceb572..5b9331400a0 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -63,13 +63,22 @@ describe("defaultTools setting", () => { .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["grep", "find"]); expect(session.systemPrompt).toContain("- grep:"); expect(session.systemPrompt).not.toContain("- read:"); session.dispose(); }); + it("can select powershell instead of bash", async () => { + const session = await createSession(["read", "powershell", "edit", "write"]); + + expect(session.getActiveToolNames()).toEqual(["read", "powershell", "edit", "write"]); + expect(session.systemPrompt).toContain("- powershell: Execute PowerShell commands"); + expect(session.systemPrompt).not.toContain("- bash:"); + session.dispose(); + }); + it("keeps extension and SDK custom tools enabled", async () => { const session = await createSession( ["grep"], @@ -143,7 +152,7 @@ describe("defaultTools setting", () => { .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["ls"]); session.dispose(); }); diff --git a/packages/coding-agent/test/experimental-tool-strict-mode.test.ts b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts index 07a00d94c91..2e8b0334dad 100644 --- a/packages/coding-agent/test/experimental-tool-strict-mode.test.ts +++ b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createBashToolDefinition, createEditToolDefinition, + createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, } from "../src/core/tools/index.ts"; @@ -10,6 +11,7 @@ function createBuiltInTools() { return [ createReadToolDefinition(process.cwd()), createBashToolDefinition(process.cwd()), + createPowerShellToolDefinition(process.cwd()), createEditToolDefinition(process.cwd()), createWriteToolDefinition(process.cwd()), ]; diff --git a/packages/coding-agent/test/powershell-tool.test.ts b/packages/coding-agent/test/powershell-tool.test.ts new file mode 100644 index 00000000000..22bc193c895 --- /dev/null +++ b/packages/coding-agent/test/powershell-tool.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { createPowerShellTool } from "../src/core/tools/powershell.ts"; +import { getPowerShellConfig, POWERSHELL_ARGS } from "../src/utils/shell.ts"; + +function getTextOutput(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((content) => content.type === "text") + .map((content) => content.text ?? "") + .join("\n"); +} + +describe("powershell tool", () => { + it("uses process-local execution policy bypass", () => { + expect(POWERSHELL_ARGS).toEqual(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"]); + }); + + it.skipIf(process.platform !== "win32")("executes PowerShell commands with UTF-8 output", async () => { + const config = getPowerShellConfig(); + expect(config.args).toEqual(POWERSHELL_ARGS); + + const tool = createPowerShellTool(process.cwd()); + const result = await tool.execute("powershell-test", { + command: "Write-Output 'héllo €'; Get-ExecutionPolicy -Scope Process", + }); + const output = getTextOutput(result); + + expect(output).toContain("héllo €"); + expect(output).toContain("Bypass"); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts index 3d900ee361c..86ad26a08c9 100644 --- a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts +++ b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts @@ -78,7 +78,7 @@ describe("regression #3592: no-builtin-tools keeps extension tools enabled", () .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["dynamic_tool"]); expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); expect(session.systemPrompt).not.toContain("- read:"); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 6e38fcb0f22..41d83207bd0 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -46,6 +46,20 @@ describe("buildSystemPrompt", () => { expect(prompt).toContain("- write:"); }); + test.each([ + [["powershell"], "Use PowerShell for file operations"], + [["bash", "powershell"], "Use bash or PowerShell for file operations"], + ] as const)("uses shell-specific guidance for %j", (selectedTools, expected) => { + const prompt = buildSystemPrompt({ + selectedTools: [...selectedTools], + contextFiles: [], + skills: [], + cwd: process.cwd(), + }); + + expect(prompt).toContain(expected); + }); + test("instructs models to resolve pi docs and examples under absolute base paths", () => { const prompt = buildSystemPrompt({ contextFiles: [], diff --git a/packages/coding-agent/test/tool-system-prompt-contributions.test.ts b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts index 4a2927c165f..ed39fa508b8 100644 --- a/packages/coding-agent/test/tool-system-prompt-contributions.test.ts +++ b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts @@ -4,12 +4,17 @@ import { createEditToolDefinition, editToolSystemPromptContribution } from "../s import { createFindToolDefinition, findToolSystemPromptContribution } from "../src/core/tools/find.ts"; import { createGrepToolDefinition, grepToolSystemPromptContribution } from "../src/core/tools/grep.ts"; import { createLsToolDefinition, lsToolSystemPromptContribution } from "../src/core/tools/ls.ts"; +import { + createPowerShellToolDefinition, + powershellToolSystemPromptContribution, +} from "../src/core/tools/powershell.ts"; import { createReadToolDefinition, readToolSystemPromptContribution } from "../src/core/tools/read.ts"; import { createWriteToolDefinition, writeToolSystemPromptContribution } from "../src/core/tools/write.ts"; const cases = [ ["read", readToolSystemPromptContribution, createReadToolDefinition], ["bash", bashToolSystemPromptContribution, createBashToolDefinition], + ["powershell", powershellToolSystemPromptContribution, createPowerShellToolDefinition], ["edit", editToolSystemPromptContribution, createEditToolDefinition], ["write", writeToolSystemPromptContribution, createWriteToolDefinition], ["grep", grepToolSystemPromptContribution, createGrepToolDefinition], @@ -28,8 +33,11 @@ describe("built-in tool system prompt contributions", () => { }, ); - test("keeps bash session-environment guidance conditional", () => { - const definition = createBashToolDefinition("/workspace", { exposeSessionEnvironment: false }); + test.each([ + ["bash", createBashToolDefinition], + ["powershell", createPowerShellToolDefinition], + ] as const)("keeps %s session-environment guidance conditional", (_name, createDefinition) => { + const definition = createDefinition("/workspace", { exposeSessionEnvironment: false }); expect(definition.promptGuidelines).toBeUndefined(); }); From 7623e8a0f1728aec54b84374c525856fb9471f26 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:47:11 +0200 Subject: [PATCH 269/284] docs: audit unreleased changelogs --- packages/agent/CHANGELOG.md | 5 ++++ packages/ai/CHANGELOG.md | 13 ++++++++- packages/coding-agent/CHANGELOG.md | 46 ++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 679110f18d1..dee760b6a80 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays ([#7835](https://github.com/earendil-works/pi/issues/7835)). +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). + ## [0.84.2] - 2026-08-14 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 49d2ce9e55a..9bae0529dae 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,14 +9,25 @@ ### Added - Added provider-neutral `toolChoice` support to simple stream requests. +- Added automatic Anthropic server-side refusal fallback for supported first-party models, including returned-model usage pricing ([#8017](https://github.com/earendil-works/pi/issues/8017)). +- Added configurable OpenAI-compatible thinking-token budget fields for vLLM, Qwen/SGLang, and llama.cpp servers ([#8275](https://github.com/earendil-works/pi/pull/8275) by [@bnsd55](https://github.com/bnsd55)). - Added China-specific ZAI Coding Plan models, including GLM-4.6V vision support, and API-equivalent usage cost estimates for models with published PAYG prices ([#8220](https://github.com/earendil-works/pi/issues/8220)). +- Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). + +### Changed + +- Changed built-in xAI models to use the Responses API with encrypted reasoning replay and made Grok 4.6 the default xAI model ([#8124](https://github.com/earendil-works/pi/pull/8124) by [@Jaaneek](https://github.com/Jaaneek)). +- Changed the Anthropic, Azure OpenAI, Google Generative AI, Google Vertex, Mistral, OpenAI Chat Completions, and OpenAI Responses adapters to send Pi's default `User-Agent` unless overridden ([#8305](https://github.com/earendil-works/pi/issues/8305)). ### Fixed - Fixed OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` (`reasoning.text`, `reasoning.summary`, and `reasoning.encrypted`) verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)). - Fixed Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). +- Fixed GitHub Copilot login triggering model-policy rate limits by limiting policy updates, retrying model discovery once, and honoring server retry delays ([#7850](https://github.com/earendil-works/pi/issues/7850)). +- Fixed Amazon Bedrock dropping and failing to replay opaque redacted reasoning from non-Anthropic models ([#8314](https://github.com/earendil-works/pi/pull/8314) by [@seiji](https://github.com/seiji)). +- Fixed Z.AI Coding Plan models deriving incomplete reasoning-effort metadata, including missing GLM-5.3 low, high, and max levels ([#8336](https://github.com/earendil-works/pi/issues/8336)). +- Fixed DeepSeek V4 Flash on OpenCode and OpenCode Go omitting its supported low thinking level ([#8181](https://github.com/earendil-works/pi/pull/8181) by [@tianshuang](https://github.com/tianshuang)). - Fixed Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. -- Added `deepseek-v4-pro-0813` to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). - Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5394de918c5..24776576236 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,12 +2,34 @@ ## [Unreleased] +### New Features + +- **PowerShell tool** — Use optional native PowerShell command execution on Windows. See [PowerShell Tool](docs/windows.md#powershell-tool). +- **Safer managed updates** — Stage, verify, and atomically activate updates for installer-managed installations. See [Install and Manage](docs/packages.md#install-and-manage). +- **Model and thinking controls** — Select thinking levels with `/thinking`, search defaults, keep selections session-scoped, and persist them explicitly with Ctrl+S. See [Models and Thinking](docs/keybindings.md#models-and-thinking). + +### Breaking Changes + +- Renamed the inherited `GoogleThinkingLevel` type to `GoogleApiThinkingLevel` and added `ResolvedGoogleThinkingLevel` for normalized adapter levels. + ### Added +- Added an optional `powershell` tool for Windows, configurable through `defaultTools` and the SDK. See [PowerShell Tool](docs/windows.md#powershell-tool). +- Added a `/thinking` selector and searchable default choices to the model and thinking selectors; Ctrl+S saves the selected model as the global default. See [Models and Thinking](docs/keybindings.md#models-and-thinking). +- Added optional routing session IDs to exported compaction summary helpers so callers can preserve provider routing without enabling prompt cache writes. - Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled. +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). +- Added inherited provider-neutral `toolChoice` support to simple stream requests. +- Added inherited automatic Anthropic server-side refusal fallback for supported first-party models, including returned-model usage pricing ([#8017](https://github.com/earendil-works/pi/issues/8017)). +- Added inherited configurable OpenAI-compatible thinking-token budget fields for vLLM, Qwen/SGLang, and llama.cpp servers. See [OpenAI Compatibility](docs/models.md#openai-compatibility) ([#8275](https://github.com/earendil-works/pi/pull/8275) by [@bnsd55](https://github.com/bnsd55)). +- Added inherited China-specific ZAI Coding Plan models, including GLM-4.6V vision support and API-equivalent usage cost estimates ([#8220](https://github.com/earendil-works/pi/issues/8220)). +- Added inherited `deepseek-v4-pro-0813` support to the Qwen Token Plan Individual catalog ([#8194](https://github.com/earendil-works/pi/issues/8194)). ### Changed +- Changed experimental installer-managed installations so `pi update` stages, verifies, and atomically activates the selected release in place. See [Install and Manage](docs/packages.md#install-and-manage). +- Changed inherited built-in xAI models to use the Responses API with encrypted reasoning replay and made Grok 4.6 the default xAI model ([#8124](https://github.com/earendil-works/pi/pull/8124) by [@Jaaneek](https://github.com/Jaaneek)). +- Changed inherited Anthropic, Azure OpenAI, Google, Mistral, and OpenAI adapters to send Pi's default `User-Agent` unless overridden ([#8305](https://github.com/earendil-works/pi/issues/8305)). - Changed Windows and WSL keybinding defaults to avoid terminal-reserved shortcuts for image paste, model cycling, editor undo, fullscreen transcript navigation and search, and message queueing ([#8372](https://github.com/earendil-works/pi/issues/8372)). - Changed Bun release archives to ship the native clipboard binary only inside the wrapper package, removing a duplicate platform package from each archive. - Changed package resource glob expansion to use Node.js's built-in implementation with deterministic visible-path matching, reducing the installed runtime dependency tree. @@ -18,18 +40,38 @@ ### Fixed -- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8460](https://github.com/earendil-works/pi/issues/8460)). +- Fixed failed extension factories leaving event subscriptions, provider registrations, and default flag state active ([#8424](https://github.com/earendil-works/pi/pull/8424) by [@acmerfight](https://github.com/acmerfight)). +- Fixed `models.json` typings omitting the documented OpenAI-compatible `compat.supportsFinishReason` provider and model override ([#8487](https://github.com/earendil-works/pi/pull/8487) by [@petrroll](https://github.com/petrroll)). +- Fixed `/model` and `/thinking` selections being persisted globally unless explicitly saved with Ctrl+S ([#5263](https://github.com/earendil-works/pi/issues/5263)). +- Fixed JSON and RPC `toolcall_start` events omitting the tool call id and name ([#7953](https://github.com/earendil-works/pi/pull/7953) by [@christianklotz](https://github.com/christianklotz)). +- Fixed extensions failing to load when the Node.js CLI runs as a single-executable application ([#8237](https://github.com/earendil-works/pi/issues/8237)). +- Fixed nested Markdown skills inside `.agents/skills/` grouping directories not being discovered. +- Fixed compaction and branch summarization requests exposing tools to providers. +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays in both coding-agent and harness edit tools ([#7835](https://github.com/earendil-works/pi/issues/7835)). +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). +- Fixed the default Cerebras model referencing an unavailable Z.AI model. +- Fixed inherited OpenAI-compatible Chat Completions reasoning replay to preserve and resend assistant-level `reasoning_details` verbatim and in order ([#7994](https://github.com/earendil-works/pi/issues/7994)). +- Fixed inherited Anthropic server-side fallback responses being priced with the requested model instead of the returned fallback model ([#8285](https://github.com/earendil-works/pi/issues/8285)). +- Fixed inherited GitHub Copilot login triggering model-policy rate limits by limiting policy updates, retrying model discovery once, and honoring server retry delays ([#7850](https://github.com/earendil-works/pi/issues/7850)). +- Fixed inherited Amazon Bedrock dropping and failing to replay opaque redacted reasoning from non-Anthropic models ([#8314](https://github.com/earendil-works/pi/pull/8314) by [@seiji](https://github.com/seiji)). +- Fixed inherited Z.AI Coding Plan models deriving incomplete reasoning-effort metadata, including missing GLM-5.3 low, high, and max levels ([#8336](https://github.com/earendil-works/pi/issues/8336)). +- Fixed inherited DeepSeek V4 Flash on OpenCode and OpenCode Go omitting its supported low thinking level ([#8181](https://github.com/earendil-works/pi/pull/8181) by [@tianshuang](https://github.com/tianshuang)). +- Fixed inherited Azure OpenAI Responses ignoring `toolChoice` in provider-specific stream requests. +- Fixed inherited Amazon Bedrock response hooks receiving only a synthesized request id instead of the raw response headers ([#8234](https://github.com/earendil-works/pi/issues/8234)). +- Fixed inherited Kimi usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). +- Fixed inherited Google custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). - Fixed writes to `auth.json` and `models-store.json` overriding administrator-managed file permissions and ACLs ([#7779](https://github.com/earendil-works/pi/issues/7779)). - Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). - Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). -- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). +- Fixed inherited padded text exceeding narrow terminal widths ([#8252](https://github.com/earendil-works/pi/issues/8252)). +- Fixed inherited wrapped Markdown table links leaking color into borders and neighboring cells, including tables inside blockquotes ([#8335](https://github.com/earendil-works/pi/issues/8335)). - Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). - Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). - Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). From 4e58f324fae8ebfa98a3d45181fb248072a2afac Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:50:48 +0200 Subject: [PATCH 270/284] Release v0.84.3 --- package-lock.json | 60 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 6 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 4 +- packages/client/CHANGELOG.md | 2 +- packages/client/package.json | 4 +- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/gondolin/package-lock.json | 4 +- .../examples/extensions/gondolin/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +- .../extensions/with-deps/package.json | 2 +- .../install-lock/package-lock.json | 52 ++++++++-------- .../coding-agent/install-lock/package.json | 4 +- packages/coding-agent/npm-shrinkwrap.json | 46 +++++++------- packages/coding-agent/package.json | 12 ++-- packages/evals/package.json | 6 +- packages/protocol/CHANGELOG.md | 2 +- packages/protocol/package.json | 2 +- packages/server/CHANGELOG.md | 2 +- packages/server/package.json | 6 +- .../session-backends/sqlite-node/CHANGELOG.md | 2 +- .../session-backends/sqlite-node/package.json | 6 +- packages/telemetry/CHANGELOG.md | 2 +- packages/telemetry/package.json | 2 +- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 32 files changed, 128 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2ad9261a0d..37842d14c45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5329,11 +5329,11 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -5358,12 +5358,12 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", @@ -5386,10 +5386,10 @@ }, "packages/client": { "name": "@earendil-works/pi-client", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", @@ -5401,14 +5401,14 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5447,32 +5447,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.84.2" + "version": "0.84.3" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.14.2", + "version": "1.14.3", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "ms": "2.1.3" }, @@ -5526,10 +5526,10 @@ }, "packages/evals": { "name": "@earendil-works/pi-evals", - "version": "0.84.2", + "version": "0.84.3", "devDependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-coding-agent": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-coding-agent": "^0.84.3", "@types/node": "22.19.19", "shx": "0.4.0", "typescript": "5.9.3", @@ -5539,7 +5539,7 @@ }, "packages/protocol": { "name": "@earendil-works/pi-protocol", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -5554,11 +5554,11 @@ }, "packages/server": { "name": "@earendil-works/pi-server", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", @@ -5570,11 +5570,11 @@ }, "packages/session-backends/sqlite-node": { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2" + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", @@ -5586,7 +5586,7 @@ }, "packages/telemetry": { "name": "@earendil-works/pi-telemetry", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "devDependencies": { "@types/node": "22.19.19", @@ -5598,7 +5598,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index dee760b6a80..a6bc8377023 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index b1b81d85c1c..e472eaac5cb 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.84.2", + "version": "0.84.3", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -35,8 +35,8 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9bae0529dae..f3281fa01d7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ### Breaking Changes diff --git a/packages/ai/package.json b/packages/ai/package.json index 38fc4f46e24..70c8d498693 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.84.2", + "version": "0.84.3", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -62,7 +62,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 49ecd548fc2..cd2fa5f8cae 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/client/package.json b/packages/client/package.json index 45d5a103f4b..5ca649b9151 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-client", - "version": "0.84.2", + "version": "0.84.3", "description": "Transport-neutral client for remote pi sessions over framed CBOR bytes", "type": "module", "main": "./dist/index.js", @@ -47,7 +47,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 24776576236..fde46b8a549 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ### New Features diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 272f0fcb735..a5ce47769f9 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 7c8231631ea..9de50e63bbd 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.84.2", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 9a8e14ff5be..247c4d5b4d3 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.84.2", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index df80c87fd8e..1beb731ef47 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 450c74f69d4..eac937af065 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.84.2", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 22d48511490..6a422b32bff 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.14.2", + "version": "1.14.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.14.2", + "version": "1.14.3", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 57ba99af509..2d37b5c82fa 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.14.2", + "version": "1.14.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index fc39ce641c6..b0d0855aad7 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 907d96bc1a3..3532ad7a724 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.84.2", + "version": "0.84.3", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index c594cf91fbc..44ab528ce36 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.2", + "version": "0.84.3", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.2" + "@earendil-works/pi-coding-agent": "0.84.3" }, "engines": { "node": ">=22.19.0" @@ -450,12 +450,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -466,13 +466,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", @@ -489,26 +489,26 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-protocol": "^0.84.3" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -536,8 +536,8 @@ } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.3.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -547,16 +547,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.3.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index 4c35a51ec80..e7cf5a2af37 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.84.2", + "version": "0.84.3", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.84.2" + "@earendil-works/pi-coding-agent": "0.84.3" }, "overrides": { "protobufjs": "7.6.5", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 1b892389f27..f65c23df9a5 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,19 +1,19 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.2", + "version": "0.84.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.2", + "version": "0.84.3", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -476,12 +476,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-telemetry": "^0.84.3", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -492,13 +492,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.3.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@earendil-works/pi-telemetry": "^0.84.2", + "@earendil-works/pi-telemetry": "^0.84.3", "@google/genai": "1.52.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", @@ -515,19 +515,19 @@ } }, "node_modules/@earendil-works/pi-client": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.3.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-protocol": "^0.84.3" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-protocol": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.3.tgz", "license": "MIT", "dependencies": { "typebox": "1.3.7" @@ -537,16 +537,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.3.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.84.2", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", + "version": "0.84.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.3.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 11b6cd28e20..02f45a37b3a 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.84.2", + "version": "0.84.3", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -44,11 +44,11 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.84.2", - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-client": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2", - "@earendil-works/pi-tui": "^0.84.2", + "@earendil-works/pi-agent-core": "^0.84.3", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-client": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.3", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/evals/package.json b/packages/evals/package.json index a6fa94da582..b02e89a8eca 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-evals", - "version": "0.84.2", + "version": "0.84.3", "private": true, "type": "module", "scripts": { @@ -9,8 +9,8 @@ "test": "vitest run --config vitest.test.config.ts" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-coding-agent": "^0.84.2", + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-coding-agent": "^0.84.3", "@types/node": "22.19.19", "shx": "0.4.0", "typescript": "5.9.3", diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index d4328d9eae4..487363b2e4e 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 1c943ec27c8..5ac7bf7e726 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-protocol", - "version": "0.84.2", + "version": "0.84.3", "description": "Transport-neutral CBOR protocol for remote pi sessions", "type": "module", "main": "./dist/index.js", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 1725d40d7cd..5872182750f 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/server/package.json b/packages/server/package.json index cdbbf338a25..b079277bf38 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-server", - "version": "0.84.2", + "version": "0.84.3", "description": "experimental server package for pi", "type": "module", "main": "./dist/index.js", @@ -47,8 +47,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-protocol": "^0.84.2" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-protocol": "^0.84.3" }, "devDependencies": { "shx": "0.4.0", diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index 30064cb53c3..59d3f84f433 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/session-backends/sqlite-node/package.json b/packages/session-backends/sqlite-node/package.json index 145fffeac68..6849b14e898 100644 --- a/packages/session-backends/sqlite-node/package.json +++ b/packages/session-backends/sqlite-node/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-session-backend-sqlite-node", - "version": "0.84.2", + "version": "0.84.3", "description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions", "type": "module", "main": "./dist/index.js", @@ -34,8 +34,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-ai": "^0.84.2", - "@earendil-works/pi-agent-core": "^0.84.2" + "@earendil-works/pi-ai": "^0.84.3", + "@earendil-works/pi-agent-core": "^0.84.3" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index e9de35b6460..91cfb265649 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 8b6c27780b1..49ffa4396d0 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-telemetry", - "version": "0.84.2", + "version": "0.84.3", "description": "Vendor-neutral telemetry contracts and typed schema utilities for pi", "type": "module", "main": "./dist/index.js", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3bd2a2fda65..4b0e494c220 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 034d351ab48..7e015439b9b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.84.2", + "version": "0.84.3", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From 31d4ed586092309b8835edf22e79cb993fb82091 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:50:52 +0200 Subject: [PATCH 271/284] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/client/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/protocol/CHANGELOG.md | 2 ++ packages/server/CHANGELOG.md | 2 ++ packages/session-backends/sqlite-node/CHANGELOG.md | 2 ++ packages/telemetry/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 9 files changed, 18 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index a6bc8377023..c057c322855 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f3281fa01d7..6cc3e74da6f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ### Breaking Changes diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index cd2fa5f8cae..726fa233d9e 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fde46b8a549..1761c8d0f21 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ### New Features diff --git a/packages/protocol/CHANGELOG.md b/packages/protocol/CHANGELOG.md index 487363b2e4e..f0c58f21c72 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 5872182750f..ab89c9590b7 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index 59d3f84f433..c9368d12585 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 91cfb265649..e11e6186a90 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ## [0.84.2] - 2026-08-14 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4b0e494c220..cc92212ee18 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.84.3] - 2026-08-24 ### Fixed From bfb004d4418ff05c6f909eaaab856cbe75c1fde0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:53:52 +0200 Subject: [PATCH 272/284] fix: extract Windows release ZIPs in CI --- .github/workflows/build-binaries.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index f007f26e125..37facbe2f88 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -182,7 +182,14 @@ jobs: esac mkdir -p extracted - tar -xf "${archive}" -C extracted + if [[ "${archive}" == *.zip ]]; then + export ARCHIVE_PATH="$(cygpath -w "${archive}")" + export DESTINATION_PATH="$(cygpath -w extracted)" + powershell.exe -NoProfile -NonInteractive -Command \ + 'Expand-Archive -LiteralPath $env:ARCHIVE_PATH -DestinationPath $env:DESTINATION_PATH -Force' + else + tar -xf "${archive}" -C extracted + fi echo "root=${root}" >> "${GITHUB_OUTPUT}" echo "binary=${binary}" >> "${GITHUB_OUTPUT}" From 97fa14e39cfce78c273a36b2d9e8509cd5bc6b72 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 24 Aug 2026 13:07:30 +0200 Subject: [PATCH 273/284] fix(coding-agent): reject truncated compaction summaries closes #7048 --- packages/coding-agent/CHANGELOG.md | 2 + .../core/compaction/branch-summarization.ts | 7 +-- .../src/core/compaction/compaction.ts | 24 ++++++++-- .../test/branch-summarization.test.ts | 24 ++++++++++ .../test/compaction-summary-reasoning.test.ts | 33 +++++++++++++ .../7048-compaction-truncated-summary.test.ts | 48 +++++++++++++++++++ 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1761c8d0f21..850c304a9d9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -66,6 +66,8 @@ - Fixed UTF-8 BOM markers preventing frontmatter and user configuration files from loading ([#8337](https://github.com/earendil-works/pi/issues/8337)). - Fixed invalid settings files being easy to miss during interactive startup by rendering warnings with the file path inside the TUI ([#7829](https://github.com/earendil-works/pi/issues/7829)). - Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)). +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). +- Fixed truncated compaction and branch summaries being persisted when generation reaches its output token limit ([#7048](https://github.com/earendil-works/pi/issues/7048)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 8b0d6d3c53a..d0abd545098 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -16,7 +16,7 @@ import { createCustomMessage, } from "../messages.ts"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts"; -import { completeSummarization, estimateTokens } from "./compaction.ts"; +import { completeSummarization, estimateTokens, getSummarizationFailure } from "./compaction.ts"; import { computeFileLists, createFileOps, @@ -354,8 +354,9 @@ export async function generateBranchSummary( if (response.stopReason === "aborted") { return { aborted: true }; } - if (response.stopReason === "error") { - return { error: response.errorMessage || "Summarization failed" }; + const failure = getSummarizationFailure(response, "Branch summarization"); + if (failure) { + return { error: failure }; } if (response.content.some((block) => block.type === "toolCall")) { return { error: "Branch summarization attempted to call a tool" }; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index b7df9da34e1..cd442d4aa15 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -538,6 +538,20 @@ const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation mes ${UPDATE_SUMMARIZATION_INSTRUCTIONS}`; +/** + * Returns an error message when a summarization response cannot safely be persisted. + * A length stop contains partial text and must not become a session checkpoint. + */ +export function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined { + if (response.stopReason === "error") { + return `${label} failed: ${response.errorMessage || "Unknown error"}`; + } + if (response.stopReason === "length") { + return `${label} failed: generation hit the token cap and the summary is incomplete`; + } + return undefined; +} + function createSummarizationOptions( model: Model, maxTokens: number, @@ -699,8 +713,9 @@ export async function generateSummaryWithUsage( callbacks, ); - if (response.stopReason === "error") { - throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); + const failure = getSummarizationFailure(response, "Summarization"); + if (failure) { + throw new Error(failure); } if (response.content.some((block) => block.type === "toolCall")) { throw new Error("Summarization attempted to call a tool"); @@ -983,8 +998,9 @@ async function generateTurnPrefixSummary( callbacks, ); - if (response.stopReason === "error") { - throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); + const failure = getSummarizationFailure(response, "Turn prefix summarization"); + if (failure) { + throw new Error(failure); } if (response.content.some((block) => block.type === "toolCall")) { throw new Error("Turn prefix summarization attempted to call a tool"); diff --git a/packages/coding-agent/test/branch-summarization.test.ts b/packages/coding-agent/test/branch-summarization.test.ts index 875dce881b3..e54da181fea 100644 --- a/packages/coding-agent/test/branch-summarization.test.ts +++ b/packages/coding-agent/test/branch-summarization.test.ts @@ -87,4 +87,28 @@ describe("branch summarization", () => { expect(result.error).toBe("Branch summarization attempted to call a tool"); }); + + it("rejects length-limited branch summaries", async () => { + const streamFn: StreamFn = () => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => + stream.push({ + type: "done", + reason: "length", + message: { ...response([{ type: "text", text: "partial" }]), stopReason: "length" }, + }), + ); + return stream; + }; + + const result = await generateBranchSummary(entries, { + model, + signal: new AbortController().signal, + streamFn, + }); + + expect(result.error).toBe( + "Branch summarization failed: generation hit the token cap and the summary is incomplete", + ); + }); }); diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 037469cabc7..5a4d8e86b53 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -173,6 +173,39 @@ describe("generateSummary reasoning options", () => { ); }); + it("rejects a length-limited history summary", async () => { + completeSimpleMock.mockResolvedValueOnce({ + ...mockSummaryResponse, + stopReason: "length", + content: [{ type: "text", text: "partial" }], + }); + + await expect(generateSummaryWithUsage(messages, createModel(false), 2000, "test-key")).rejects.toThrow( + "generation hit the token cap", + ); + }); + + it("rejects a length-limited split-turn summary", async () => { + completeSimpleMock.mockResolvedValueOnce({ + ...mockSummaryResponse, + stopReason: "length", + content: [{ type: "text", text: "partial" }], + }); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: [], + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + await expect(compact(preparation, createModel(false), "test-key")).rejects.toThrow( + "generation hit the token cap", + ); + }); + it("does not set reasoning when thinking is off", async () => { await generateSummary( messages, diff --git a/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts b/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts new file mode 100644 index 00000000000..7935951ddbb --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7048-compaction-truncated-summary.test.ts @@ -0,0 +1,48 @@ +import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, type Harness } from "../harness.ts"; + +function seedCompactableSession(harness: Harness): void { + harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + const now = Date.now(); + harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "message to compact" }], + timestamp: now - 1000, + }); + const model = harness.getModel(); + const assistant: AssistantMessage = { + ...fauxAssistantMessage("assistant response to compact", { timestamp: now - 500 }), + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 100, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 100, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + harness.sessionManager.appendMessage(assistant); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; +} + +describe("#7048 truncated compaction summaries", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + harness = undefined; + }); + + it("does not persist a length-limited summary", async () => { + harness = await createHarness(); + seedCompactableSession(harness); + harness.setResponses([fauxAssistantMessage("partial summar", { stopReason: "length" })]); + + await expect(harness.session.compact()).rejects.toThrow("generation hit the token cap"); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(0); + }); +}); From dcd461925db2edf69a43c8135db1180d418afd54 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:29:51 +0200 Subject: [PATCH 274/284] feat: show llama presets if autoload enabled (#8558) --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/extensions/llama/client.ts | 11 +++ .../src/extensions/llama/provider.ts | 39 ++++++++-- .../coding-agent/test/llama-extension.test.ts | 72 +++++++++++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 850c304a9d9..d3885a628f5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -69,7 +69,7 @@ - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed truncated compaction and branch summaries being persisted when generation reaches its output token limit ([#7048](https://github.com/earendil-works/pi/issues/7048)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). -- Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). +- Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models plus autoloadable unloaded presets in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). diff --git a/packages/coding-agent/src/extensions/llama/client.ts b/packages/coding-agent/src/extensions/llama/client.ts index 45cd8f3a650..c071f518ccc 100644 --- a/packages/coding-agent/src/extensions/llama/client.ts +++ b/packages/coding-agent/src/extensions/llama/client.ts @@ -28,6 +28,10 @@ export interface LlamaModelsResponse { object?: string; } +export interface LlamaServerProps { + models_autoload?: boolean; +} + export interface LlamaModelEvent { model: string; event: string; @@ -189,6 +193,13 @@ export class LlamaClient { return data; } + async props(options: { signal?: AbortSignal } = {}): Promise { + const payload = await this.request("/props", { signal: options.signal }); + if (typeof payload !== "object" || payload === null) return {}; + const { models_autoload: modelsAutoload } = payload as Record; + return typeof modelsAutoload === "boolean" ? { models_autoload: modelsAutoload } : {}; + } + async load(model: string, signal?: AbortSignal): Promise { await this.request("/models/load", { method: "POST", body: JSON.stringify({ model }), signal }); } diff --git a/packages/coding-agent/src/extensions/llama/provider.ts b/packages/coding-agent/src/extensions/llama/provider.ts index 83fa72ddc1a..6a685938d08 100644 --- a/packages/coding-agent/src/extensions/llama/provider.ts +++ b/packages/coding-agent/src/extensions/llama/provider.ts @@ -25,9 +25,25 @@ async function resolveServerUrl( return configured ? normalizeLlamaServerUrl(configured) : undefined; } -function modelIsSelectable(model: LlamaModelInfo): boolean { +function modelIsSelectable(model: LlamaModelInfo, routerAutoload: boolean): boolean { + if (model.status.value === "loaded") return true; // llama.cpp reports idle-slept models as "sleeping"; requests wake them automatically. - return model.status.value === "loaded" || model.status.value === "sleeping"; + if (model.status.value === "sleeping") return true; + // Unloaded presets are routable only when llama.cpp router autoload can load them on first use. + return routerAutoload && model.status.value === "unloaded" && !model.status.failed && model.source === "preset"; +} + +async function routerAutoloadEnabled( + client: LlamaClient, + catalog: readonly LlamaModelInfo[], + signal: AbortSignal, +): Promise { + if (!catalog.some((model) => model.status.value === "unloaded" && model.source === "preset")) return false; + try { + return (await client.props({ signal })).models_autoload === true; + } catch { + return false; + } } function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-completions"> { @@ -57,14 +73,20 @@ function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-comp export interface LlamaProviderController { provider: Provider<"openai-completions">; - setCatalog(models: readonly LlamaModelInfo[], serverUrl: string): void; + setCatalog(models: readonly LlamaModelInfo[], serverUrl: string, options?: { routerAutoload?: boolean }): void; } export function createLlamaProvider(): LlamaProviderController { let models: readonly Model<"openai-completions">[] = []; - const setCatalog = (catalog: readonly LlamaModelInfo[], serverUrl: string): void => { - models = catalog.filter((model) => modelIsSelectable(model)).map((model) => toPiModel(model, serverUrl)); + const setCatalog = ( + catalog: readonly LlamaModelInfo[], + serverUrl: string, + options: { routerAutoload?: boolean } = {}, + ): void => { + models = catalog + .filter((model) => modelIsSelectable(model, options.routerAutoload === true)) + .map((model) => toPiModel(model, serverUrl)); }; const provider: Provider<"openai-completions"> = { @@ -135,10 +157,13 @@ export function createLlamaProvider(): LlamaProviderController { if (!context.allowNetwork || context.signal.aborted || context.credential?.type !== "api_key") return; const serverUrl = credentialServerUrl(context.credential); if (!serverUrl) return; - const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal }); + const client = new LlamaClient(serverUrl, context.credential.key); + const catalog = await client.list({ signal: context.signal }); + if (context.signal.aborted) return; + const routerAutoload = await routerAutoloadEnabled(client, catalog, context.signal); if (context.signal.aborted) return; const refreshed = catalog - .filter((model) => modelIsSelectable(model)) + .filter((model) => modelIsSelectable(model, routerAutoload)) .map((model) => toPiModel(model, serverUrl)); await context.publish({ persist: { models: refreshed, checkedAt: Date.now() }, diff --git a/packages/coding-agent/test/llama-extension.test.ts b/packages/coding-agent/test/llama-extension.test.ts index 7d46c76c9ab..7c506aafc91 100644 --- a/packages/coding-agent/test/llama-extension.test.ts +++ b/packages/coding-agent/test/llama-extension.test.ts @@ -138,6 +138,78 @@ describe("llama.cpp extension", () => { ]); }); + it("exposes unloaded presets only when router autoload is enabled", async () => { + let propsRequests = 0; + const { url } = await listen((request, response) => { + expect(request.headers.authorization).toBe("Bearer local"); + if (request.url === "/models") { + json(response, { + data: [ + { id: "preset", status: { value: "unloaded" }, source: "preset", meta: { n_ctx: 65536 } }, + { id: "failed-preset", status: { value: "unloaded", failed: true }, source: "preset" }, + { id: "cache", status: { value: "unloaded" }, source: "cache" }, + { id: "models-dir", status: { value: "unloaded" }, source: "models_dir" }, + ], + }); + return; + } + if (request.url === "/props") { + propsRequests++; + json(response, { role: "router", models_autoload: true }); + return; + } + response.writeHead(404).end(); + }); + + let cachedEntry: ModelsStoreEntry | undefined; + const controller = createLlamaProvider(); + await controller.provider.refreshModels?.({ + credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } }, + stored: undefined, + publish: async (publication) => { + if (publication.persist !== undefined && publication.persist !== null) { + cachedEntry = structuredClone(publication.persist); + } + publication.update?.(); + return true; + }, + allowNetwork: true, + signal: new AbortController().signal, + }); + + expect(propsRequests).toBe(1); + expect(controller.provider.getModels().map((model) => model.id)).toEqual(["preset"]); + expect(cachedEntry?.models.map((model) => model.id)).toEqual(["preset"]); + }); + + it("hides unloaded presets when router autoload is disabled", async () => { + const { url } = await listen((request, response) => { + if (request.url === "/models") { + json(response, { data: [{ id: "preset", status: { value: "unloaded" }, source: "preset" }] }); + return; + } + if (request.url === "/props") { + json(response, { role: "router", models_autoload: false }); + return; + } + response.writeHead(404).end(); + }); + + const controller = createLlamaProvider(); + await controller.provider.refreshModels?.({ + credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } }, + stored: undefined, + publish: async (publication) => { + publication.update?.(); + return true; + }, + allowNetwork: true, + signal: new AbortController().signal, + }); + + expect(controller.provider.getModels()).toEqual([]); + }); + it("stays dormant until configured and stores URL plus optional key", async () => { const { provider } = createLlamaProvider(); const auth = provider.auth.apiKey!; From b7170b86a609028618cd2efe9b31f3f2b347ad4d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 08:13:02 +0000 Subject: [PATCH 275/284] chore: approve contributors from issue #8388 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index fb8384f185b..5b42a4e200c 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -375,3 +375,5 @@ gaoyk19 pr cad0p pr Jaaneek pr + +CaiJichang212 pr From c5de2cc67f04d2e700617f9452a22a4242aaa1a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 08:17:15 +0000 Subject: [PATCH 276/284] chore: approve contributors from issue #8409 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 5b42a4e200c..f8a343b03ae 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -377,3 +377,5 @@ cad0p pr Jaaneek pr CaiJichang212 pr + +Mallikarjun-0 pr From e8c632ef64f99b0d2ce7cc7c215aecb5c66b0016 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:03:13 +0200 Subject: [PATCH 277/284] fix(ai): cloudflare gateway type, include workers Fixes https://github.com/earendil-works/pi/actions/runs/32825925694/job/97733792662?pr=8605 build error --- packages/ai/scripts/generate-models.ts | 37 +++++++++++++++++++ .../ai/src/providers/cloudflare-ai-gateway.ts | 8 ++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d91c999166c..9bd3396ceef 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1666,6 +1666,7 @@ async function loadModelsDevData(): Promise[]> { } // Process Cloudflare AI Gateway models + const cloudflareAIGatewayModelIds = new Set(); if (data["cloudflare-ai-gateway"]?.models) { for (const [prefixedId, model] of Object.entries(data["cloudflare-ai-gateway"].models)) { const m = model as ModelsDevModel; @@ -1700,6 +1701,7 @@ async function loadModelsDevData(): Promise[]> { const compat = upstream === "anthropic" || upstream === "workers-ai" ? { sendSessionAffinityHeaders: true } : undefined; + cloudflareAIGatewayModelIds.add(id); models.push({ id, name: m.name || id, @@ -1722,6 +1724,41 @@ async function loadModelsDevData(): Promise[]> { } } + // models.dev may omit Workers AI passthroughs from the AI Gateway provider + // list even though the gateway /compat endpoint supports routing to them. + // Mirror the Workers AI catalog under the documented workers-ai/ prefix so + // the gateway keeps its OpenAI-compatible /compat models stable. + if (data["cloudflare-workers-ai"]?.models) { + for (const [modelId, model] of Object.entries(data["cloudflare-workers-ai"].models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + const id = `workers-ai/${modelId}`; + if (cloudflareAIGatewayModelIds.has(id)) continue; + cloudflareAIGatewayModelIds.add(id); + + models.push({ + id, + name: m.name || id, + api: "openai-completions", + provider: "cloudflare-ai-gateway", + baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + compat: { sendSessionAffinityHeaders: true }, + }); + recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m); + } + } + // Process xAi models if (data.xai?.models) { for (const [modelId, model] of Object.entries(data.xai.models)) { diff --git a/packages/ai/src/providers/cloudflare-ai-gateway.ts b/packages/ai/src/providers/cloudflare-ai-gateway.ts index 50c10569ff0..7c1cc3b78a1 100644 --- a/packages/ai/src/providers/cloudflare-ai-gateway.ts +++ b/packages/ai/src/providers/cloudflare-ai-gateway.ts @@ -6,10 +6,10 @@ import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts" import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts"; import { cloudflareStreams } from "./cloudflare-stream.ts"; -export function cloudflareAIGatewayProvider(): Provider< - "anthropic-messages" | "openai-completions" | "openai-responses" -> { - return createProvider({ +type CloudflareAIGatewayApi = "anthropic-messages" | "openai-completions" | "openai-responses"; + +export function cloudflareAIGatewayProvider(): Provider { + return createProvider({ id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: cloudflareAIGatewayAuth() }, From 5cd6a2a502a723398326ef0bfaa62a83dd90065f Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:12:15 +0200 Subject: [PATCH 278/284] fix(ai-test): update glm 5.3 price --- packages/ai/test/zai-coding-plan-models.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ai/test/zai-coding-plan-models.test.ts b/packages/ai/test/zai-coding-plan-models.test.ts index 5ed6658dc65..2d0ef25b3f4 100644 --- a/packages/ai/test/zai-coding-plan-models.test.ts +++ b/packages/ai/test/zai-coding-plan-models.test.ts @@ -41,6 +41,14 @@ it("uses API-equivalent reference costs for Coding Plan models", () => { cacheRead: 0.24, cacheWrite: 0, }); + for (const provider of ["zai", "zai-coding-cn"] as const) { + expect(getBuiltinModel(provider, "glm-5.3").cost).toEqual({ + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }); + } }); it("keeps zero costs for Coding Plan models without a matching API price", () => { @@ -48,6 +56,5 @@ it("keeps zero costs for Coding Plan models without a matching API price", () => for (const provider of ["zai", "zai-coding-cn"] as const) { expect(getBuiltinModel(provider, "glm-5.2-highspeed").cost).toEqual(zeroCost); - expect(getBuiltinModel(provider, "glm-5.3").cost).toEqual(zeroCost); } }); From c5ad7c1b0f7623bbfdf64dd4967fa6e99c15c01a Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:19:31 +0200 Subject: [PATCH 279/284] fix(ai): concatenate openai completions reasoning deltas (#8605) --- packages/ai/src/api/openai-completions.ts | 32 ++++++++- ...enai-completions-reasoning-details.test.ts | 67 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 08b181f230b..04326ca2f7d 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -247,6 +247,31 @@ function parseLegacyEncryptedReasoningDetail( } } +function fillMissingCommonReasoningDetailFields( + target: OpenAIReasoningDetailBase, + source: OpenAIReasoningDetail, +): void { + target.id ??= source.id; + target.format ||= source.format; + target.index ??= source.index; +} + +function appendOpenAIReasoningDetail(details: OpenAIReasoningDetail[], detail: OpenAIReasoningDetail): void { + const lastDetail = details[details.length - 1]; + if (detail.type === "reasoning.text" && lastDetail?.type === "reasoning.text") { + lastDetail.text += detail.text; + lastDetail.signature ||= detail.signature; + fillMissingCommonReasoningDetailFields(lastDetail, detail); + return; + } + if (detail.type === "reasoning.summary" && lastDetail?.type === "reasoning.summary") { + lastDetail.summary += detail.summary; + fillMissingCommonReasoningDetailFields(lastDetail, detail); + return; + } + details.push({ ...detail }); +} + const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const; type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number]; @@ -623,9 +648,10 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio if (!isOpenAIReasoningDetail(detail)) continue; const block = ensureThinkingBlock(""); const preservedDetails = parseOpenAIReasoningDetails(block.thinkingSignature) ?? []; - preservedDetails.push(detail); - // Keep provider replay data in the existing signature slot. OpenRouter - // requires the complete reasoning_details sequence in its original order. + appendOpenAIReasoningDetail(preservedDetails, detail); + // Keep provider replay data in the existing signature slot. OpenRouter streams + // reasoning_details as deltas: consecutive text/summary deltas are merged into + // logical entries, while encrypted entries remain opaque and discrete. block.thinkingSignature = JSON.stringify(preservedDetails); } } diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index 723a51b79a5..a76c3b1dd8e 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -181,4 +181,71 @@ describe("openai-completions reasoning_details streaming", () => { expect(payload?.reasoning_details).toEqual(expectedReasoningDetails); expect(payload?.reasoning).toBeUndefined(); }); + + it("merges consecutive text and summary reasoning_details deltas before replay", async () => { + const textDelta = { type: "reasoning.text", text: "The", index: 0 }; + const textDeltaWithSignature = { + type: "reasoning.text", + text: " user wants the time.", + signature: "sha256:text-signature", + format: "openai-responses-v1", + index: 0, + }; + const summaryDelta = { type: "reasoning.summary", summary: "Looked", index: 0 }; + const summaryDeltaWithFormat = { + type: "reasoning.summary", + summary: " up time.", + format: "openai-responses-v1", + index: 0, + }; + const laterSummaryDelta = { + type: "reasoning.summary", + summary: "After encrypted block.", + format: "openai-responses-v1", + index: 0, + }; + const expectedReasoningDetails = [ + { + type: "reasoning.text", + text: "The user wants the time.", + index: 0, + signature: "sha256:text-signature", + format: "openai-responses-v1", + }, + { + type: "reasoning.summary", + summary: "Looked up time.", + index: 0, + format: "openai-responses-v1", + }, + reasoningDetail, + laterSummaryDelta, + ]; + + mockState.chunkSets = [ + [ + chunk({ reasoning_details: [textDelta] }), + chunk({ reasoning_details: [textDeltaWithSignature] }), + chunk({ reasoning_details: [summaryDelta] }), + chunk({ reasoning_details: [summaryDeltaWithFormat] }), + chunk({ reasoning_details: [reasoningDetail] }), + chunk({ reasoning_details: [laterSummaryDelta] }), + toolCallChunk(), + chunk({}, "tool_calls"), + ], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: "", + thinkingSignature: JSON.stringify(expectedReasoningDetails), + }); + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); + }); }); From de82e536738f10fda8e53404d2968241f3fcd96a Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Tue, 25 Aug 2026 06:39:16 -0300 Subject: [PATCH 280/284] feat(coding-agent): export image MIME detector (#8600) --- packages/coding-agent/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 210b84010d2..52d8f9b0627 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -415,5 +415,6 @@ export { copyToClipboard } from "./utils/clipboard.ts"; export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; export { convertToPng } from "./utils/image-convert.ts"; export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; +export { detectSupportedImageMimeTypeFromFile } from "./utils/mime.ts"; // Shell utilities export { getPowerShellConfig, getShellConfig } from "./utils/shell.ts"; From cacb5917f9712d7e80807f540b7dd70885422bf6 Mon Sep 17 00:00:00 2001 From: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:43:36 +0800 Subject: [PATCH 281/284] fix: export ToolExecution*Event types from public API (#6847) Add missing ToolExecutionEndEvent, ToolExecutionStartEvent, and ToolExecutionUpdateEvent type exports to packages/coding-agent/src/index.ts so they are available to consumers of the package. Fixes #6687 From 45207864febb4658c294d075e761533a9b2f79f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 09:47:58 +0000 Subject: [PATCH 282/284] chore: approve contributors from issue #8574 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index f8a343b03ae..aa90960e269 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -379,3 +379,5 @@ Jaaneek pr CaiJichang212 pr Mallikarjun-0 pr + +wutongyuonce pr From c1729a0f7532d553cf590da3958ad0c18162f770 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 09:54:56 +0000 Subject: [PATCH 283/284] chore: approve contributors from issue #8594 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index aa90960e269..37dc4fee835 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -381,3 +381,5 @@ CaiJichang212 pr Mallikarjun-0 pr wutongyuonce pr + +Terminator666666 pr From 0c03bc905b8ab5ccb8a4195841c44eca783fabdf Mon Sep 17 00:00:00 2001 From: Stan Date: Tue, 25 Aug 2026 15:36:59 +0500 Subject: [PATCH 284/284] Add AI/ML API (aimlapi.com) as a built-in provider Registers "aimlapi" alongside the other built-in providers: a live-catalog fetcher in generate-models.ts (api.aimlapi.com/v1/models?include=pricing already returns per-model pricing, including tiered/threshold pricing and a reasoning-phase signal used to set the `reasoning` flag), the generated provider shard/catalog wiring, the aimlapi.ts provider factory (OpenAI-completions compatible, AIMLAPI_API_KEY), and referral/attribution headers (X-AIMLAPI-Partner-ID, X-AIMLAPI-Source) gated the same way OpenRouter's own attribution headers already are, behind install telemetry. --- packages/ai/scripts/generate-models.ts | 97 ++++++++++++++++++- packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 3 + packages/ai/src/providers/aimlapi.models.ts | 8 ++ packages/ai/src/providers/aimlapi.ts | 17 ++++ packages/ai/src/providers/all.ts | 2 + packages/ai/src/types.ts | 1 + .../coding-agent/src/core/model-resolver.ts | 1 + .../src/core/provider-attribution.ts | 17 ++++ 9 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 packages/ai/src/providers/aimlapi.models.ts create mode 100644 packages/ai/src/providers/aimlapi.ts diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 9bd3396ceef..3b441e64d6e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1108,6 +1108,100 @@ async function fetchOpenRouterModels(): Promise[]> { } } +const AIMLAPI_MODELS_URL = "https://api.aimlapi.com/v1/models?include=pricing"; +const AIMLAPI_BASE_URL = "https://api.aimlapi.com/v1"; + +interface AimlapiPricingUnit { + content?: string; + author?: string; + origin?: string; + phase?: string; + price?: number; + per?: number; +} + +interface AimlapiPricingThreshold { + from: number; + units: AimlapiPricingUnit[]; +} + +interface AimlapiPricing { + units?: AimlapiPricingUnit[]; + thresholds?: AimlapiPricingThreshold[]; +} + +/** $/unit-price -> $/million tokens, honoring the unit's own `per` denominator. */ +function aimlapiRate(units: AimlapiPricingUnit[] | undefined, match: Partial): number { + const unit = units?.find((candidate) => + Object.entries(match).every(([key, value]) => candidate[key as keyof AimlapiPricingUnit] === value), + ); + if (!unit || typeof unit.price !== "number") return 0; + const per = unit.per || 1_000_000; + return roundCost((unit.price * 1_000_000) / per); +} + +function aimlapiCostFromUnits(units: AimlapiPricingUnit[] | undefined): ModelCost { + return { + input: aimlapiRate(units, { content: "text", author: "user", origin: "provided" }), + output: aimlapiRate(units, { content: "text", author: "model", origin: "generated", phase: "inference" }), + cacheRead: aimlapiRate(units, { content: "text", author: "user", origin: "cached" }), + cacheWrite: aimlapiRate(units, { content: "text", author: "user", origin: "cache_write" }), + }; +} + +/** + * AI/ML API's public catalog exposes live pricing (`?include=pricing`) but no + * tool-calling capability flag, so — matching the filter our own OpenClaude + * fork's aimlapi gateway already applies — every `openai/chat-completions` + * entry is included rather than guessing per-model tool support. + */ +async function fetchAimlapiModels(): Promise[]> { + try { + console.log("Fetching models from AI/ML API..."); + const response = await fetch(AIMLAPI_MODELS_URL); + if (!response.ok) throw new Error(`AI/ML API returned ${response.status}`); + const data = await response.json(); + + const models: Model[] = []; + const seen = new Set(); + for (const model of data.data ?? []) { + if (model.type !== "openai/chat-completions") continue; + if (seen.has(model.id)) continue; + seen.add(model.id); + + const pricing: AimlapiPricing | undefined = model.pricing; + const baseCost = aimlapiCostFromUnits(pricing?.units); + // A reasoning-phase output unit means the model bills (and thus supports) thinking tokens. + const reasoning = (pricing?.units ?? []).some((unit) => unit.phase === "reasoning"); + const tiers = (pricing?.thresholds ?? []) + .filter((threshold) => threshold.from > 0) + .map((threshold) => ({ inputTokensAbove: threshold.from, ...aimlapiCostFromUnits(threshold.units) })); + + const contextWindow = model.info?.contextLength || 4096; + + models.push({ + id: model.id, + name: model.info?.name || model.id, + api: "openai-completions", + baseUrl: AIMLAPI_BASE_URL, + provider: "aimlapi", + reasoning, + input: ["text"], + cost: tiers.length > 0 ? { ...baseCost, tiers } : baseCost, + contextWindow, + maxTokens: Math.min(contextWindow, 8192), + }); + } + + console.log(`Fetched ${models.length} chat models from AI/ML API`); + return models; + } catch (error) { + console.error("Failed to fetch AI/ML API models:", error); + if (generatorOptions.strict) throw error; + return []; + } +} + async function fetchAiGatewayModels(): Promise[]> { try { console.log("Fetching models from Vercel AI Gateway API..."); @@ -2372,9 +2466,10 @@ async function generateModels() { const modelsDevModels = await loadModelsDevData(); const openRouterModels = await fetchOpenRouterModels(); const aiGatewayModels = await fetchAiGatewayModels(); + const aimlapiModels = await fetchAimlapiModels(); // Combine models (models.dev has priority) - const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels].filter( + const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...aimlapiModels].filter( (model) => !(model.provider === "xai" && XAI_BUILTIN_EXCLUDED_MODEL_IDS.has(model.id)) && !((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "gpt-5.3-codex-spark"), diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 5b952b7b510..24b82b52a9b 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -77,6 +77,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { } const envMap: Record = { + aimlapi: "AIMLAPI_API_KEY", "ant-ling": "ANT_LING_API_KEY", "qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY", "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 12952045922..bc5cd3ec9c4 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1,6 +1,7 @@ // This file is auto-generated by scripts/generate-models.ts // Do not edit manually - run 'npm run generate-models' to update +import { AIMLAPI_MODELS } from "./providers/aimlapi.models.ts"; import { AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts"; import { ANT_LING_MODELS } from "./providers/ant-ling.models.ts"; import { ANTHROPIC_MODELS } from "./providers/anthropic.models.ts"; @@ -42,6 +43,7 @@ import { ZAI_MODELS } from "./providers/zai.models.ts"; import { ZAI_CODING_CN_MODELS } from "./providers/zai-coding-cn.models.ts"; export const MODELS: { + readonly "aimlapi": typeof AIMLAPI_MODELS; readonly "amazon-bedrock": typeof AMAZON_BEDROCK_MODELS; readonly "ant-ling": typeof ANT_LING_MODELS; readonly "anthropic": typeof ANTHROPIC_MODELS; @@ -82,6 +84,7 @@ export const MODELS: { readonly "zai": typeof ZAI_MODELS; readonly "zai-coding-cn": typeof ZAI_CODING_CN_MODELS; } = { + "aimlapi": AIMLAPI_MODELS, "amazon-bedrock": AMAZON_BEDROCK_MODELS, "ant-ling": ANT_LING_MODELS, "anthropic": ANTHROPIC_MODELS, diff --git a/packages/ai/src/providers/aimlapi.models.ts b/packages/ai/src/providers/aimlapi.models.ts new file mode 100644 index 00000000000..d317a815c71 --- /dev/null +++ b/packages/ai/src/providers/aimlapi.models.ts @@ -0,0 +1,8 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/aimlapi.json" with { type: "json" }; +import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts"; + +export const AIMLAPI_MODELS: ModelCatalog = + flattenModelCatalog("aimlapi", values); diff --git a/packages/ai/src/providers/aimlapi.ts b/packages/ai/src/providers/aimlapi.ts new file mode 100644 index 00000000000..21098fd82e9 --- /dev/null +++ b/packages/ai/src/providers/aimlapi.ts @@ -0,0 +1,17 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { AIMLAPI_MODELS } from "./aimlapi.models.ts"; + +export function aimlapiProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "aimlapi", + name: "AI/ML API", + baseUrl: "https://api.aimlapi.com/v1", + auth: { + apiKey: envApiKeyAuth("AI/ML API key", ["AIMLAPI_API_KEY"]), + }, + models: Object.values(AIMLAPI_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 8785e94aed6..78d1cb3c530 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -2,6 +2,7 @@ import { createImagesModels, type ImagesProvider, type MutableImagesModels } fro import { MODELS } from "../models.generated.ts"; import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts"; import type { Api, Model } from "../types.ts"; +import { aimlapiProvider } from "./aimlapi.ts"; import { amazonBedrockProvider } from "./amazon-bedrock.ts"; import { antLingProvider } from "./ant-ling.ts"; import { anthropicProvider } from "./anthropic.ts"; @@ -88,6 +89,7 @@ export function getBuiltinModels( /** All built-in providers, freshly constructed. */ export function builtinProviders(): Provider[] { return [ + aimlapiProvider(), amazonBedrockProvider(), antLingProvider(), anthropicProvider(), diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d35a48b84eb..0127d323acf 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -33,6 +33,7 @@ export type KnownImagesApi = "openrouter-images"; export type ImagesApi = KnownImagesApi | (string & {}); export type KnownProvider = + | "aimlapi" | "amazon-bedrock" | "ant-ling" | "anthropic" diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 4bf4ac54128..441f47c16bd 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -18,6 +18,7 @@ import type { ModelRuntime } from "./model-runtime.ts"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { + aimlapi: "openai/gpt-5.5-2026-04-23", "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", "ant-ling": "Ring-2.6-1T", anthropic: "claude-opus-4-8", diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts index 3a541f52767..602d3963092 100644 --- a/packages/coding-agent/src/core/provider-attribution.ts +++ b/packages/coding-agent/src/core/provider-attribution.ts @@ -3,6 +3,7 @@ import type { SettingsManager } from "./settings-manager.ts"; import { isInstallTelemetryEnabled } from "./telemetry.ts"; const OPENROUTER_HOST = "openrouter.ai"; +const AIMLAPI_HOST = "api.aimlapi.com"; const NVIDIA_NIM_HOST = "integrate.api.nvidia.com"; const CLOUDFLARE_API_HOST = "api.cloudflare.com"; const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com"; @@ -20,6 +21,10 @@ function isOpenRouterModel(model: Model): boolean { return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST); } +function isAimlapiModel(model: Model): boolean { + return model.provider === "aimlapi" || matchesHost(model.baseUrl, AIMLAPI_HOST); +} + function isNvidiaNimModel(model: Model): boolean { return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST); } @@ -49,6 +54,18 @@ function getDefaultAttributionHeaders( }; } + if (isAimlapiModel(model)) { + return { + // Rebate attribution id (part_...) for the "pi" partner row in AI/ML + // API's rebate_partners table — do not repoint this to a different + // partner without also updating the backend record. + "X-AIMLAPI-Partner-ID": "part_0OJphKWIKTIItaGwnhjmaGJI", + "X-AIMLAPI-Source": "agent/pi", + "HTTP-Referer": "https://pi.dev", + "X-Title": "pi", + }; + } + if (isNvidiaNimModel(model)) { return { "X-BILLING-INVOKE-ORIGIN": "Pi",