diff --git a/extensions/plan-mode/index.ts b/extensions/plan-mode/index.ts index 47917282..e5d67eed 100644 --- a/extensions/plan-mode/index.ts +++ b/extensions/plan-mode/index.ts @@ -189,6 +189,7 @@ export const PLAN_SAFE_TOOLS = new Set([ "subagent_check", "subagent_list", "subagent_wait", + "subagent_result", "workflow_status", // Delegated investigation. Exploring several subsystems in parallel without // dragging the noise into the main context is one of the most useful things diff --git a/extensions/shared/child-session.ts b/extensions/shared/child-session.ts index 115e2e03..17e8497d 100644 --- a/extensions/shared/child-session.ts +++ b/extensions/shared/child-session.ts @@ -446,6 +446,7 @@ export const CHILD_EXCLUDED_TOOL_NAMES = [ "subagent_send", "subagent_check", "subagent_list", + "subagent_result", // workflows — children cannot recursively orchestrate or manage runs "workflow", "workflow_stop", diff --git a/extensions/shared/tool-surface.ts b/extensions/shared/tool-surface.ts index c3e0f443..23e672b8 100644 --- a/extensions/shared/tool-surface.ts +++ b/extensions/shared/tool-surface.ts @@ -29,6 +29,7 @@ export const OPENPI_TOOL_SURFACE = { "subagent_send", "subagent_check", "subagent_list", + "subagent_result", ], deferred: [], }, diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index db90f2e0..64876e91 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -10,6 +10,7 @@ * - subagent_wait: block until the listed subagents settle, return results. * - subagent_cancel: stop one or more running subagents. * - subagent_check: peek at a subagent's status and recent activity. + * - subagent_result: read bounded pages of a settled exact result by subagent id. * - subagent_list: list all subagents. * * Unawaited subagents queue their result as a follow-up message when they @@ -70,7 +71,11 @@ import { planModeAllowsDeclaredTools, planModeChildTools, } from "../shared/plan-mode-state.ts"; -import { loadSetupConfig, type DetailDisplay } from "../shared/setup-config.ts"; +import { + allocateResultBudgets, + type ParentContextUsage, +} from "../shared/result-budget.ts"; +import { type DetailDisplay, loadSetupConfig } from "../shared/setup-config.ts"; import { OPENPI_TOOL_SURFACE, patchOwnedTools, @@ -83,9 +88,9 @@ import { } from "../shared/worktree.ts"; import { normalizeSubagentTitle, + SubagentStripWidget, selectSubagentStripEntry, subagentStripEntryKey, - SubagentStripWidget, } from "./navigation.ts"; import { type AgentType, @@ -119,23 +124,27 @@ import { SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS, SUBAGENT_CHECK_TOOL_DESCRIPTION, SUBAGENT_LIST_TOOL_DESCRIPTION, + SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS, + SUBAGENT_RESULT_TOOL_DESCRIPTION, SUBAGENT_SEND_PARAMETER_DESCRIPTIONS, SUBAGENT_SEND_TOOL_DESCRIPTION, SUBAGENT_SPAWN_PROMPT_GUIDELINES, SUBAGENT_SPAWN_PROMPT_SNIPPET, - stripSubagentResultTransportInstruction, SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS, SUBAGENT_WAIT_TOOL_DESCRIPTION, + stripSubagentResultTransportInstruction, } from "./src/prompt.ts"; import { + isResultArtifactRef, + MAX_RESULT_PAGE_BYTES, + MAX_RESULT_PAGE_LINES, + pageResultText, persistResultArtifact, projectResult, type ResultProjection, + readResultArtifact, + resolveExactResultText, } from "./src/result-artifact.ts"; -import { - allocateResultBudgets, - type ParentContextUsage, -} from "../shared/result-budget.ts"; import { createSubagentResultDelivery } from "./src/result-delivery.ts"; import { createSubagentRuntime, @@ -144,8 +153,8 @@ import { } from "./src/runtime.ts"; import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts"; import { - renderWaitResultPreview, renderWaitResult, + renderWaitResultPreview, type WaitResultDetails, } from "./src/ui/wait-result.ts"; @@ -185,6 +194,7 @@ interface SubagentResultDetails { readonly artifactSaveFailed?: boolean; readonly fullResultSaved?: boolean; readonly count?: number; + readonly projection?: SubagentProjectionDetails; readonly results?: ReadonlyArray<{ readonly id: string; readonly title: string; @@ -194,11 +204,19 @@ interface SubagentResultDetails { readonly elapsed?: string; readonly artifactSaveFailed?: boolean; readonly fullResultSaved?: boolean; + readonly projection?: SubagentProjectionDetails; }>; /** Display-only projection for the custom message renderer. */ readonly displayContent?: string; } +interface SubagentProjectionDetails { + readonly truncated: boolean; + readonly omittedBytes: number; + readonly omitted: NonNullable["omitted"]; + readonly exactResultAvailable?: boolean; +} + interface SubagentResultEntryData { readonly content: string; readonly details: SubagentResultDetails; @@ -221,38 +239,124 @@ function describeSubagent(snap: SubagentSnapshot) { formatElapsed(snap), snap.cwd, ].filter(Boolean); - return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`; + const projection = projectionNotice(snap); + return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})${projection ? ` · ${projection}` : ""}`; } -export function truncatedOutput( +function exactResultText(snap: SubagentSnapshot): string | undefined { + if (!isResultArtifactRef(snap.resultArtifact)) return undefined; + return readResultArtifact(getAgentDir(), snap.resultArtifact); +} + +function resultText(snap: SubagentSnapshot): string { + const artifact = exactResultText(snap); + // A retained prefix is not an exact-result measurement source. Keep batch + // budgeting conservative until the protected artifact is readable. + if (artifact === undefined && snap.finalTextTruncated) return ""; + return artifact !== undefined + ? artifact || "(no output)" + : snap.finalText || "(no output)"; +} + +function withCanonicalResult( snap: SubagentSnapshot, - maxBytes = SUBAGENT_OUTPUT_MAX_BYTES, - writeArtifact: (content: string) => string = (content) => - persistResultArtifact(getAgentDir(), content), -): string { - const output = snap.finalText || "(no output)"; - return projectResult(output, { - maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES), - maxLines: Math.min(600, DEFAULT_MAX_LINES), - writeArtifact, - }).text; + result: + | Pick< + SubagentSnapshot, + "finalText" | "finalTextTruncated" | "resultArtifact" + > + | undefined, +) { + if (!result) return { snap, resultIsCanonical: false }; + return { + snap: { + ...snap, + finalText: result.finalText, + ...(result.finalTextTruncated + ? { finalTextTruncated: true } + : { finalTextTruncated: undefined }), + resultArtifact: result.resultArtifact, + }, + resultIsCanonical: true, + }; +} + +function projectionDetails( + snap: SubagentSnapshot, +): SubagentProjectionDetails | undefined { + const projection = snap.snapshot; + if (!projection?.truncated) return undefined; + const exactResultAvailable = exactResultText(snap) !== undefined; + return { + truncated: true, + omittedBytes: projection.omittedBytes, + omitted: projection.omitted, + ...(exactResultAvailable ? { exactResultAvailable: true } : {}), + }; } -function projectSubagentOutput( +function projectionNotice(snap: SubagentSnapshot): string | undefined { + const projection = snap.snapshot; + if (!projection?.truncated) return undefined; + const omitted = [ + projection.omitted.transcriptItems > 0 + ? `${projection.omitted.transcriptItems} transcript item(s)` + : undefined, + projection.omitted.liveTools > 0 + ? `${projection.omitted.liveTools} live tool(s)` + : undefined, + projection.omitted.queued > 0 + ? `${projection.omitted.queued} queued message(s)` + : undefined, + projection.omitted.finalTextBytes > 0 ? "final output" : undefined, + ].filter((value): value is string => value !== undefined); + const detail = omitted.length > 0 ? omitted.join(", ") : "display data"; + const hasExactArtifact = exactResultText(snap) !== undefined; + return `snapshot truncated: ${detail} omitted${hasExactArtifact ? "; exact result artifact available" : ""}`; +} + +export function truncatedOutput( snap: SubagentSnapshot, - maxBytes: number, + maxBytes = SUBAGENT_OUTPUT_MAX_BYTES, + writeArtifact: (content: string) => unknown = (content) => + persistResultArtifact(getAgentDir(), content), + resultOptions: { readonly resultIsCanonical?: boolean } = {}, ): ResultProjection { - const output = snap.finalText || "(no output)"; + const artifact = exactResultText(snap); + const finalTextWasOmitted = + snap.finalTextTruncated === true || + (!resultOptions.resultIsCanonical && + (snap.snapshot?.omitted.finalTextBytes ?? 0) > 0); + const output = + artifact !== undefined + ? artifact || "(no output)" + : finalTextWasOmitted + ? "[exact subagent result unavailable]" + : snap.finalText || "(no output)"; + // A projected finalText is not authoritative. Do not create a second + // artifact containing only that projection when the original artifact is + // unavailable. A cache miss with a complete retained result can safely + // repopulate the cache on demand. + const artifactAvailable = artifact !== undefined; + const persist = artifactAvailable + ? () => undefined + : !finalTextWasOmitted + ? writeArtifact + : () => { + throw new Error("The exact subagent result artifact is unavailable"); + }; return projectResult(output, { maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES), maxLines: Math.min(600, DEFAULT_MAX_LINES), - writeArtifact: (content) => persistResultArtifact(getAgentDir(), content), + recoveryId: snap.id, + artifactAvailable, + writeArtifact: persist, }); } type OutputProjection = Pick< ResultProjection, - "text" | "artifactPath" | "artifactSaveFailed" + "text" | "artifactPersisted" | "artifactSaveFailed" >; function normalizeProjection( @@ -284,7 +388,7 @@ export function createSubagentResultDispatcher( outputFor: ( snap: SubagentSnapshot, maxBytes: number, - ) => string | OutputProjection = projectSubagentOutput, + ) => string | OutputProjection = truncatedOutput, getContextUsage: () => ParentContextUsage | undefined = () => undefined, ) { return (snaps: readonly SubagentSnapshot[]) => { @@ -309,9 +413,7 @@ export function createSubagentResultDispatcher( AUTOMATIC_OUTPUT_MAX_BYTES - wrapperBytes, ); const allocation = allocateResultBudgets( - snaps.map((snap) => - Buffer.byteLength(snap.finalText || "(no output)", "utf8"), - ), + snaps.map((snap) => Buffer.byteLength(resultText(snap), "utf8")), getContextUsage(), { maxBatchBytes: projectionBatchBytes, @@ -354,38 +456,48 @@ export function createSubagentResultDispatcher( ); const details: SubagentResultDetails = snaps.length === 1 - ? { - id: snaps[0]!.id, - title: snaps[0]!.title, - status: snaps[0]!.status, - ...(snaps[0]!.outcome ? { outcome: snaps[0]!.outcome } : {}), - ...(snaps[0]!.worktreeBranch - ? { worktreeBranch: snaps[0]!.worktreeBranch } - : {}), - elapsed: formatElapsed(snaps[0]!), - ...(projections[0]!.artifactPath ? { fullResultSaved: true } : {}), - ...(projections[0]!.artifactSaveFailed - ? { artifactSaveFailed: true } - : {}), - } - : { - count: snaps.length, - results: snaps.map((snap, index) => ({ - id: snap.id, - title: snap.title, - status: snap.status, - ...(snap.outcome ? { outcome: snap.outcome } : {}), - ...(snap.worktreeBranch - ? { worktreeBranch: snap.worktreeBranch } + ? (() => { + const projection = projectionDetails(snaps[0]!); + return { + id: snaps[0]!.id, + title: snaps[0]!.title, + status: snaps[0]!.status, + ...(snaps[0]!.outcome ? { outcome: snaps[0]!.outcome } : {}), + ...(snaps[0]!.worktreeBranch + ? { worktreeBranch: snaps[0]!.worktreeBranch } : {}), - elapsed: formatElapsed(snap), - ...(projections[index]!.artifactPath + elapsed: formatElapsed(snaps[0]!), + ...(projections[0]!.artifactPersisted ? { fullResultSaved: true } : {}), - ...(projections[index]!.artifactSaveFailed + ...(projections[0]!.artifactSaveFailed ? { artifactSaveFailed: true } : {}), - })), + ...(projection ? { projection } : {}), + }; + })() + : { + count: snaps.length, + results: snaps.map((snap, index) => { + const projection = projectionDetails(snap); + return { + id: snap.id, + title: snap.title, + status: snap.status, + ...(snap.outcome ? { outcome: snap.outcome } : {}), + ...(snap.worktreeBranch + ? { worktreeBranch: snap.worktreeBranch } + : {}), + elapsed: formatElapsed(snap), + ...(projections[index]!.artifactPersisted + ? { fullResultSaved: true } + : {}), + ...(projections[index]!.artifactSaveFailed + ? { artifactSaveFailed: true } + : {}), + ...(projection ? { projection } : {}), + }; + }), }; pi.appendEntry("subagent-result", { content: displayContent, @@ -505,7 +617,7 @@ export default function ( let dashboardOpen = false; const dispatchResults = createSubagentResultDispatcher( pi, - projectSubagentOutput, + truncatedOutput, () => sessionContext?.getContextUsage(), ); const resultDelivery = createSubagentResultDelivery({ @@ -524,6 +636,8 @@ export default function ( (runtime ??= createSubagentRuntime({ initialModelCounter: restoredIdCounters.modelCounter, initialBtwCounter: restoredIdCounters.btwCounter, + persistResultArtifact: (content) => + persistResultArtifact(getAgentDir(), content), })); const persistId = (id: string) => @@ -653,7 +767,7 @@ export default function ( status: snap.status, errorText: snap.errorText, prompt: snap.prompt, - answer: truncatedOutput(snap), + answer: truncatedOutput(snap).text, sessionFilePath: snap.meta.sessionFilePath, }); ui?.notify( @@ -1080,14 +1194,23 @@ export default function ( readonly id: string; readonly snap: SubagentSnapshot; readonly header: string; + readonly resultIsCanonical: boolean; } > = ids.map((id) => { const snap = manager.view.get(id); if (!snap) return { id, section: `## ${id}\n\n(no longer tracked)` }; + const result = withCanonicalResult(snap, manager.view.getResult?.(id)); const verb = snap.status === "error" ? "failed" : "finished"; let header = `## ${snap.id} "${snap.title}" ${verb}`; if (snap.errorText) header += `\nError: ${snap.errorText}`; - return { id, snap, header }; + const projection = projectionNotice(snap); + if (projection) header += `\n[${projection}]`; + return { + id, + snap: result.snap, + header, + resultIsCanonical: result.resultIsCanonical, + }; }); const separatorsBytes = Math.max(0, entries.length - 1) * 7; const fixedBytes = @@ -1108,6 +1231,7 @@ export default function ( readonly id: string; readonly snap: SubagentSnapshot; readonly header: string; + readonly resultIsCanonical: boolean; } => "snap" in entry, ); const projectionBatchBytes = Math.max( @@ -1116,7 +1240,7 @@ export default function ( ); const allocation = allocateResultBudgets( resultEntries.map(({ snap }) => - Buffer.byteLength(snap.finalText || "(no output)", "utf8"), + Buffer.byteLength(resultText(snap), "utf8"), ), ctx.getContextUsage(), { @@ -1134,9 +1258,14 @@ export default function ( const sections = entries.map((entry) => { if ("section" in entry) return entry.section; const outputBudget = allocation.budgets[resultIndex++]!; - const projection = projectSubagentOutput(entry.snap, outputBudget); + const projection = truncatedOutput( + entry.snap, + outputBudget, + undefined, + { resultIsCanonical: entry.resultIsCanonical }, + ); if (projection.artifactSaveFailed) artifactSaveFailures.add(entry.id); - if (projection.artifactPath) fullResultsSaved.add(entry.id); + if (projection.artifactPersisted) fullResultsSaved.add(entry.id); return `${entry.header}\n\n${projection.text}`; }); @@ -1153,6 +1282,7 @@ export default function ( details: { results: ids.map((id) => { const snap = manager.view.get(id); + const projection = snap ? projectionDetails(snap) : undefined; return { id, title: snap?.title, @@ -1166,6 +1296,7 @@ export default function ( ...(artifactSaveFailures.has(id) ? { artifactSaveFailed: true } : {}), + ...(projection ? { projection } : {}), }; }), }, @@ -1333,9 +1464,26 @@ export default function ( let text = `${describeSubagent(snap)}\nTurns: ${snap.turns}`; if (snap.errorText) text += `\nError: ${snap.errorText}`; - const output = latestText(snap); - if (output) { - const preview = truncateHead(output, { maxBytes: 2048, maxLines: 20 }); + const result = + snap.status === "running" + ? undefined + : withCanonicalResult(snap, manager.view.getResult?.(snap.id)); + const resultSnap = result?.snap ?? snap; + const output = + snap.status === "running" ? latestText(snap) : resultText(resultSnap); + if (output && output !== "(no output)") { + const preview = + snap.status === "running" + ? truncateHead(output, { maxBytes: 2048, maxLines: 20 }) + : (() => { + const projected = truncatedOutput(resultSnap, 2048, undefined, { + resultIsCanonical: result?.resultIsCanonical, + }); + return { + content: projected.text, + truncated: projected.truncated || projected.text !== output, + }; + })(); text += `\n\nLatest output:\n${preview.content}`; if (preview.truncated) text += "\n[...]"; } else if (snap.status === "running") { @@ -1344,7 +1492,107 @@ export default function ( return { content: [{ type: "text", text }], - details: { id: snap.id, status: snap.status, turns: snap.turns }, + details: { + id: snap.id, + status: snap.status, + turns: snap.turns, + ...(projectionDetails(snap) + ? { projection: projectionDetails(snap) } + : {}), + }, + }; + }, + }); + + pi.registerTool({ + name: "subagent_result", + label: "Read Subagent Result", + description: SUBAGENT_RESULT_TOOL_DESCRIPTION, + parameters: Type.Object({ + id: Type.String({ + description: SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS.id, + }), + offset: Type.Optional( + Type.Integer({ + minimum: 0, + description: SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS.offset, + }), + ), + limit: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: MAX_RESULT_PAGE_LINES, + description: SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS.limit, + }), + ), + byteOffset: Type.Optional( + Type.Integer({ + minimum: 0, + description: SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS.byteOffset, + }), + ), + }), + async execute(_toolCallId, params) { + const manager = await getManager(); + const snap = manager.view.get(params.id); + if (!snap || !isModelVisible(snap)) { + const known = manager.view + .list() + .filter(isModelVisible) + .map((s) => s.id); + throw new Error( + `Unknown subagent id "${params.id}". Known: ${known.join(", ") || "none"}.`, + ); + } + if (snap.status === "running") { + throw new Error( + `Subagent ${snap.id} is still running; use subagent_wait or wait for automatic delivery.`, + ); + } + + const canonical = withCanonicalResult( + snap, + manager.view.getResult?.(snap.id), + ); + const artifact = exactResultText(canonical.snap); + // Prefer the protected artifact. Fall back to retained canonical + // finalText, never to a truncated projection. + const exact = resolveExactResultText({ + artifactText: artifact, + retainedFinalText: canonical.snap.finalText, + resultIsCanonical: canonical.resultIsCanonical, + finalTextTruncated: canonical.snap.finalTextTruncated, + omittedFinalTextBytes: canonical.snap.finalTextTruncated + ? Math.max(1, canonical.snap.snapshot?.omitted.finalTextBytes ?? 0) + : (canonical.snap.snapshot?.omitted.finalTextBytes ?? 0), + }); + if (exact === undefined) { + throw new Error( + `Exact result for ${snap.id} is unavailable; the bounded projection is not a recovery source.`, + ); + } + const page = pageResultText(exact, { + offset: params.offset, + limit: params.limit, + byteOffset: params.byteOffset, + maxBytes: MAX_RESULT_PAGE_BYTES, + }); + return { + content: [{ type: "text", text: page.text }], + details: { + id: snap.id, + offset: page.offset, + limit: page.limit, + totalLines: page.totalLines, + hasMore: page.hasMore, + ...(page.nextByteOffset !== undefined + ? { nextByteOffset: page.nextByteOffset } + : {}), + ...(page.byteOffset !== undefined + ? { byteOffset: page.byteOffset } + : {}), + ...(artifact !== undefined ? { exactResultAvailable: true } : {}), + }, }; }, }); @@ -1364,12 +1612,16 @@ export default function ( return { content: [{ type: "text", text }], details: { - subagents: subs.map((snap) => ({ - id: snap.id, - title: snap.title, - harness: snap.backend, - status: snap.status, - })), + subagents: subs.map((snap) => { + const projection = projectionDetails(snap); + return { + id: snap.id, + title: snap.title, + harness: snap.backend, + status: snap.status, + ...(projection ? { projection } : {}), + }; + }), }, }; }, diff --git a/extensions/subagents/src/domain.ts b/extensions/subagents/src/domain.ts index 503bd351..4a523f4c 100644 --- a/extensions/subagents/src/domain.ts +++ b/extensions/subagents/src/domain.ts @@ -139,6 +139,29 @@ export interface QueuedMessage { readonly kind: "steer" | "follow-up"; } +/** Path-free identity of an exact terminal-result cache entry. */ +export interface ResultArtifactRef { + readonly version: 1; + /** Lowercase SHA-256 digest of the UTF-8 artifact content. */ + readonly digest: string; +} + +/** Why a live snapshot does not contain the complete child conversation. */ +export interface SubagentSnapshotProjection { + readonly maxBytes: number; + readonly bytes: number; + readonly truncated: boolean; + readonly omittedBytes: number; + readonly omitted: { + readonly transcriptItems: number; + readonly liveTools: number; + readonly queued: number; + readonly liveAssistantBytes: number; + readonly finalTextBytes: number; + readonly promptBytes: number; + }; +} + // --- Events ------------------------------------------------------------------ export type RunOutcome = @@ -232,8 +255,14 @@ export interface SubagentSnapshot { readonly queued: ReadonlyArray; /** Final text of the most recent completed run (v1 `finalOutput`). */ readonly finalText: string; + /** True when finalText is only a bounded retained prefix. */ + readonly finalTextTruncated?: boolean; + /** Content-addressed exact result, when the bounded projection omitted text. */ + readonly resultArtifact?: ResultArtifactRef; /** Count of finalized assistant messages (for subagent_check). */ readonly turns: number; + /** Aggregate UTF-8 budget metadata for this in-memory projection. */ + readonly snapshot?: SubagentSnapshotProjection; } /** Final text, or the live streaming buffer while a run is active (v1 `latestOutput`). */ diff --git a/extensions/subagents/src/manager.ts b/extensions/subagents/src/manager.ts index 033bcd30..cc2d8fad 100644 --- a/extensions/subagents/src/manager.ts +++ b/extensions/subagents/src/manager.ts @@ -26,17 +26,18 @@ import { Scope, Stream, } from "effect"; -import type { SubagentBackend, SubagentSession } from "./backend.ts"; import type { AgentToolRenderer } from "../../shared/agent-tool-renderer.ts"; +import type { SubagentBackend, SubagentSession } from "./backend.ts"; import { BackendRegistry } from "./backend.ts"; import type { BackendName, LiveToolState, + ResultArtifactRef, RunOutcome, SpawnTask, SubagentEvent, - SubagentOrigin, SubagentMeta, + SubagentOrigin, SubagentSnapshot, SubagentStatus, TranscriptItem, @@ -47,6 +48,13 @@ import { SendError, SpawnError, } from "./domain.ts"; +import { resultArtifactRefMatchesContent } from "./result-artifact.ts"; +import { + DEFAULT_SUBAGENT_SNAPSHOT_MAX_BYTES, + projectSubagentSnapshots, + truncateUtf8Head, + truncateUtf8Tail, +} from "./snapshot.ts"; /** Model-spawned subagents get their own pool so a user aside cannot starve it. */ export const MAX_RUNNING = 4; @@ -70,11 +78,16 @@ export const FIRST_RESPONSE_TIMEOUT_MS = 45_000; const ERROR_TEXT_MAX_LENGTH = 4_096; const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024; const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024; -const FINAL_TEXT_MAX_LENGTH = 1_024 * 1_024; const MAX_TRANSCRIPT_ITEMS = 512; +const MAX_QUEUE_MESSAGES = 128; +const MAX_RETAINED_FINAL_TEXT_BYTES = 1 * 1024 * 1024; function bounded(text: string) { - return text.slice(0, ERROR_TEXT_MAX_LENGTH); + return truncateUtf8Head(text, ERROR_TEXT_MAX_LENGTH); +} + +function boundedFinalText(text: string) { + return truncateUtf8Head(text, MAX_RETAINED_FINAL_TEXT_BYTES); } function formatWatchdogTimeout(ms: number) { @@ -82,7 +95,7 @@ function formatWatchdogTimeout(ms: number) { } function boundedTranscriptText(text: string) { - return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH); + return truncateUtf8Head(text, TRANSCRIPT_TEXT_MAX_LENGTH); } function appendTranscript(snapshot: MutableSnapshot, item: TranscriptItem) { @@ -123,15 +136,22 @@ interface MutableSnapshot { liveTools: LiveToolState[]; queued: SubagentSnapshot["queued"]; finalText: string; + finalTextTruncated?: boolean; + resultArtifact?: ResultArtifactRef; turns: number; } interface Entry { + /** Canonical state used to fold backend events and rehydrate takeover UI. */ snapshot: MutableSnapshot; + /** Detached aggregate-bounded projection for ordinary readers and tools. */ + projection?: SubagentSnapshot; session: SubagentSession; scope: Scope.Closeable; pump?: Fiber.Fiber; liveToolMap: Map; + /** Generation of the terminal result currently eligible for persistence. */ + persistenceGeneration: number; /** First-response watchdog timer for the active (or just-armed) run. */ watchdogTimer?: ReturnType; /** Idle restart dispatched but RunStarted not folded yet; counts as running @@ -141,11 +161,29 @@ interface Entry { // --- Read model ---------------------------------------------------------------- -/** Synchronous bridge for the TUI. Snapshots are live objects; do not mutate. */ +/** Synchronous bridge for bounded readers and the local takeover UI. */ export interface SubagentReadModel { + /** Aggregate-bounded projection for dashboards and model-facing tools. */ list(): ReadonlyArray; + /** Aggregate-bounded projection for dashboards and model-facing tools. */ get(id: string): SubagentSnapshot | undefined; - /** Native tool projection retained by the live child session, when present. */ + /** + * Complete retained state for the local takeover UI. It is deliberately not + * used by model-facing tools, which consume the bounded projection above. + */ + getFull?(id: string): SubagentSnapshot | undefined; + /** + * Canonical terminal-result fields for delivery only. This intentionally + * excludes transcript-like state from model-facing tool responses. + */ + getResult?( + id: string, + ): + | Pick< + SubagentSnapshot, + "finalText" | "finalTextTruncated" | "resultArtifact" + > + | undefined; getToolRenderer?(id: string): AgentToolRenderer | undefined; size(): number; /** Any-change notification (footer status, dashboard). */ @@ -215,6 +253,11 @@ const makeManager = (config: SubagentManagerConfig = {}) => Effect.gen(function* () { const firstResponseTimeoutMs = config.firstResponseTimeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS; + const maxSnapshotBytes = + config.maxSnapshotBytes ?? DEFAULT_SUBAGENT_SNAPSHOT_MAX_BYTES; + if (!Number.isSafeInteger(maxSnapshotBytes) || maxSnapshotBytes <= 0) { + throw new Error("maxSnapshotBytes must be a positive safe integer"); + } const registry = yield* BackendRegistry; // Detached forker for sync contexts (read-model commands, pruning) that // preserves the manager's services instead of using the global runtime. @@ -239,6 +282,68 @@ const makeManager = (config: SubagentManagerConfig = {}) => | ((snap: SubagentSnapshot, consumed: boolean) => void) | undefined; + /** Keep the manager's read-model projection under one aggregate byte bound. */ + const enforceSnapshotBudget = () => { + if (entries.size === 0) return; + const current = [...entries.values()].map( + (entry) => entry.snapshot as SubagentSnapshot, + ); + const projected = projectSubagentSnapshots(current, maxSnapshotBytes); + if (!projected) { + throw new Error( + `Subagent snapshot aggregate exceeds the configured ${maxSnapshotBytes}-byte minimum identity budget`, + ); + } + const projectedById = new Map( + projected.map((snapshot) => [snapshot.id, snapshot] as const), + ); + for (const entry of entries.values()) { + const snapshot = projectedById.get(entry.snapshot.id); + if (!snapshot) + throw new Error("Projected subagent identity disappeared"); + // Projection is disposable. Never write it back into the event-folding + // snapshot: takeover can then render the retained transcript in full. + entry.projection = snapshot; + } + }; + + const tryEnforceSnapshotBudget = () => { + try { + enforceSnapshotBudget(); + return true; + } catch { + // Never expose a projection from an older lifecycle state after a + // failed rebuild; readers fall back to the canonical snapshot. + for (const entry of entries.values()) entry.projection = undefined; + return false; + } + }; + + const cloneSnapshot = ( + snapshot: SubagentSnapshot, + projection?: SubagentSnapshot["snapshot"], + ): SubagentSnapshot => ({ + ...snapshot, + meta: { ...snapshot.meta }, + usage: { ...snapshot.usage }, + transcript: snapshot.transcript.map((item) => + item.kind === "assistant" + ? { ...item, parts: item.parts.map((part) => ({ ...part })) } + : { ...item }, + ), + liveAssistant: snapshot.liveAssistant + ? { ...snapshot.liveAssistant } + : undefined, + liveTools: snapshot.liveTools.map((tool) => ({ ...tool })), + queued: snapshot.queued.map((message) => ({ ...message })), + snapshot: projection + ? { + ...projection, + omitted: { ...projection.omitted }, + } + : undefined, + }); + const notify = (id?: string) => { const waiters = changeWaiters; changeWaiters = []; @@ -324,6 +429,28 @@ const makeManager = (config: SubagentManagerConfig = {}) => Effect.ignore, ); + const registerEntry = ( + id: string, + entry: Entry, + ): Effect.Effect => + Effect.try({ + try: () => { + entries.set(id, entry); + enforceSnapshotBudget(); + }, + catch: (error) => + new SpawnError({ + message: error instanceof Error ? error.message : String(error), + }), + }).pipe( + Effect.catch((error: SpawnError) => + Effect.sync(() => entries.delete(id)).pipe( + Effect.andThen(closeEntryScope(entry)), + Effect.andThen(Effect.fail(error)), + ), + ), + ); + const pruneSettled = () => { if (entries.size <= MAX_TRACKED) return; const candidates = [...entries.values()] @@ -342,6 +469,38 @@ const makeManager = (config: SubagentManagerConfig = {}) => } }; + /** + * Persist an optional exact-result recovery artifact. Filesystem failures + * must never prevent terminal state, waiters, or delivery hooks from + * observing settlement. + */ + const persistExactResult = (entry: Entry, text: string) => { + if (!text || !config.persistResultArtifact) return; + const generation = entry.persistenceGeneration; + // Artifact persistence is optional recovery work. Schedule it only + // after settlement has notified waiters and hooks, so a slow or broken + // writer can never hold the lifecycle path or a concurrency slot. + setTimeout(() => { + try { + const artifact = config.persistResultArtifact!(text); + if (!resultArtifactRefMatchesContent(artifact, text)) return; + if ( + entry.snapshot.status === "running" || + entry.persistenceGeneration !== generation + ) + return; + entry.snapshot.resultArtifact = artifact; + if (!tryEnforceSnapshotBudget()) { + entry.snapshot.resultArtifact = undefined; + return; + } + notify(entry.snapshot.id); + } catch { + // Recovery is best effort and must not alter terminal state. + } + }, 0); + }; + const settle = (entry: Entry, outcome: RunOutcome) => { clearWatchdog(entry); const s = entry.snapshot; @@ -355,44 +514,64 @@ const makeManager = (config: SubagentManagerConfig = {}) => s.status = "running"; s.settledAt = undefined; s.errorText = undefined; + s.resultArtifact = undefined; } s.settledAt = Date.now(); switch (outcome._tag) { - case "Completed": + case "Completed": { s.status = "done"; s.outcome = "completed"; s.errorText = undefined; - s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH); + s.finalText = outcome.finalText; + s.finalTextTruncated = + Buffer.byteLength(s.finalText, "utf8") > + MAX_RETAINED_FINAL_TEXT_BYTES; + persistExactResult(entry, s.finalText); + s.finalText = boundedFinalText(s.finalText); break; - case "Failed": + } + case "Failed": { s.status = "error"; s.outcome = "failed"; s.errorText = bounded(outcome.errorText); // Never let a failed run report the previous run's successful output. - s.finalText = (outcome.partialText ?? "").slice( - 0, - FINAL_TEXT_MAX_LENGTH, - ); + s.finalText = outcome.partialText ?? ""; + s.finalTextTruncated = + Buffer.byteLength(s.finalText, "utf8") > + MAX_RETAINED_FINAL_TEXT_BYTES; + persistExactResult(entry, s.finalText); + s.finalText = boundedFinalText(s.finalText); break; - case "Interrupted": + } + case "Interrupted": { s.status = "error"; s.outcome = "interrupted"; s.errorText = "Run was aborted"; - s.finalText = (outcome.partialText ?? "").slice( - 0, - FINAL_TEXT_MAX_LENGTH, - ); + s.finalText = outcome.partialText ?? ""; + s.finalTextTruncated = + Buffer.byteLength(s.finalText, "utf8") > + MAX_RETAINED_FINAL_TEXT_BYTES; + persistExactResult(entry, s.finalText); + s.finalText = boundedFinalText(s.finalText); break; + } } s.liveAssistant = undefined; entry.liveToolMap.clear(); s.liveTools = []; s.queued = []; const consumed = (waitInterest.get(s.id) ?? 0) > 0; + if (!tryEnforceSnapshotBudget()) { + // An artifact reference is optional. Retrying without it keeps a + // malformed callback result from suppressing settlement notification. + s.resultArtifact = undefined; + tryEnforceSnapshotBudget(); + } + const settledSnapshot = cloneSnapshot(s); notify(s.id); try { // During teardown, don't queue results into a shutting-down session. - if (!disposed) onSettled?.(s, consumed); + if (!disposed) onSettled?.(settledSnapshot, consumed); } catch { // The parent session may be unavailable; settlement stays final. } @@ -446,6 +625,8 @@ const makeManager = (config: SubagentManagerConfig = {}) => s.outcome = undefined; s.settledAt = undefined; s.errorText = undefined; + s.resultArtifact = undefined; + s.finalTextTruncated = undefined; armWatchdog(entry); break; case "RunSettled": @@ -464,14 +645,16 @@ const makeManager = (config: SubagentManagerConfig = {}) => event.kind === "text" ? { ...live, - text: (live.text + event.delta).slice( - -LIVE_ASSISTANT_MAX_LENGTH, + text: truncateUtf8Tail( + live.text + event.delta, + LIVE_ASSISTANT_MAX_LENGTH, ), } : { ...live, - thinking: (live.thinking + event.delta).slice( - -LIVE_ASSISTANT_MAX_LENGTH, + thinking: truncateUtf8Tail( + live.thinking + event.delta, + LIVE_ASSISTANT_MAX_LENGTH, ), }; break; @@ -497,7 +680,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => case "ToolStart": entry.liveToolMap.set(event.toolId, { toolId: event.toolId, - name: event.name, + name: boundedTranscriptText(event.name), argsPreview: event.argsPreview ? boundedTranscriptText(event.argsPreview) : undefined, @@ -523,7 +706,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => appendTranscript(s, { kind: "toolResult", toolId: event.toolId, - name: event.name, + name: boundedTranscriptText(event.name), isError: event.isError, outputPreview: event.outputPreview ? boundedTranscriptText(event.outputPreview) @@ -531,7 +714,10 @@ const makeManager = (config: SubagentManagerConfig = {}) => }); break; case "QueueChanged": - s.queued = event.queued; + s.queued = event.queued.slice(-MAX_QUEUE_MESSAGES).map((message) => ({ + kind: message.kind, + text: boundedTranscriptText(message.text), + })); break; case "UsageChanged": s.usage = { @@ -546,6 +732,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => s.errorText = bounded(event.message); break; } + tryEnforceSnapshotBudget(); notify(s.id); }; @@ -621,8 +808,9 @@ const makeManager = (config: SubagentManagerConfig = {}) => session, scope, liveToolMap: new Map(), + persistenceGeneration: 0, }; - entries.set(id, entry); + yield* registerEntry(id, entry); // The run is live from the caller's perspective before RunStarted // reaches the pump; guard that window too. armWatchdog(entry); @@ -647,7 +835,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope); notify(id); - return entry.snapshot as SubagentSnapshot; + return (entry.projection ?? entry.snapshot) as SubagentSnapshot; }); return yield* doSpawn.pipe( @@ -776,6 +964,7 @@ const makeManager = (config: SubagentManagerConfig = {}) => // both pass the check in that window. Cleared by RunStarted/settle, // or here when the backend rejects the send. entry.restarting = true; + entry.persistenceGeneration++; // A backend that accepts the send but never starts the run would // hold the slot forever; guard the restart window the same way the // spawn path guards its pre-RunStarted window. @@ -821,8 +1010,32 @@ const makeManager = (config: SubagentManagerConfig = {}) => }); const view: SubagentReadModel = { - list: () => [...entries.values()].map((entry) => entry.snapshot), - get: (id) => entries.get(id)?.snapshot, + list: () => + [...entries.values()].map( + (entry) => (entry.projection ?? entry.snapshot) as SubagentSnapshot, + ), + get: (id) => { + const entry = entries.get(id); + return entry + ? ((entry.projection ?? entry.snapshot) as SubagentSnapshot) + : undefined; + }, + getFull: (id) => + entries.get(id)?.snapshot as SubagentSnapshot | undefined, + getResult: (id) => { + const snapshot = entries.get(id)?.snapshot; + return snapshot + ? { + finalText: snapshot.finalText, + ...(snapshot.finalTextTruncated + ? { finalTextTruncated: true } + : {}), + ...(snapshot.resultArtifact + ? { resultArtifact: snapshot.resultArtifact } + : {}), + } + : undefined; + }, getToolRenderer: (id) => entries.get(id)?.session.toolRenderer, size: () => entries.size, subscribe: (listener) => { @@ -865,8 +1078,18 @@ const makeManager = (config: SubagentManagerConfig = {}) => waitFor, cancel, send, - get: (id) => Effect.sync(() => entries.get(id)?.snapshot), - list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)), + get: (id) => + Effect.sync(() => { + const entry = entries.get(id); + return entry + ? ((entry.projection ?? entry.snapshot) as SubagentSnapshot) + : undefined; + }), + list: Effect.sync(() => + [...entries.values()].map( + (entry) => (entry.projection ?? entry.snapshot) as SubagentSnapshot, + ), + ), disposeAll, view, }); @@ -875,6 +1098,10 @@ const makeManager = (config: SubagentManagerConfig = {}) => export interface SubagentManagerConfig { /** Test-only override for the first-response watchdog timeout. */ firstResponseTimeoutMs?: number; + /** Aggregate UTF-8 budget for all live/settled read-model snapshots. */ + maxSnapshotBytes?: number; + /** Persist an exact terminal result before the in-memory projection is cut. */ + persistResultArtifact?: (content: string) => ResultArtifactRef; /** Session-branch high-water marks restored by the extension host. */ initialModelCounter?: number; initialBtwCounter?: number; diff --git a/extensions/subagents/src/prompt.ts b/extensions/subagents/src/prompt.ts index 438893e6..621f683b 100644 --- a/extensions/subagents/src/prompt.ts +++ b/extensions/subagents/src/prompt.ts @@ -229,7 +229,7 @@ export function buildSubagentSpawnResult(options: { return ( `Spawned subagent ${options.id} "${options.title}" (${options.harness}: ${options.modelLabel}, ${options.cwd}).${typeNote}${toolNote}${worktreeNote}\n` + `It runs in the background — keep working on independent work. If none remains in an interactive session, briefly tell the user it is still running and end your turn; its result is delivered automatically and you are automatically re-invoked when it finishes. Do not poll or call subagent_wait merely because a later step depends on it. ` + - `Use subagent_wait(ids: ["${options.id}"]) only if the user explicitly asked you to keep the current response open for this result, or a non-interactive automation must return it in the same invocation; subagent_cancel stops it, subagent_check peeks at a running one, subagent_list shows all.` + `Use subagent_wait(ids: ["${options.id}"]) only if the user explicitly asked you to keep the current response open for this result, or a non-interactive automation must return it in the same invocation; subagent_cancel stops it, subagent_check peeks at a running one, subagent_result pages a settled exact result by id, subagent_list shows all.` ); } @@ -281,6 +281,19 @@ export const SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS = { id: "Subagent id", }; +/** Describes bounded, path-free reads of a settled exact result. */ +export const SUBAGENT_RESULT_TOOL_DESCRIPTION = + "Read a bounded page of a settled subagent's exact final result by id. Use offset/limit for lines, or byteOffset to continue a split long line; byteOffset is a UTF-8 boundary and cannot be combined with offset. It never accepts or returns a filesystem path."; + +/** Model-facing schema descriptions for exact result paging. */ +export const SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS = { + id: 'Subagent id to read, e.g. "sa-1"', + offset: "Zero-based line offset in the exact final result.", + limit: "Maximum number of lines to return, from 1 through 200.", + byteOffset: + "UTF-8 byte cursor for continuing a split line; use the returned nextByteOffset.", +}; + /** Describes listing all tracked running and settled subagents. */ export const SUBAGENT_LIST_TOOL_DESCRIPTION = "List all subagents (running and finished) with their status."; diff --git a/extensions/subagents/src/result-artifact.ts b/extensions/subagents/src/result-artifact.ts index 066dd3da..ec74fa6b 100644 --- a/extensions/subagents/src/result-artifact.ts +++ b/extensions/subagents/src/result-artifact.ts @@ -1,146 +1,1526 @@ -import { createHash } from "node:crypto"; -import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; +import { + closeSync, + constants as fsConstants, + fstatSync, + fsyncSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readSync, + renameSync, + type Stats, + unlinkSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import { formatSize, truncateHead, truncateTail, } from "@earendil-works/pi-coding-agent"; +import type { ResultArtifactRef } from "./domain.ts"; + +/** + * The cache is optional recovery data. POSIX hosts pin the cache directory + * through a descriptor-backed path and use no-follow opens. Windows hosts do + * not expose the required handle-relative/no-follow primitives through Node, + * so this module deliberately disables filesystem cache operations there. + * Any uncertainty fails closed and is caught by the settlement layer. + */ + +export type { ResultArtifactRef } from "./domain.ts"; const HEAD_SHARE = 0.75; -const RESULT_ARTIFACT_DIR = ["cache", "openpi", "subagent-results"]; +const RESULT_ARTIFACT_DIR = ["cache", "openpi", "subagent-results"] as const; +const RESULT_ARTIFACT_NAME = /^[a-f0-9]{64}\.txt$/u; +const RESULT_ARTIFACT_DIGEST = /^[a-f0-9]{64}$/u; +const RESULT_CACHE_LOCK_NAME = ".retention-lock"; +const RESULT_CACHE_OWNER_PREFIX = `${RESULT_CACHE_LOCK_NAME}.owner.`; +const RESULT_CACHE_RECOVERY_PREFIX = `${RESULT_CACHE_LOCK_NAME}.recovery.`; +const OUTPUT_MIDDLE_MARKER = "[... middle omitted ...]"; +const OUTPUT_TRUNCATED_MARKER = "[Output truncated]"; +const OUTPUT_NO_ARTIFACT_MARKER = "[full answer could not be saved]"; +const NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0; +const NONBLOCK = fsConstants.O_NONBLOCK ?? 0; +const DIRECTORY = fsConstants.O_DIRECTORY ?? 0; +const READ_ONLY_NOFOLLOW = fsConstants.O_RDONLY | NOFOLLOW | NONBLOCK; +const DIRECTORY_NOFOLLOW = fsConstants.O_RDONLY | DIRECTORY | NOFOLLOW; +const MAX_LOCK_DOCUMENT_BYTES = 16 * 1024; +const MIN_USEFUL_BODY_BYTES = 18; +const MAX_LOCK_ACQUIRE_ATTEMPTS = 3; +const CACHE_METADATA_STALE_AFTER_MS = 60 * 60 * 1_000; +const RESULT_CACHE_OWNER_NAME = + /^\.retention-lock\.owner\.\d+\.[0-9a-f-]{36}$/iu; +const RESULT_CACHE_RECOVERY_NAME = + /^\.retention-lock\.recovery\.[0-9a-f-]{36}$/iu; +const RESULT_ARTIFACT_TEMP_NAME = /^\.[a-f0-9]{64}\.[0-9a-f-]{36}\.tmp$/u; +const SUPPORTED_DESCRIPTOR_PLATFORMS = new Set(["linux"]); +const UNSUPPORTED_CACHE_PLATFORM_ERROR = + "Result artifact cache is unavailable because Node cannot provide safe handle-relative no-follow operations"; + +/** Bounded retention for exact terminal-result recovery artifacts. */ +export const MAX_RESULT_ARTIFACT_FILES = 64; +export const MAX_RESULT_ARTIFACT_BYTES = 64 * 1024 * 1024; +/** Model-facing exact-result paging limits. */ +export const MAX_RESULT_PAGE_LINES = 200; +export const MAX_RESULT_PAGE_BYTES = 16 * 1024; + +export interface ResultArtifactCacheOptions { + /** Override used by embedding hosts and focused retention tests. */ + readonly maxFiles?: number; + /** Aggregate UTF-8 payload bytes retained by this cache. */ + readonly maxBytes?: number; +} export interface ResultProjectionOptions { readonly maxBytes: number; readonly maxLines: number; - readonly writeArtifact: (content: string) => string; + /** Optional model-facing recovery capability identity, never a filesystem path. */ + readonly recoveryId?: string; + /** True when an exact artifact is already available to the protected reader. */ + readonly artifactAvailable?: boolean; + /** The writer's return value is opaque and is never rendered as a path. */ + readonly writeArtifact: (content: string) => unknown; } export interface ResultProjection { readonly text: string; readonly truncated: boolean; - readonly artifactPath?: string; + /** True when an exact recovery artifact was already present or just persisted. */ + readonly artifactPersisted?: boolean; + /** True when this projection attempted and failed to persist an artifact. */ readonly artifactSaveFailed?: boolean; } +interface ResultArtifactLimits { + readonly maxFiles: number; + readonly maxBytes: number; +} + +interface FileIdentity { + readonly dev: number; + readonly ino: number; +} + +interface CachedArtifact extends FileIdentity { + readonly name: string; + readonly size: number; + readonly modifiedAt: number; +} + +interface CacheDirectory extends FileIdentity { + /** A descriptor-relative path on POSIX, or the checked path elsewhere. */ + readonly operationPath: string; + readonly fd: number; +} + +interface ReadFileResult { + readonly bytes: Buffer; +} + +interface CacheLock { + readonly claimPath: string; + readonly lockPath: string; + readonly owner: LockOwner; +} + +interface LockOwner { + readonly version: 1; + readonly pid: number; + readonly token: string; + readonly createdAt: number; +} + +function byteLength(value: string) { + return Buffer.byteLength(value, "utf8"); +} + +function identityOf(stat: Stats): FileIdentity { + return { dev: Number(stat.dev), ino: Number(stat.ino) }; +} + +function sameIdentity(left: FileIdentity, right: FileIdentity) { + return left.dev === right.dev && left.ino === right.ino; +} + +function isErrno(error: unknown, code: string) { + return (error as NodeJS.ErrnoException | undefined)?.code === code; +} + +function closeQuietly(fd: number) { + try { + closeSync(fd); + } catch { + // Best-effort cache cleanup must not mask the primary result path. + } +} + +function unlinkQuietly(filePath: string) { + try { + unlinkSync(filePath); + } catch { + // A crashed or racing cache writer may already have removed it. + } +} + +function digestForBytes(bytes: Uint8Array) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function digestForContent(content: string) { + return digestForBytes(Buffer.from(content, "utf8")); +} + +/** Validate the compact, path-free durable artifact identity. */ +export function isResultArtifactRef( + value: unknown, +): value is ResultArtifactRef { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const candidate = value as Record; + const keys = Object.keys(candidate); + return ( + keys.length === 2 && + keys.includes("version") && + keys.includes("digest") && + candidate.version === 1 && + typeof candidate.digest === "string" && + RESULT_ARTIFACT_DIGEST.test(candidate.digest) + ); +} + +export function resultArtifactRefMatchesContent( + value: unknown, + content: string, +): value is ResultArtifactRef { + return ( + isResultArtifactRef(value) && value.digest === digestForContent(content) + ); +} + +function assertResultArtifactRef(value: ResultArtifactRef) { + if (!isResultArtifactRef(value)) { + throw new Error("Invalid result artifact reference"); + } +} + +/** Pure path construction for display only. It never creates directories. */ +export function cacheDirectoryPath(agentDir: string) { + return path.resolve(agentDir, ...RESULT_ARTIFACT_DIR); +} + +/** Derive a display path from the validated digest reference. */ +export function resultArtifactPath(agentDir: string, ref: ResultArtifactRef) { + assertResultArtifactRef(ref); + return path.join(cacheDirectoryPath(agentDir), `${ref.digest}.txt`); +} + function sliceStartToUtf8Bytes(content: string, maxBytes: number) { + if (maxBytes <= 0) return ""; const bytes = Buffer.from(content, "utf8"); if (bytes.length <= maxBytes) return content; - let end = maxBytes; + let end = Math.min(maxBytes, bytes.length); while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; return bytes.subarray(0, end).toString("utf8"); } -function ensureDirectory(parent: string, name: string) { - const directory = path.join(parent, name); +function assertCachePlatformSupported() { + if ( + !SUPPORTED_DESCRIPTOR_PLATFORMS.has(process.platform) || + NOFOLLOW === 0 || + DIRECTORY === 0 || + NONBLOCK === 0 + ) { + throw new Error(UNSUPPORTED_CACHE_PLATFORM_ERROR); + } +} + +function descriptorRelativePath(fd: number, fallback: string) { + if (process.platform === "linux") return `/proc/self/fd/${fd}`; + return fallback; +} + +function openCheckedDirectory( + parentPath: string, + parentFd: number, + name: string, + create: boolean, +) { + const childPath = path.join( + descriptorRelativePath(parentFd, parentPath), + name, + ); + if (create) { + try { + mkdirSync(childPath, { mode: 0o700 }); + } catch (error) { + if (!isErrno(error, "EEXIST")) throw error; + } + } + const checked = lstatSync(childPath); + if (!checked.isDirectory() || checked.isSymbolicLink()) { + throw new Error( + `Unsafe result artifact directory: ${path.join(parentPath, name)}`, + ); + } + const fd = openSync(childPath, DIRECTORY_NOFOLLOW); try { - mkdirSync(directory, { mode: 0o700 }); + const opened = fstatSync(fd); + if ( + !opened.isDirectory() || + !sameIdentity(identityOf(checked), identityOf(opened)) + ) { + throw new Error( + `Unsafe result artifact directory: ${path.join(parentPath, name)}`, + ); + } + return { fd, path: path.join(parentPath, name) }; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + closeSync(fd); + throw error; } - const stat = lstatSync(directory); - if (!stat.isDirectory() || stat.isSymbolicLink()) { - throw new Error(`Unsafe result artifact directory: ${directory}`); +} + +function openCacheDirectory(agentDir: string, create: boolean): CacheDirectory { + assertCachePlatformSupported(); + const root = path.resolve(agentDir); + const rootStat = lstatSync(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error(`Unsafe result artifact directory: ${root}`); + } + const rootFd = openSync(root, DIRECTORY_NOFOLLOW); + let currentFd = rootFd; + let currentPath = root; + try { + const openedRoot = fstatSync(currentFd); + if ( + !openedRoot.isDirectory() || + !sameIdentity(identityOf(rootStat), identityOf(openedRoot)) + ) { + throw new Error(`Unsafe result artifact directory: ${root}`); + } + + for (const segment of RESULT_ARTIFACT_DIR) { + const child = openCheckedDirectory( + currentPath, + currentFd, + segment, + create, + ); + closeSync(currentFd); + currentFd = child.fd; + currentPath = child.path; + } + + const identity = identityOf(fstatSync(currentFd)); + return { + operationPath: descriptorRelativePath(currentFd, currentPath), + fd: currentFd, + ...identity, + }; + } catch (error) { + closeSync(currentFd); + throw error; } - return directory; +} + +function assertDirectoryStable(directory: CacheDirectory) { + const current = fstatSync(directory.fd); + if (!current.isDirectory() || !sameIdentity(directory, identityOf(current))) { + throw new Error(`Result artifact directory changed during operation`); + } +} + +function cacheLimits( + options: ResultArtifactCacheOptions | undefined, +): ResultArtifactLimits { + const maxFiles = options?.maxFiles ?? MAX_RESULT_ARTIFACT_FILES; + const maxBytes = options?.maxBytes ?? MAX_RESULT_ARTIFACT_BYTES; + if (!Number.isSafeInteger(maxFiles) || maxFiles <= 0) { + throw new Error("maxFiles must be a positive safe integer"); + } + if (maxFiles > MAX_RESULT_ARTIFACT_FILES) { + throw new Error( + `maxFiles must not exceed ${MAX_RESULT_ARTIFACT_FILES} files`, + ); + } + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new Error("maxBytes must be a positive safe integer"); + } + if (maxBytes > MAX_RESULT_ARTIFACT_BYTES) { + throw new Error( + `maxBytes must not exceed ${MAX_RESULT_ARTIFACT_BYTES} bytes`, + ); + } + return { maxFiles, maxBytes }; +} + +function artifactPathIn(directory: CacheDirectory, name: string) { + return path.join(directory.operationPath, name); +} + +function readFixedBytes(fd: number, size: number) { + const bytes = Buffer.allocUnsafe(size); + let offset = 0; + while (offset < size) { + const count = readSync(fd, bytes, offset, size - offset, offset); + if (count === 0) break; + offset += count; + } + return offset === size ? bytes : bytes.subarray(0, offset); +} + +function readRegularFile( + filePath: string, + maxBytes: number, +): ReadFileResult | undefined { + let before: Stats; + try { + before = lstatSync(filePath); + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } + if (before.isSymbolicLink() || !before.isFile()) { + throw new Error(`Unsafe result artifact file: ${filePath}`); + } + + let fd: number; + try { + fd = openSync(filePath, READ_ONLY_NOFOLLOW); + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } + try { + const opened = fstatSync(fd); + if ( + !opened.isFile() || + !sameIdentity(identityOf(before), identityOf(opened)) + ) { + throw new Error(`Result artifact file changed during open: ${filePath}`); + } + if (!Number.isSafeInteger(opened.size) || opened.size > maxBytes) { + throw new Error(`Result artifact exceeds the read limit: ${filePath}`); + } + const bytes = readFixedBytes(fd, opened.size); + const after = fstatSync(fd); + if ( + !sameIdentity(identityOf(opened), identityOf(after)) || + !after.isFile() || + after.size !== opened.size || + bytes.byteLength !== opened.size + ) { + throw new Error(`Result artifact changed during read: ${filePath}`); + } + return { bytes }; + } finally { + closeQuietly(fd); + } +} + +function inspectCachedArtifact( + directory: CacheDirectory, + name: string, +): CachedArtifact | undefined { + const artifactPath = artifactPathIn(directory, name); + let before: Stats; + try { + before = lstatSync(artifactPath); + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } + // Symlinks and unknown file types are ignored during enumeration. They are + // never opened, followed, or selected for deletion. + if (before.isSymbolicLink() || !before.isFile()) return undefined; + + let fd: number; + try { + fd = openSync(artifactPath, READ_ONLY_NOFOLLOW); + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } + try { + const opened = fstatSync(fd); + if ( + !opened.isFile() || + !sameIdentity(identityOf(before), identityOf(opened)) + ) { + throw new Error( + `Unsafe result artifact changed during scan: ${artifactPath}`, + ); + } + const identity = identityOf(opened); + return { + name, + size: opened.size, + modifiedAt: opened.mtimeMs, + ...identity, + }; + } finally { + closeQuietly(fd); + } +} + +function cacheArtifacts(directory: CacheDirectory): CachedArtifact[] { + assertDirectoryStable(directory); + const artifacts: CachedArtifact[] = []; + for (const entry of readdirSync(directory.operationPath, { + withFileTypes: true, + })) { + const name = entry.name; + if (!RESULT_ARTIFACT_NAME.test(name)) continue; + const artifact = inspectCachedArtifact(directory, name); + if (artifact) artifacts.push(artifact); + } + assertDirectoryStable(directory); + return artifacts; +} + +function isCacheMetadataName(name: string) { + return ( + RESULT_ARTIFACT_TEMP_NAME.test(name) || + RESULT_CACHE_OWNER_NAME.test(name) || + RESULT_CACHE_RECOVERY_NAME.test(name) + ); +} + +function cleanStaleCacheMetadata(directory: CacheDirectory) { + const cutoff = Date.now() - CACHE_METADATA_STALE_AFTER_MS; + for (const entry of readdirSync(directory.operationPath, { + withFileTypes: true, + })) { + const name = entry.name; + if (!isCacheMetadataName(name)) continue; + const metadataPath = artifactPathIn(directory, name); + let stat: Stats; + try { + stat = lstatSync(metadataPath); + } catch (error) { + if (isErrno(error, "ENOENT")) continue; + throw error; + } + if (stat.isSymbolicLink() || !stat.isFile() || stat.mtimeMs > cutoff) { + continue; + } + if (RESULT_CACHE_OWNER_NAME.test(name)) { + const owner = readLockOwner(directory, name); + if (owner && !definitelyDead(owner.pid)) continue; + } + removeRenamedEntry(directory, metadataPath, identityOf(stat)); + } +} + +function parseLockOwner(bytes: Uint8Array): LockOwner | undefined { + try { + const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8")); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const candidate = value as Record; + if ( + candidate.version !== 1 || + !Number.isSafeInteger(candidate.pid) || + (candidate.pid as number) <= 0 || + typeof candidate.token !== "string" || + !/^[0-9a-f-]{36}$/iu.test(candidate.token) || + !Number.isSafeInteger(candidate.createdAt) || + (candidate.createdAt as number) <= 0 + ) { + return undefined; + } + return { + version: 1, + pid: candidate.pid as number, + token: candidate.token as string, + createdAt: candidate.createdAt as number, + }; + } catch { + return undefined; + } +} + +function readLockOwner( + directory: CacheDirectory, + name: string, +): LockOwner | undefined { + const result = readRegularFile( + artifactPathIn(directory, name), + MAX_LOCK_DOCUMENT_BYTES, + ); + return result ? parseLockOwner(result.bytes) : undefined; +} + +function lockPath(directory: CacheDirectory) { + return artifactPathIn(directory, RESULT_CACHE_LOCK_NAME); +} + +function ownerPath(directory: CacheDirectory, owner: LockOwner) { + return artifactPathIn( + directory, + `${RESULT_CACHE_OWNER_PREFIX}${owner.pid}.${owner.token}`, + ); +} + +function sameOwner(left: LockOwner | undefined, right: LockOwner) { + return ( + left?.version === right.version && + left.pid === right.pid && + left.token === right.token && + left.createdAt === right.createdAt + ); } /** - * Persist one immutable, content-addressed final answer below Pi's cache. - * Model-authored titles and paths never participate in the filename. + * PID liveness is deliberately conservative: a reused PID is treated as + * alive, so automatic recovery may leave a cache unavailable. There is no + * portable Node API for a process-start identity on every supported POSIX + * host; operators can remove the lock/owner metadata after confirming the + * owning process is gone. Never replace this with an age-only timeout. */ -export function persistResultArtifact(agentDir: string, content: string) { - let directory = path.resolve(agentDir); - for (const segment of RESULT_ARTIFACT_DIR) { - directory = ensureDirectory(directory, segment); +function definitelyDead(pid: number) { + if (pid === process.pid) return false; + try { + process.kill(pid, 0); + return false; + } catch (error) { + return isErrno(error, "ESRCH"); } +} - const digest = createHash("sha256").update(content).digest("hex"); - const artifactPath = path.join(directory, `${digest}.txt`); +function removeRenamedEntry( + directory: CacheDirectory, + sourcePath: string, + expected: FileIdentity, +) { + const quarantinePath = artifactPathIn( + directory, + `${RESULT_CACHE_RECOVERY_PREFIX}${randomUUID()}`, + ); + assertDirectoryStable(directory); try { - writeFileSync(artifactPath, content, { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); + // Rename moves the directory entry itself. If a race replaced the source + // with a symlink, it is moved to quarantine and never dereferenced. + renameSync(sourcePath, quarantinePath); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - const stat = lstatSync(artifactPath); + if (isErrno(error, "ENOENT")) { + throw new Error(`Owned cache entry disappeared: ${sourcePath}`); + } + throw error; + } + + const moved = lstatSync(quarantinePath); + if ( + moved.isSymbolicLink() || + !moved.isFile() || + !sameIdentity(identityOf(moved), expected) + ) { + // Keep an uncertain entry quarantined instead of deleting it. The name is + // outside the owned-artifact grammar, so later retention will ignore it. + throw new Error(`Unsafe result artifact cleanup target: ${sourcePath}`); + } + unlinkSync(quarantinePath); + assertDirectoryStable(directory); +} + +function abandonRecoveredLock( + _directory: CacheDirectory, + _recoveryPath: string, +) { + // Never relink an untrusted recovery pathname. Leaving it quarantined keeps + // an uncertain entry out of the published lock name and lets a later owner + // make progress instead of poisoning the cache with a symlink lock. +} + +function reclaimDeadLock(directory: CacheDirectory) { + const lock = lockPath(directory); + const owner = readLockOwner(directory, RESULT_CACHE_LOCK_NAME); + if (!owner || !definitelyDead(owner.pid)) return false; + const claim = ownerPath(directory, owner); + const claimOwner = readLockOwner(directory, path.basename(claim)); + if (!sameOwner(claimOwner, owner)) return false; + let lockStat: Stats; + let claimStat: Stats; + try { + lockStat = lstatSync(lock); + claimStat = lstatSync(claim); + } catch { + return false; + } + if ( + lockStat.isSymbolicLink() || + claimStat.isSymbolicLink() || + !lockStat.isFile() || + !claimStat.isFile() || + !sameIdentity(identityOf(lockStat), identityOf(claimStat)) + ) { + return false; + } + + const recoveryPath = artifactPathIn( + directory, + `${RESULT_CACHE_RECOVERY_PREFIX}${randomUUID()}`, + ); + try { + renameSync(lock, recoveryPath); + } catch (error) { + if (isErrno(error, "ENOENT")) return true; + return false; + } + try { + const recovered = lstatSync(recoveryPath); + const currentClaim = lstatSync(claim); + const expectedIdentity = identityOf(lockStat); + const recoveredIdentity = identityOf(recovered); + const claimIdentity = identityOf(currentClaim); + if ( + recovered.isSymbolicLink() || + currentClaim.isSymbolicLink() || + !recovered.isFile() || + !currentClaim.isFile() || + !sameIdentity(recoveredIdentity, claimIdentity) || + !sameIdentity(recoveredIdentity, expectedIdentity) + ) { + abandonRecoveredLock(directory, recoveryPath); + return false; + } + unlinkSync(claim); + unlinkSync(recoveryPath); + return true; + } catch { + abandonRecoveredLock(directory, recoveryPath); + return false; + } +} + +function acquireCacheLock(directory: CacheDirectory): CacheLock { + for (let attempt = 0; attempt < MAX_LOCK_ACQUIRE_ATTEMPTS; attempt++) { + const owner: LockOwner = { + version: 1, + pid: process.pid, + token: randomUUID(), + createdAt: Date.now(), + }; + const claimPath = ownerPath(directory, owner); + const lock = lockPath(directory); + let fd: number | undefined; + try { + fd = openSync( + claimPath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + NOFOLLOW, + 0o600, + ); + writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8"); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + } catch (error) { + if (fd !== undefined) closeQuietly(fd); + unlinkQuietly(claimPath); + throw error; + } + + try { + linkSync(claimPath, lock); + return { claimPath, lockPath: lock, owner }; + } catch (error) { + unlinkQuietly(claimPath); + if ( + isErrno(error, "EEXIST") && + attempt + 1 < MAX_LOCK_ACQUIRE_ATTEMPTS && + reclaimDeadLock(directory) + ) { + continue; + } + if (isErrno(error, "EEXIST")) { + throw new Error( + "Result artifact cache is busy or has uncertain ownership", + ); + } + throw error; + } + } + throw new Error("Result artifact cache lock acquisition was not stable"); +} + +function releaseCacheLock(directory: CacheDirectory, lock: CacheLock) { + assertDirectoryStable(directory); + const lockStat = lstatSync(lock.lockPath); + const claimStat = lstatSync(lock.claimPath); + if ( + lockStat.isSymbolicLink() || + claimStat.isSymbolicLink() || + !lockStat.isFile() || + !claimStat.isFile() || + !sameIdentity(identityOf(lockStat), identityOf(claimStat)) + ) { + throw new Error("Refusing to release an uncertain result artifact lock"); + } + const publishedOwner = readLockOwner(directory, RESULT_CACHE_LOCK_NAME); + const claimOwner = readLockOwner(directory, path.basename(lock.claimPath)); + if ( + !sameOwner(publishedOwner, lock.owner) || + !sameOwner(claimOwner, lock.owner) + ) { + throw new Error("Refusing to release an uncertain result artifact lock"); + } + + const releasePath = artifactPathIn( + directory, + `${RESULT_CACHE_RECOVERY_PREFIX}${randomUUID()}`, + ); + // Move the lock entry itself out of the published name before deleting it. + // If a race replaced the lock path, the moved inode is detected below and + // left quarantined rather than relinked into a potentially poisoned name. + renameSync(lock.lockPath, releasePath); + try { + const released = lstatSync(releasePath); if ( - !stat.isFile() || - stat.isSymbolicLink() || - readFileSync(artifactPath, "utf8") !== content + released.isSymbolicLink() || + !released.isFile() || + !sameIdentity(identityOf(released), identityOf(lockStat)) ) { - throw new Error(`Result artifact collision: ${artifactPath}`); + throw new Error("Refusing to release an uncertain result artifact lock"); + } + try { + unlinkSync(releasePath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } + try { + unlinkSync(lock.claimPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } + } finally { + assertDirectoryStable(directory); + } +} + +function withCacheLock(directory: CacheDirectory, action: () => T) { + const lock = acquireCacheLock(directory); + let value!: T; + let actionSucceeded = false; + let actionError: unknown; + try { + value = action(); + actionSucceeded = true; + } catch (error) { + actionError = error; + } + + try { + releaseCacheLock(directory, lock); + } catch { + // A completed publication remains authoritative even if optional lock + // metadata cleanup fails. The next writer will fail closed if ownership is + // uncertain, but a valid reference must not be discarded. + } + if (!actionSucceeded) throw actionError; + return value; +} + +function existingArtifactMatches( + directory: CacheDirectory, + name: string, + content: string, + expectedDigest: string, +) { + const result = readRegularFile( + artifactPathIn(directory, name), + MAX_RESULT_ARTIFACT_BYTES, + ); + if (!result) return false; + const actualDigest = digestForBytes(result.bytes); + if ( + actualDigest !== expectedDigest || + result.bytes.toString("utf8") !== content + ) { + throw new Error( + `Result artifact collision: ${artifactPathIn(directory, name)}`, + ); + } + return true; +} + +function publishResultArtifact( + directory: CacheDirectory, + name: string, + content: string, + expectedDigest: string, +) { + const targetPath = artifactPathIn(directory, name); + const temporaryName = `.${name}.${randomUUID()}.tmp`; + const temporaryPath = artifactPathIn(directory, temporaryName); + let fd: number | undefined; + try { + fd = openSync( + temporaryPath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + NOFOLLOW, + 0o600, + ); + writeFileSync(fd, Buffer.from(content, "utf8")); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + + // Hard-link publication is atomic and cannot overwrite a competing target. + try { + linkSync(temporaryPath, targetPath); + return true; + } catch (error) { + if (!isErrno(error, "EEXIST")) throw error; + if (!existingArtifactMatches(directory, name, content, expectedDigest)) { + throw error; + } + return false; + } + } finally { + if (fd !== undefined) closeQuietly(fd); + try { + unlinkSync(temporaryPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } + } +} + +function trimResultArtifactCache( + directory: CacheDirectory, + limits: ResultArtifactLimits, + incomingBytes: number, + protectedName?: string, +) { + const artifacts = cacheArtifacts(directory); + let totalBytes = artifacts.reduce( + (total, artifact) => total + artifact.size, + 0, + ); + let count = artifacts.length; + const candidates = artifacts + .filter((artifact) => artifact.name !== protectedName) + .sort( + (left, right) => + left.modifiedAt - right.modifiedAt || + left.name.localeCompare(right.name), + ); + + while ( + count + (protectedName ? 0 : 1) > limits.maxFiles || + totalBytes + incomingBytes > limits.maxBytes + ) { + const candidate = candidates.shift(); + if (!candidate) { + throw new Error("Result artifact cache limit cannot be satisfied safely"); + } + removeRenamedEntry( + directory, + artifactPathIn(directory, candidate.name), + candidate, + ); + count--; + totalBytes -= candidate.size; + } +} + +function assertCacheWithinLimits( + directory: CacheDirectory, + limits: ResultArtifactLimits, +) { + const artifacts = cacheArtifacts(directory); + const totalBytes = artifacts.reduce( + (total, artifact) => total + artifact.size, + 0, + ); + if (artifacts.length > limits.maxFiles || totalBytes > limits.maxBytes) { + throw new Error("Result artifact cache limits were exceeded"); + } +} + +/** + * Persist one immutable, content-addressed final answer below Pi's cache. + * Model-authored titles and paths never participate in the durable identity. + * Retention and publication are one cache-wide transaction for cooperating + * OpenPI processes; cache failures remain safe for the caller to ignore. + */ +export function persistResultArtifact( + agentDir: string, + content: string, + options?: ResultArtifactCacheOptions, +): ResultArtifactRef { + const limits = cacheLimits(options); + assertCachePlatformSupported(); + const contentBytes = byteLength(content); + if (contentBytes > limits.maxBytes) { + throw new Error( + `Result artifact exceeds the ${limits.maxBytes}-byte cache capacity`, + ); + } + + const digest = digestForContent(content); + const ref: ResultArtifactRef = { version: 1, digest }; + const directory = openCacheDirectory(agentDir, true); + try { + return withCacheLock(directory, () => { + cleanStaleCacheMetadata(directory); + const name = `${digest}.txt`; + if (existingArtifactMatches(directory, name, content, digest)) { + trimResultArtifactCache(directory, limits, 0, name); + assertCacheWithinLimits(directory, limits); + return ref; + } + + trimResultArtifactCache(directory, limits, contentBytes); + publishResultArtifact(directory, name, content, digest); + if (!existingArtifactMatches(directory, name, content, digest)) { + throw new Error("Published result artifact disappeared"); + } + assertCacheWithinLimits(directory, limits); + return ref; + }); + } finally { + closeQuietly(directory.fd); + } +} + +export interface ResultPage { + readonly text: string; + readonly offset: number; + readonly limit: number; + readonly totalLines: number; + readonly hasMore: boolean; + readonly truncated: boolean; + readonly byteOffset?: number; + readonly nextByteOffset?: number; +} + +/** + * Resolve the exact settled text for model-facing paging. Artifact bytes win; + * retained canonical finalText is next. A truncated projection is never used. + */ +export function resolveExactResultText(options: { + readonly artifactText: string | undefined; + readonly retainedFinalText: string | undefined; + readonly resultIsCanonical: boolean; + readonly finalTextTruncated?: boolean; + readonly omittedFinalTextBytes: number; +}): string | undefined { + if (options.artifactText !== undefined) return options.artifactText; + if ( + !options.finalTextTruncated && + (options.resultIsCanonical || options.omittedFinalTextBytes <= 0) + ) { + return options.retainedFinalText ?? ""; + } + return undefined; +} + +function utf8PageEnd(bytes: Buffer, start: number, budget: number) { + let end = Math.min(bytes.length, start + budget); + while (end > start && end < bytes.length && (bytes[end] & 0xc0) === 0x80) { + end--; + } + return end; +} + +function boundedBytePage(bytes: Buffer, start: number, maxBytes: number) { + let end = utf8PageEnd(bytes, start, maxBytes); + if (end === start) { + throw new Error( + "maxBytes is too small to return the next complete UTF-8 code point.", + ); + } + + const noticeFor = (next: number) => `\n[page truncated; next ${next}]`; + const notice = noticeFor(end); + const contentBudget = maxBytes - byteLength(notice); + if (contentBudget > 0) { + const noticedEnd = utf8PageEnd(bytes, start, contentBudget); + if (noticedEnd > start) { + end = noticedEnd; + return { + text: `${bytes.subarray(start, end).toString("utf8")}${noticeFor(end)}`, + end, + }; + } + } + + // Tiny budgets may not fit a notice. The structured nextByteOffset remains + // authoritative, while the text still makes forward progress. + return { text: bytes.subarray(start, end).toString("utf8"), end }; +} + +/** + * Page exact result text either by line offset or by an absolute UTF-8 byte + * cursor. The byte cursor is used to continue a line split by the byte cap. + */ +export function pageResultText( + content: string, + options: { + readonly offset?: number; + readonly limit?: number; + readonly byteOffset?: number; + readonly maxBytes?: number; + } = {}, +): ResultPage { + if (options.offset !== undefined && options.byteOffset !== undefined) { + throw new Error("Specify either offset or byteOffset, not both."); + } + + const maxBytes = + Number.isSafeInteger(options.maxBytes) && (options.maxBytes as number) >= 0 + ? Math.min(MAX_RESULT_PAGE_BYTES, options.maxBytes as number) + : MAX_RESULT_PAGE_BYTES; + const requested = + Number.isSafeInteger(options.limit) && (options.limit as number) >= 1 + ? Math.floor(options.limit as number) + : MAX_RESULT_PAGE_LINES; + const limit = Math.min(MAX_RESULT_PAGE_LINES, requested); + const lines = content.split("\n"); + const totalLines = lines.length; + const bytes = Buffer.from(content, "utf8"); + + if (options.byteOffset !== undefined) { + const cursor = options.byteOffset; + if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > bytes.length) { + throw new Error( + "byteOffset must be a non-negative safe integer within the result.", + ); + } + if (cursor < bytes.length && (bytes[cursor] & 0xc0) === 0x80) { + throw new Error("byteOffset must be at a UTF-8 code-point boundary."); + } + if (cursor === bytes.length) { + return { + text: "", + offset: 0, + limit, + totalLines, + hasMore: false, + truncated: false, + byteOffset: cursor, + }; + } + + const end = utf8PageEnd(bytes, cursor, maxBytes); + if (end === bytes.length) { + return { + text: bytes.subarray(cursor).toString("utf8"), + offset: 0, + limit, + totalLines, + hasMore: false, + truncated: false, + byteOffset: cursor, + }; } + const page = boundedBytePage(bytes, cursor, maxBytes); + return { + text: page.text, + offset: 0, + limit, + totalLines, + hasMore: true, + truncated: true, + byteOffset: cursor, + nextByteOffset: page.end, + }; } - return artifactPath; + + const offset = + Number.isSafeInteger(options.offset) && (options.offset as number) >= 0 + ? Math.floor(options.offset as number) + : 0; + if (offset >= totalLines) { + return { + text: `No result lines at offset ${offset}.`, + offset, + limit, + totalLines, + hasMore: false, + truncated: false, + }; + } + + const page = lines.slice(offset, offset + limit).join("\n"); + if (byteLength(page) <= maxBytes) { + return { + text: page, + offset, + limit, + totalLines, + hasMore: offset + limit < totalLines, + truncated: false, + }; + } + + const pageStartByte = + byteLength(lines.slice(0, offset).join("\n")) + (offset > 0 ? 1 : 0); + const bounded = boundedBytePage(Buffer.from(page, "utf8"), 0, maxBytes); + return { + text: bounded.text, + offset, + limit, + totalLines, + hasMore: true, + truncated: true, + byteOffset: pageStartByte, + nextByteOffset: pageStartByte + bounded.end, + }; +} + +/** + * Read an exact result by reconstructing its path from the trusted cache root + * and a validated digest reference. The reference itself can never escape the + * cache directory or point at an arbitrary absolute path. + */ +export function readResultArtifact(agentDir: string, ref: ResultArtifactRef) { + if (!isResultArtifactRef(ref)) return undefined; + let directory: CacheDirectory; + try { + directory = openCacheDirectory(agentDir, false); + } catch { + return undefined; + } + try { + assertDirectoryStable(directory); + const result = readRegularFile( + artifactPathIn(directory, `${ref.digest}.txt`), + MAX_RESULT_ARTIFACT_BYTES, + ); + assertDirectoryStable(directory); + if (!result || digestForBytes(result.bytes) !== ref.digest) + return undefined; + const content = result.bytes.toString("utf8"); + if (!Buffer.from(content, "utf8").equals(result.bytes)) return undefined; + return content; + } catch { + return undefined; + } finally { + closeQuietly(directory.fd); + } +} + +function recoveryInstruction(recoveryId: string | undefined, offset: number) { + return recoveryId + ? `Full final answer available via subagent_result(id=${JSON.stringify(recoveryId)}, offset=${offset}, limit=200).` + : "Full final answer was saved for protected recovery through the owning subagent result reader."; +} + +function compactFooter( + hasArtifact: boolean, + recoveryId: string | undefined, + offset: number, + totalBytes: number, + totalLines: number, + shownBytes: number, +) { + if (hasArtifact) { + return recoveryInstruction(recoveryId, offset); + } + return `Full final answer could not be saved; head and tail only (${formatSize(shownBytes)} of ${formatSize(totalBytes)}; ${totalLines} total lines).`; +} + +function verboseFooter( + hasArtifact: boolean, + recoveryId: string | undefined, + offset: number, + totalBytes: number, + totalLines: number, + shownBytes: number, +) { + const recovery = hasArtifact + ? recoveryInstruction(recoveryId, offset) + : "Full final answer could not be saved; only the head and tail above are available."; + return ( + `[Output truncated: showing ${formatSize(shownBytes)} of ${formatSize(totalBytes)} ` + + `across the head and tail (${totalLines} total lines).\n${recovery}]` + ); +} + +interface FooterCandidate { + readonly render: (offset: number, shownBytes: number) => string; + readonly hasArtifact: boolean; +} + +interface ProjectionCandidate { + readonly text: string; + readonly bodyBudget: number; + readonly hasArtifact: boolean; + readonly readableBody: boolean; +} + +function firstAndLastLineBytes(content: string) { + const firstBreak = content.indexOf("\n"); + const first = firstBreak < 0 ? content : content.slice(0, firstBreak); + const lastBreak = content.lastIndexOf("\n"); + const last = lastBreak < 0 ? content : content.slice(lastBreak + 1); + return { + first: byteLength(first), + last: byteLength(last), + sameLine: firstBreak < 0, + }; +} + +function bodyByteAllocation(content: string, budget: number) { + if (budget <= 0) return { head: 0, tail: 0 }; + const lines = firstAndLastLineBytes(content); + if (!lines.sameLine && lines.first + lines.last <= budget) { + return { head: lines.first, tail: budget - lines.first }; + } + if (lines.last < budget) { + return { head: budget - lines.last, tail: lines.last }; + } + const tail = Math.max(1, Math.floor(budget / 2)); + return { head: budget - tail, tail }; +} + +function assembleProjection( + content: string, + bodyBudget: number, + maxLines: number, + marker: string, + gap: string, + footer: (offset: number, shownBytes: number) => string, + hasArtifact: boolean, +): ProjectionCandidate { + const safeBudget = Math.max(0, Math.floor(bodyBudget)); + if (safeBudget === 0) { + return { + text: footer(0, 0), + bodyBudget: 0, + hasArtifact, + readableBody: false, + }; + } + + const headLines = Math.max(1, Math.floor(maxLines * HEAD_SHARE)); + const tailLines = Math.max(1, maxLines - headLines); + const allocation = bodyByteAllocation(content, safeBudget); + const headResult = truncateHead(content, { + maxBytes: allocation.head, + maxLines: headLines, + }); + const tailResult = truncateTail(content, { + maxBytes: allocation.tail, + maxLines: tailLines, + }); + const head = + headResult.content || sliceStartToUtf8Bytes(content, allocation.head); + const tail = tailResult.content; + const shownBytes = byteLength(head) + byteLength(tail); + const body = `${head}${gap}${marker}${gap}${tail}`; + const text = `${body}${gap}${footer( + // 0-based line offset of the first omitted head line, matching + // subagent_result(id, offset, limit). + headResult.outputLines, + shownBytes, + )}`; + const lines = firstAndLastLineBytes(content); + const readableBody = + safeBudget >= MIN_USEFUL_BODY_BYTES && + (lines.sameLine || + (byteLength(head) >= lines.first && byteLength(tail) >= lines.last)); + return { text, bodyBudget: safeBudget, hasArtifact, readableBody }; +} + +function fitProjection( + content: string, + maxBytes: number, + maxLines: number, + marker: string, + gap: string, + footer: (offset: number, shownBytes: number) => string, + hasArtifact: boolean, +): ProjectionCandidate | undefined { + let low = 0; + let high = Math.max(0, Math.floor(maxBytes)); + let best: ProjectionCandidate | undefined; + for (let attempt = 0; attempt < 20 && low <= high; attempt++) { + const bodyBudget = Math.floor((low + high) / 2); + const candidate = assembleProjection( + content, + bodyBudget, + maxLines, + marker, + gap, + footer, + hasArtifact, + ); + if (byteLength(candidate.text) <= maxBytes) { + best = candidate; + low = bodyBudget + 1; + } else { + high = bodyBudget - 1; + } + } + return best; } /** * Build the single model-visible projection used by automatic delivery and - * explicit waits. Short answers pass through byte-for-byte. Long answers keep - * both decision context at the start and verdict/evidence at the end, while a - * plain-text artifact preserves the exact final answer for Pi's native read. + * explicit waits. The complete rendered text, including recovery footer, is + * always bounded by maxBytes. */ export function projectResult( content: string, options: ResultProjectionOptions, ): ResultProjection { + const maxBytes = Number.isFinite(options.maxBytes) + ? Math.max(0, Math.floor(options.maxBytes)) + : 0; + const maxLines = Number.isFinite(options.maxLines) + ? Math.max(1, Math.floor(options.maxLines)) + : 1; const probe = truncateHead(content, { - maxBytes: options.maxBytes, - maxLines: options.maxLines, + maxBytes, + maxLines, }); if (!probe.truncated) return { text: content, truncated: false }; - const headLines = Math.max(1, Math.floor(options.maxLines * HEAD_SHARE)); - const tailLines = Math.max(1, options.maxLines - headLines); - - let artifactPath: string | undefined; + let hasArtifact = options.artifactAvailable === true; let artifactSaveFailed = false; - try { - artifactPath = options.writeArtifact(content); - } catch { - // Delivery is more important than the optional recovery cache. The footer - // below stays explicit so a failed write never advertises a false path. - artifactSaveFailed = true; - } - - let bodyBudget = options.maxBytes; - let text = ""; - for (let attempt = 0; attempt < 8; attempt++) { - const headBytes = Math.max(1, Math.floor(bodyBudget * HEAD_SHARE)); - const tailBytes = Math.max(1, bodyBudget - headBytes); - const headResult = truncateHead(content, { - maxBytes: headBytes, - maxLines: headLines, - }); - const tailResult = truncateTail(content, { - maxBytes: tailBytes, - maxLines: tailLines, - }); - const head = - headResult.content || sliceStartToUtf8Bytes(content, headBytes); - const tail = tailResult.content; - const shownBytes = - Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8"); - const recovery = artifactPath - ? `Full final answer: ${JSON.stringify(artifactPath)}\nUse Pi's read tool with path=${JSON.stringify(artifactPath)}, offset=${Math.max(1, headResult.outputLines + 1)}, limit=200 to inspect the omitted middle; adjust offset to continue.` - : "Full final answer could not be saved; only the head and tail above are available."; - const footer = - `[Output truncated: showing ${formatSize(shownBytes)} of ${formatSize(probe.totalBytes)} ` + - `across the head and tail (${probe.totalLines} total lines).\n${recovery}]`; - text = `${head}\n\n[... middle omitted ...]\n\n${tail}\n\n${footer}`; - - const overflow = Buffer.byteLength(text, "utf8") - options.maxBytes; - if (overflow <= 0 || bodyBudget <= overflow + 2) break; - bodyBudget -= overflow; + if (!hasArtifact) { + try { + const result = options.writeArtifact(content); + hasArtifact = + result !== undefined && + result !== null && + result !== false && + result !== ""; + } catch { + // Delivery is more important than the optional recovery cache. + artifactSaveFailed = true; + } + } + const recoveryId = + typeof options.recoveryId === "string" && options.recoveryId.length > 0 + ? options.recoveryId + : undefined; + const persistence = hasArtifact + ? { artifactPersisted: true as const } + : artifactSaveFailed + ? { artifactSaveFailed: true as const } + : {}; + + const footerFactories: FooterCandidate[] = [ + ...(hasArtifact + ? [ + { + hasArtifact: true, + render: (offset: number, shownBytes: number) => + verboseFooter( + true, + recoveryId, + offset, + probe.totalBytes, + probe.totalLines, + shownBytes, + ), + }, + { + hasArtifact: true, + render: (offset: number, shownBytes: number) => + compactFooter( + true, + recoveryId, + offset, + probe.totalBytes, + probe.totalLines, + shownBytes, + ), + }, + ] + : []), + { + hasArtifact: false, + render: (offset: number, shownBytes: number) => + compactFooter( + false, + undefined, + offset, + probe.totalBytes, + probe.totalLines, + shownBytes, + ), + }, + { + hasArtifact: false, + render: () => OUTPUT_NO_ARTIFACT_MARKER, + }, + { + hasArtifact: false, + render: () => OUTPUT_TRUNCATED_MARKER, + }, + ]; + + const bodyVariants = [ + { marker: OUTPUT_MIDDLE_MARKER, gap: "\n\n" }, + { marker: OUTPUT_MIDDLE_MARKER, gap: "\n" }, + { marker: "[...]", gap: "\n" }, + { marker: "...", gap: "\n" }, + ]; + const candidates: ProjectionCandidate[] = []; + for (const footer of footerFactories) { + for (const variant of bodyVariants) { + const fitted = fitProjection( + content, + maxBytes, + maxLines, + variant.marker, + variant.gap, + footer.render, + footer.hasArtifact, + ); + if (fitted) candidates.push(fitted); + } + } + + const artifactCandidates = candidates.filter( + (candidate) => candidate.hasArtifact && candidate.readableBody, + ); + const nonArtifactCandidates = candidates.filter( + (candidate) => !candidate.hasArtifact, + ); + const preferredArtifactCandidates = artifactCandidates.filter((candidate) => + candidate.text.includes(OUTPUT_MIDDLE_MARKER), + ); + const selected = + (preferredArtifactCandidates.length > 0 + ? preferredArtifactCandidates + : artifactCandidates + ).sort((left, right) => right.bodyBudget - left.bodyBudget)[0] ?? + nonArtifactCandidates.sort( + (left, right) => + Number(right.text.includes(OUTPUT_MIDDLE_MARKER)) - + Number(left.text.includes(OUTPUT_MIDDLE_MARKER)) || + right.bodyBudget - left.bodyBudget, + )[0]; + if (selected && byteLength(selected.text) <= maxBytes) { + return { + text: selected.text, + truncated: true, + ...persistence, + }; } + // maxBytes can be smaller than every human-readable marker. Keep the hard + // contract as the final invariant, even for pathological test/config caps. return { - text, + text: sliceStartToUtf8Bytes(OUTPUT_TRUNCATED_MARKER, maxBytes), truncated: true, - ...(artifactPath ? { artifactPath } : {}), - ...(artifactSaveFailed ? { artifactSaveFailed: true } : {}), + ...persistence, }; } diff --git a/extensions/subagents/src/snapshot.ts b/extensions/subagents/src/snapshot.ts new file mode 100644 index 00000000..5e53ea13 --- /dev/null +++ b/extensions/subagents/src/snapshot.ts @@ -0,0 +1,616 @@ +import type { + LiveToolState, + QueuedMessage, + SubagentMeta, + SubagentSnapshot, + SubagentSnapshotProjection, + TranscriptItem, + TranscriptPart, +} from "./domain.ts"; + +/** Aggregate UTF-8 budget for the in-memory subagent read model. */ +export const DEFAULT_SUBAGENT_SNAPSHOT_MAX_BYTES = 256 * 1024; + +const OMIT_MARKER = "\n[... omitted ...]\n"; +const CORE_TEXT_BYTES = 512; +const DISPLAY_ITEM_BYTES = 4 * 1024; +const MAX_TRANSCRIPT_PARTS = 32; +const MAX_META_TEXT_BYTES = 2 * 1024; + +interface TrimmedText { + readonly text: string; + readonly omittedBytes: number; +} + +function byteLength(value: string) { + return Buffer.byteLength(value, "utf8"); +} + +/** Return a valid UTF-8 prefix without splitting a code point. */ +export function truncateUtf8Head(value: string, maxBytes: number) { + if (maxBytes <= 0) return ""; + const bytes = Buffer.from(value, "utf8"); + if (bytes.length <= maxBytes) return value; + let end = maxBytes; + while (end > 0 && end < bytes.length && (bytes[end] & 0xc0) === 0x80) { + end--; + } + return bytes.subarray(0, end).toString("utf8"); +} + +/** Return a valid UTF-8 suffix without splitting a code point. */ +export function truncateUtf8Tail(value: string, maxBytes: number) { + if (maxBytes <= 0) return ""; + const bytes = Buffer.from(value, "utf8"); + if (bytes.length <= maxBytes) return value; + let start = bytes.length - maxBytes; + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) { + start++; + } + return bytes.subarray(start).toString("utf8"); +} + +/** Trim by serialized UTF-8 bytes while retaining both ends of long text. */ +function trimText(value: string, maxBytes: number): TrimmedText { + const originalBytes = byteLength(value); + if (originalBytes <= maxBytes) return { text: value, omittedBytes: 0 }; + if (maxBytes <= 0) return { text: "", omittedBytes: originalBytes }; + + const markerBytes = byteLength(OMIT_MARKER); + if (maxBytes <= markerBytes) { + const text = truncateUtf8Head(value, maxBytes); + return { text, omittedBytes: originalBytes - byteLength(text) }; + } + + const bodyBytes = maxBytes - markerBytes; + let headBytes = Math.ceil(bodyBytes / 2); + let tailBytes = Math.floor(bodyBytes / 2); + let text = `${truncateUtf8Head(value, headBytes)}${OMIT_MARKER}${truncateUtf8Tail(value, tailBytes)}`; + while (byteLength(text) > maxBytes && (headBytes > 0 || tailBytes > 0)) { + if (headBytes >= tailBytes && headBytes > 0) headBytes--; + else if (tailBytes > 0) tailBytes--; + text = `${truncateUtf8Head(value, headBytes)}${OMIT_MARKER}${truncateUtf8Tail(value, tailBytes)}`; + } + return { text, omittedBytes: originalBytes - byteLength(text) }; +} + +function jsonBytes(value: unknown) { + const serialized = JSON.stringify(value); + if (serialized === undefined) + throw new Error("Subagent snapshot is not serializable"); + return byteLength(serialized); +} + +function compactMeta(meta: SubagentMeta | undefined) { + const source = meta ?? { backend: "pi" as const }; + const model = source.modelLabel + ? trimText(source.modelLabel, MAX_META_TEXT_BYTES) + : undefined; + const sessionFilePath = source.sessionFilePath + ? trimText(source.sessionFilePath, MAX_META_TEXT_BYTES) + : undefined; + return { + meta: { + backend: source.backend, + ...(model ? { modelLabel: model.text } : {}), + ...(source.contextWindow !== undefined + ? { contextWindow: source.contextWindow } + : {}), + ...(sessionFilePath ? { sessionFilePath: sessionFilePath.text } : {}), + } satisfies SubagentMeta, + omittedBytes: + (model?.omittedBytes ?? 0) + (sessionFilePath?.omittedBytes ?? 0), + }; +} + +function compactPart(part: TranscriptPart, maxBytes: number) { + if (part.type === "toolCall") { + const toolId = trimText(part.toolId, CORE_TEXT_BYTES); + const name = trimText(part.name, CORE_TEXT_BYTES); + const args = part.argsPreview + ? trimText(part.argsPreview, maxBytes) + : undefined; + return { + part: { + type: "toolCall" as const, + toolId: toolId.text, + name: name.text, + ...(args ? { argsPreview: args.text } : {}), + }, + omittedBytes: + toolId.omittedBytes + name.omittedBytes + (args?.omittedBytes ?? 0), + } as const; + } + const text = trimText(part.text, maxBytes); + return { + part: { + type: part.type, + text: text.text, + ...(part.type === "thinking" && part.redacted !== undefined + ? { redacted: part.redacted } + : {}), + }, + omittedBytes: text.omittedBytes, + } as const; +} + +function compactTranscriptItem(item: TranscriptItem, maxBytes: number) { + if (item.kind === "user") { + const text = trimText(item.text, maxBytes); + return { + item: { kind: "user" as const, text: text.text }, + omittedBytes: text.omittedBytes, + } as const; + } + if (item.kind === "toolResult") { + const toolId = trimText(item.toolId, CORE_TEXT_BYTES); + const name = trimText(item.name, CORE_TEXT_BYTES); + const output = item.outputPreview + ? trimText(item.outputPreview, maxBytes) + : undefined; + return { + item: { + kind: "toolResult" as const, + toolId: toolId.text, + name: name.text, + isError: item.isError, + ...(output ? { outputPreview: output.text } : {}), + }, + omittedBytes: + toolId.omittedBytes + name.omittedBytes + (output?.omittedBytes ?? 0), + } as const; + } + + const sourceParts = item.parts; + const partBudget = Math.max( + 1, + Math.floor( + maxBytes / + Math.max(1, Math.min(sourceParts.length, MAX_TRANSCRIPT_PARTS)), + ), + ); + let parts = sourceParts.map((part) => compactPart(part, partBudget)); + let omittedBytes = parts.reduce( + (total, part) => total + part.omittedBytes, + 0, + ); + if (parts.length > MAX_TRANSCRIPT_PARTS) { + const head = Math.ceil(MAX_TRANSCRIPT_PARTS / 2); + parts = [ + ...parts.slice(0, head), + ...parts.slice(-Math.floor(MAX_TRANSCRIPT_PARTS / 2)), + ]; + omittedBytes += + jsonBytes(sourceParts) - jsonBytes(parts.map((entry) => entry.part)); + } + return { + item: { + kind: "assistant" as const, + parts: parts.map((entry) => entry.part), + }, + omittedBytes, + } as const; +} + +function removeMiddle(items: T[]) { + if (items.length === 0) return false; + items.splice(Math.floor(items.length / 2), 1); + return true; +} + +function projectTranscript( + items: ReadonlyArray, + maxBytes: number, +) { + if (items.length === 0) + return { items: [] as TranscriptItem[], omittedItems: 0, omittedBytes: 0 }; + if (maxBytes <= 2) { + return { + items: [], + omittedItems: items.length, + omittedBytes: jsonBytes(items), + }; + } + + let omittedBytes = 0; + let projected = items.map((item) => { + const compacted = compactTranscriptItem(item, DISPLAY_ITEM_BYTES); + omittedBytes += compacted.omittedBytes; + return compacted.item; + }); + const originalLength = projected.length; + + while (projected.length > 1 && jsonBytes(projected) > maxBytes) + removeMiddle(projected); + + if (projected.length > 0 && jsonBytes(projected) > maxBytes) { + const perItem = Math.max(1, Math.floor(maxBytes / projected.length)); + projected = projected.map((item) => { + const compacted = compactTranscriptItem(item, perItem); + omittedBytes += compacted.omittedBytes; + return compacted.item; + }); + } + while (projected.length > 0 && jsonBytes(projected) > maxBytes) + removeMiddle(projected); + + omittedBytes = Math.max( + omittedBytes, + Math.max(0, jsonBytes(items) - jsonBytes(projected)), + ); + return { + items: projected, + omittedItems: originalLength - projected.length, + omittedBytes, + }; +} + +function compactLiveTool(tool: LiveToolState, maxBytes: number) { + const toolId = trimText(tool.toolId, CORE_TEXT_BYTES); + const name = trimText(tool.name, CORE_TEXT_BYTES); + const args = tool.argsPreview + ? trimText(tool.argsPreview, maxBytes) + : undefined; + const output = tool.outputPreview + ? trimText(tool.outputPreview, maxBytes) + : undefined; + return { + tool: { + toolId: toolId.text, + name: name.text, + ...(args ? { argsPreview: args.text } : {}), + ...(output ? { outputPreview: output.text } : {}), + ...(tool.done !== undefined ? { done: tool.done } : {}), + ...(tool.isError !== undefined ? { isError: tool.isError } : {}), + }, + omittedBytes: + toolId.omittedBytes + + name.omittedBytes + + (args?.omittedBytes ?? 0) + + (output?.omittedBytes ?? 0), + } as const; +} + +function projectLiveTools( + tools: ReadonlyArray, + maxBytes: number, +) { + if (tools.length === 0) + return { tools: [] as LiveToolState[], omittedTools: 0, omittedBytes: 0 }; + if (maxBytes <= 2) + return { + tools: [], + omittedTools: tools.length, + omittedBytes: jsonBytes(tools), + }; + + let omittedBytes = 0; + let projected = tools.map((tool) => { + const compacted = compactLiveTool(tool, DISPLAY_ITEM_BYTES); + omittedBytes += compacted.omittedBytes; + return compacted.tool; + }); + const originalLength = projected.length; + while (projected.length > 1 && jsonBytes(projected) > maxBytes) + removeMiddle(projected); + while (projected.length > 0 && jsonBytes(projected) > maxBytes) + removeMiddle(projected); + omittedBytes = Math.max( + omittedBytes, + Math.max(0, jsonBytes(tools) - jsonBytes(projected)), + ); + return { + tools: projected, + omittedTools: originalLength - projected.length, + omittedBytes, + }; +} + +function projectQueued( + messages: ReadonlyArray, + maxBytes: number, +) { + if (messages.length === 0) + return { + queued: [] as QueuedMessage[], + omittedMessages: 0, + omittedBytes: 0, + }; + if (maxBytes <= 2) + return { + queued: [], + omittedMessages: messages.length, + omittedBytes: jsonBytes(messages), + }; + + let projected = messages.map((message) => ({ + kind: message.kind, + text: trimText(message.text, DISPLAY_ITEM_BYTES).text, + })); + const originalLength = projected.length; + while (projected.length > 1 && jsonBytes(projected) > maxBytes) + removeMiddle(projected); + while (projected.length > 0 && jsonBytes(projected) > maxBytes) + removeMiddle(projected); + return { + queued: projected, + omittedMessages: originalLength - projected.length, + omittedBytes: Math.max(0, jsonBytes(messages) - jsonBytes(projected)), + }; +} + +interface BuildOptions { + readonly transcriptBytes: number; + readonly liveAssistantBytes: number; + readonly liveToolsBytes: number; + readonly queuedBytes: number; + readonly finalTextBytes: number; + readonly promptBytes: number; + readonly coreBytes: number; + readonly includeDisplay: boolean; +} + +function buildCandidate(snapshot: SubagentSnapshot, options: BuildOptions) { + const title = trimText(snapshot.title, options.coreBytes); + const prompt = trimText(snapshot.prompt, options.promptBytes); + const cwd = trimText(snapshot.cwd, options.coreBytes); + const errorText = snapshot.errorText + ? trimText(snapshot.errorText, options.coreBytes) + : undefined; + // This is a compact recovery reference, not display text. It is retained + // only as an already-validated digest identity. + const resultArtifact = snapshot.resultArtifact; + const meta = compactMeta(snapshot.meta); + const transcript = options.includeDisplay + ? projectTranscript(snapshot.transcript, options.transcriptBytes) + : { + items: [], + omittedItems: snapshot.transcript.length, + omittedBytes: + snapshot.transcript.length > 0 ? jsonBytes(snapshot.transcript) : 0, + }; + const liveAssistant = snapshot.liveAssistant + ? { + text: trimText( + snapshot.liveAssistant.text, + Math.floor(options.liveAssistantBytes / 2), + ), + thinking: trimText( + snapshot.liveAssistant.thinking, + Math.ceil(options.liveAssistantBytes / 2), + ), + } + : undefined; + const liveTools = options.includeDisplay + ? projectLiveTools(snapshot.liveTools, options.liveToolsBytes) + : { + tools: [], + omittedTools: snapshot.liveTools.length, + omittedBytes: + snapshot.liveTools.length > 0 ? jsonBytes(snapshot.liveTools) : 0, + }; + const queued = options.includeDisplay + ? projectQueued(snapshot.queued, options.queuedBytes) + : { + queued: [], + omittedMessages: snapshot.queued.length, + omittedBytes: + snapshot.queued.length > 0 ? jsonBytes(snapshot.queued) : 0, + }; + const finalText = trimText(snapshot.finalText, options.finalTextBytes); + + const candidate: Record = { + id: snapshot.id, + origin: snapshot.origin, + backend: snapshot.backend, + title: title.text, + prompt: prompt.text, + cwd: cwd.text, + status: snapshot.status, + ...(snapshot.outcome ? { outcome: snapshot.outcome } : {}), + ...(snapshot.worktreeBranch + ? { worktreeBranch: snapshot.worktreeBranch } + : {}), + createdAt: snapshot.createdAt, + ...(snapshot.settledAt !== undefined + ? { settledAt: snapshot.settledAt } + : {}), + ...(errorText ? { errorText: errorText.text } : {}), + meta: meta.meta, + usage: { + ...(snapshot.usage.tokens !== undefined + ? { tokens: snapshot.usage.tokens } + : {}), + ...(snapshot.usage.contextWindow !== undefined + ? { contextWindow: snapshot.usage.contextWindow } + : {}), + }, + transcriptVersion: snapshot.transcriptVersion, + transcript: transcript.items, + ...(snapshot.liveAssistant + ? { + liveAssistant: { + text: liveAssistant?.text.text ?? "", + thinking: liveAssistant?.thinking.text ?? "", + }, + } + : {}), + liveTools: liveTools.tools, + queued: queued.queued, + finalText: finalText.text, + ...(snapshot.finalTextTruncated ? { finalTextTruncated: true } : {}), + ...(resultArtifact ? { resultArtifact } : {}), + turns: snapshot.turns, + }; + + const prior = snapshot.snapshot; + const omitted = { + // A projected snapshot can be projected again when the aggregate budget + // changes. Preserve the known omission, but never count the same bytes or + // entries again on every projection. + transcriptItems: Math.max( + prior?.omitted.transcriptItems ?? 0, + transcript.omittedItems, + ), + liveTools: Math.max(prior?.omitted.liveTools ?? 0, liveTools.omittedTools), + queued: Math.max(prior?.omitted.queued ?? 0, queued.omittedMessages), + liveAssistantBytes: Math.max( + prior?.omitted.liveAssistantBytes ?? 0, + (liveAssistant?.text.omittedBytes ?? 0) + + (liveAssistant?.thinking.omittedBytes ?? 0), + ), + finalTextBytes: Math.max( + prior?.omitted.finalTextBytes ?? 0, + finalText.omittedBytes, + ), + promptBytes: Math.max(prior?.omitted.promptBytes ?? 0, prompt.omittedBytes), + }; + const explicitOmittedBytes = + title.omittedBytes + + prompt.omittedBytes + + cwd.omittedBytes + + (errorText?.omittedBytes ?? 0) + + meta.omittedBytes + + transcript.omittedBytes + + liveTools.omittedBytes + + queued.omittedBytes + + (liveAssistant?.text.omittedBytes ?? 0) + + (liveAssistant?.thinking.omittedBytes ?? 0) + + finalText.omittedBytes; + const sourceForMeasurement = { ...snapshot, snapshot: undefined }; + const omittedBytes = Math.max( + prior?.omittedBytes ?? 0, + explicitOmittedBytes, + Math.max(0, jsonBytes(sourceForMeasurement) - jsonBytes(candidate)), + ); + const metadata = { + maxBytes: 0, + bytes: 0, + truncated: + omittedBytes > 0 || + omitted.transcriptItems > 0 || + omitted.liveTools > 0 || + omitted.queued > 0, + omittedBytes, + omitted, + } satisfies Omit & { + maxBytes: number; + bytes: number; + }; + return { candidate, metadata }; +} + +function finishCandidate( + candidate: Record, + metadata: ReturnType["metadata"], + cap: number, +) { + let bytes = 0; + for (let attempt = 0; attempt < 12; attempt++) { + candidate.snapshot = { ...metadata, maxBytes: cap, bytes }; + const nextBytes = jsonBytes(candidate); + if (nextBytes === bytes) return nextBytes <= cap ? candidate : undefined; + bytes = nextBytes; + } + candidate.snapshot = { ...metadata, maxBytes: cap, bytes }; + const finalBytes = jsonBytes(candidate); + return finalBytes <= cap ? candidate : undefined; +} + +/** + * Build one detached, bounded model/UI snapshot. Lifecycle identity and + * terminal facts remain explicit; transcript-like data is reconstructible from + * the native child session and is the first material shed under pressure. + */ +export function projectSubagentSnapshot( + snapshot: SubagentSnapshot, + maxBytes = DEFAULT_SUBAGENT_SNAPSHOT_MAX_BYTES, +): SubagentSnapshot | undefined { + const cap = Math.floor(maxBytes); + if (!Number.isFinite(cap) || cap <= 0) return undefined; + + const levels: BuildOptions[] = [ + { + transcriptBytes: Math.floor(cap * 0.28), + liveAssistantBytes: Math.floor(cap * 0.16), + liveToolsBytes: Math.floor(cap * 0.16), + queuedBytes: Math.floor(cap * 0.1), + finalTextBytes: Math.floor(cap * 0.16), + promptBytes: Math.floor(cap * 0.08), + coreBytes: CORE_TEXT_BYTES, + includeDisplay: true, + }, + { + transcriptBytes: Math.floor(cap * 0.12), + liveAssistantBytes: Math.floor(cap * 0.08), + liveToolsBytes: Math.floor(cap * 0.08), + queuedBytes: Math.floor(cap * 0.05), + finalTextBytes: Math.floor(cap * 0.08), + promptBytes: Math.floor(cap * 0.04), + coreBytes: Math.floor(CORE_TEXT_BYTES / 2), + includeDisplay: true, + }, + { + transcriptBytes: 2, + liveAssistantBytes: 2, + liveToolsBytes: 2, + queuedBytes: 2, + finalTextBytes: Math.min(4 * 1024, Math.floor(cap * 0.08)), + promptBytes: Math.min(CORE_TEXT_BYTES, Math.floor(cap * 0.04)), + coreBytes: Math.floor(CORE_TEXT_BYTES / 2), + includeDisplay: false, + }, + { + transcriptBytes: 2, + liveAssistantBytes: 2, + liveToolsBytes: 2, + queuedBytes: 2, + finalTextBytes: 1, + promptBytes: 1, + coreBytes: 64, + includeDisplay: false, + }, + ]; + + for (const options of levels) { + const built = buildCandidate(snapshot, options); + const candidate = finishCandidate(built.candidate, built.metadata, cap); + if (candidate) return candidate as unknown as SubagentSnapshot; + } + return undefined; +} + +/** Measure the exact JSON UTF-8 size of one snapshot projection. */ +export function measureSubagentSnapshotBytes(snapshot: SubagentSnapshot) { + return jsonBytes(snapshot); +} + +/** + * Project the complete manager read model under one shared UTF-8 byte cap. + * Equal per-entry budgets make a single large child and many medium children + * obey the same aggregate bound and keep older entries from consuming all + * space. The array wrapper is charged before allocating entry budgets. + */ +export function projectSubagentSnapshots( + snapshots: ReadonlyArray, + maxBytes = DEFAULT_SUBAGENT_SNAPSHOT_MAX_BYTES, +): ReadonlyArray | undefined { + const cap = Math.floor(maxBytes); + if (!Number.isFinite(cap) || cap <= 0) return undefined; + if (snapshots.length === 0) return []; + const wrapperBytes = 2 + Math.max(0, snapshots.length - 1); + const perEntry = Math.floor((cap - wrapperBytes) / snapshots.length); + if (perEntry <= 0) return undefined; + const projected = snapshots.map((snapshot) => + projectSubagentSnapshot(snapshot, perEntry), + ); + if (projected.some((snapshot) => snapshot === undefined)) return undefined; + const result = projected as SubagentSnapshot[]; + return jsonBytes(result) <= cap ? result : undefined; +} + +export function measureSubagentSnapshotsBytes( + snapshots: ReadonlyArray, +) { + return jsonBytes(snapshots); +} diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index 94407209..f047f88c 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -14,15 +14,15 @@ import type { import type { Component, Focusable, TUI } from "@earendil-works/pi-tui"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { AgentSessionPage } from "../../../shared/agent-session-page.ts"; +import { formatContextUtilization } from "../../../shared/context-utilization.ts"; import { hintLine, panelFrame, type ScreenHint, } from "../../../shared/screen-chrome.ts"; +import { SPINNER_INTERVAL_MS, spinnerFrame } from "../../../shared/spinner.ts"; import { sanitizeTerminalText } from "../../../shared/terminal-text.ts"; import { formatElapsed, type SubagentSnapshot } from "../domain.ts"; -import { formatContextUtilization } from "../../../shared/context-utilization.ts"; -import { SPINNER_INTERVAL_MS, spinnerFrame } from "../../../shared/spinner.ts"; import type { SubagentReadModel } from "../manager.ts"; import { subagentTranscriptDocument } from "./transcript.ts"; @@ -76,7 +76,7 @@ export async function openSubagentTakeover( id: string, options?: TakeoverOptions, ) { - if (!view.get(id)) return; + if (!(view.getFull?.(id) ?? view.get(id))) return; const takeoverOptions: TakeoverOptions = { ...options, toolsExpanded: ctx.ui.getToolsExpanded(), @@ -427,7 +427,9 @@ export class TakeoverView implements Component, Focusable { keybindings, { getState: () => { - const snap = view.get(id); + // Takeover is the explicit rehydrate path for the bounded dashboard + // projection. It can inspect the retained event-folding snapshot. + const snap = view.getFull?.(id) ?? view.get(id); if (!snap) return undefined; return { id: snap.id, @@ -452,10 +454,10 @@ export class TakeoverView implements Component, Focusable { { toolsExpanded: options?.toolsExpanded }, ); this.unsubscribe = view.subscribeTo(id, () => { - this.refreshTicker(view.get(id), tui); + this.refreshTicker(view.getFull?.(id) ?? view.get(id), tui); this.scheduleRender(tui); }); - this.refreshTicker(view.get(id), tui); + this.refreshTicker(view.getFull?.(id) ?? view.get(id), tui); } private refreshTicker(snap: SubagentSnapshot | undefined, tui: TUI) { diff --git a/tests/extensions/plan-mode/index.test.ts b/tests/extensions/plan-mode/index.test.ts index e6a5685a..7d76737c 100644 --- a/tests/extensions/plan-mode/index.test.ts +++ b/tests/extensions/plan-mode/index.test.ts @@ -44,6 +44,7 @@ test("plan mode allows only explicit observational tools", () => { "web_search", "bg_status", "subagent_check", + "subagent_result", "workflow_status", "tasks_list", "get_goal", diff --git a/tests/extensions/shared/tool-surface.test.ts b/tests/extensions/shared/tool-surface.test.ts index d7bf53e1..b744453d 100644 --- a/tests/extensions/shared/tool-surface.test.ts +++ b/tests/extensions/shared/tool-surface.test.ts @@ -63,6 +63,7 @@ test("owner patch starts from the latest tool list and preserves foreign tools", "subagent_send", "subagent_check", "subagent_list", + "subagent_result", ]); h.pi.setActiveTools([...h.active(), "late_third_party_tool"]); @@ -78,6 +79,7 @@ test("owner patch starts from the latest tool list and preserves foreign tools", "subagent_send", "subagent_check", "subagent_list", + "subagent_result", "late_third_party_tool", "subagent_wait", ]); @@ -234,6 +236,7 @@ test("catalog defines the compact parent entry surface and every managed name on "subagent_send", "subagent_check", "subagent_list", + "subagent_result", ]); assert.deepEqual(OPENPI_TOOL_SURFACE.subagents.deferred, []); }); diff --git a/tests/extensions/subagents/index.test.ts b/tests/extensions/subagents/index.test.ts index a183ec3a..c203b278 100644 --- a/tests/extensions/subagents/index.test.ts +++ b/tests/extensions/subagents/index.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; @@ -14,12 +15,20 @@ import subagents, { createSubagentResultDispatcher, truncatedOutput, } from "../../../extensions/subagents/index.ts"; +import type { ResultArtifactRef } from "../../../extensions/subagents/src/domain.ts"; import { projectResult } from "../../../extensions/subagents/src/result-artifact.ts"; initTheme("dark", false); const emptySessionManager = { getBranch: () => [] }; +function artifactRef(seed: string): ResultArtifactRef { + return { + version: 1, + digest: createHash("sha256").update(seed, "utf8").digest("hex"), + }; +} + test("subagent results render before the hidden wake-up message", () => { const events: unknown[] = []; const pi = { @@ -92,7 +101,7 @@ test("subagent results render before the hidden wake-up message", () => { test("automatic result projection keeps both ends and persists the exact final answer", () => { const finalText = `BEGIN\n${"evidence\n".repeat(100)}FINAL-VERDICT`; let persisted = ""; - const text = truncatedOutput( + const projected = truncatedOutput( { id: "sa-3", origin: "model", @@ -119,10 +128,129 @@ test("automatic result projection keeps both ends and persists the exact final a }, ); + const text = projected.text; assert.equal(persisted, finalText); + assert.equal(projected.artifactPersisted, true); assert.match(text, /^BEGIN/); assert.match(text, /FINAL-VERDICT/); - assert.match(text, /Full final answer: "\/tmp\/subagent-final\.txt"/); + assert.match( + text, + /Full final answer available via subagent_result\(id="sa-3"/, + ); + assert.doesNotMatch(text, /\/tmp\/subagent-final\.txt/); +}); + +test("a truncated retained result is never presented as exact", () => { + const projected = truncatedOutput( + { + id: "sa-unavailable", + origin: "model", + backend: "pi", + title: "inspect", + prompt: "inspect", + cwd: process.cwd(), + status: "done", + createdAt: 0, + settledAt: 1_000, + meta: { backend: "pi" }, + usage: {}, + transcriptVersion: 0, + transcript: [], + liveTools: [], + queued: [], + finalText: `head-only-prefix${"x".repeat(1 * 1024 * 1024)}TAIL-SENTINEL`, + finalTextTruncated: true, + turns: 1, + }, + 4096, + ); + + assert.match(projected.text, /exact subagent result unavailable/); + assert.doesNotMatch(projected.text, /head-only-prefix/); + assert.doesNotMatch(projected.text, /TAIL-SENTINEL/); +}); + +test("an evicted exact-result artifact falls back to the retained result", () => { + const text = truncatedOutput( + { + id: "sa-evicted", + origin: "model", + backend: "pi", + title: "inspect", + prompt: "inspect", + cwd: process.cwd(), + status: "done", + createdAt: 0, + settledAt: 1_000, + meta: { backend: "pi" }, + usage: {}, + transcriptVersion: 0, + transcript: [], + liveTools: [], + queued: [], + finalText: "retained fallback result", + resultArtifact: artifactRef("missing-result"), + turns: 1, + }, + 4096, + ).text; + + assert.equal(text, "retained fallback result"); +}); + +test("a canonical fallback rehydrates a projected result after artifact eviction", () => { + const finalText = `BEGIN\n${"middle evidence\n".repeat(100)}FINAL-VERDICT`; + let persisted = ""; + const projected = truncatedOutput( + { + id: "sa-canonical", + origin: "model", + backend: "pi", + title: "inspect", + prompt: "inspect", + cwd: process.cwd(), + status: "done", + createdAt: 0, + settledAt: 1_000, + meta: { backend: "pi" }, + usage: {}, + transcriptVersion: 0, + transcript: [], + liveTools: [], + queued: [], + finalText, + resultArtifact: artifactRef("evicted-result"), + snapshot: { + maxBytes: 1024, + bytes: 1024, + truncated: true, + omittedBytes: 1000, + omitted: { + transcriptItems: 0, + liveTools: 0, + queued: 0, + liveAssistantBytes: 0, + finalTextBytes: 1000, + promptBytes: 0, + }, + }, + turns: 1, + }, + 120, + (content) => { + persisted = content; + return "/tmp/recovered-subagent-result.txt"; + }, + { resultIsCanonical: true }, + ); + const text = projected.text; + + assert.equal(persisted, finalText); + assert.equal(projected.artifactPersisted, true); + assert.match(text, /^BEGIN/); + assert.match(text, /FINAL-VERDICT/); + assert.match(text, /subagent_result\(id="sa-canonical"/); + assert.doesNotMatch(text, /recovered-subagent-result/); }); test("automatic projection carries artifact save failures into result details", () => { @@ -180,7 +308,7 @@ test("automatic projection carries canonical outcome and recovery metadata", () const dispatch = createSubagentResultDispatcher(pi, () => ({ text: "projected result", truncated: true, - artifactPath: "/tmp/subagent-final.txt", + artifactPersisted: true, })); dispatch([ @@ -314,7 +442,7 @@ test("automatic result wrappers and projections stay inside the shared batch cap projectResult(snap.finalText, { maxBytes, maxLines: 600, - writeArtifact: () => `/tmp/${snap.id}.txt`, + writeArtifact: () => `/${"x".repeat(10_000)}`, }).text, ); const snapshot = (id: string) => ({ @@ -345,6 +473,7 @@ test("automatic result wrappers and projections stay inside the shared batch cap ]); assert.ok(Buffer.byteLength(delivered, "utf8") <= 48 * 1024); + assert.doesNotMatch(delivered, /x{1000}/); for (const id of ["sa-1", "sa-2", "sa-3", "sa-4"]) { assert.match(delivered, new RegExp(`BEGIN-${id}`)); assert.match(delivered, new RegExp(`END-${id}`)); @@ -598,6 +727,7 @@ test("session start preserves the complete registered subagent family", () => { "subagent_cancel", "subagent_send", "subagent_check", + "subagent_result", "subagent_list", ], ); @@ -669,6 +799,10 @@ test("the complete subagent family fails closed before the first spawn", async ( invoke("subagent_send", { id: "sa-missing", text: "hello" }), /Unknown subagent id "sa-missing"\. Known: none\./, ); + await assert.rejects( + invoke("subagent_result", { id: "sa-missing" }), + /Unknown subagent id "sa-missing"\. Known: none\./, + ); for (const name of ["subagent_wait", "subagent_cancel"]) { await assert.rejects( invoke(name, { ids: ["sa-missing"] }), diff --git a/tests/extensions/subagents/manager.test.ts b/tests/extensions/subagents/manager.test.ts index e0f7498e..0773ee1b 100644 --- a/tests/extensions/subagents/manager.test.ts +++ b/tests/extensions/subagents/manager.test.ts @@ -6,28 +6,30 @@ */ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import test from "node:test"; import { Effect, Layer, ManagedRuntime } from "effect"; import { BackendRegistry, type SubagentBackend, } from "../../../extensions/subagents/src/backend.ts"; -import { makeStubBackend } from "../../support/subagents-stub.ts"; import type { BackendName, ParentContext, + ResultArtifactRef, SpawnTask, SubagentStatus, } from "../../../extensions/subagents/src/domain.ts"; import { - makeSubagentManagerLayer, MAX_RUNNING, MAX_RUNNING_BTW, + makeSubagentManagerLayer, SubagentManager, type SubagentManagerConfig, type SubagentManagerShape, } from "../../../extensions/subagents/src/manager.ts"; import { runTool } from "../../../extensions/subagents/src/runtime.ts"; +import { makeStubBackend } from "../../support/subagents-stub.ts"; const STATUS_WAIT_TIMEOUT_MS = 5_000; @@ -127,6 +129,13 @@ const parent: ParentContext = { projectTrusted: false, }; +function resultArtifactRef(content: string): ResultArtifactRef { + return { + version: 1, + digest: createHash("sha256").update(content, "utf8").digest("hex"), + }; +} + function task(prompt: string): SpawnTask { return { prompt, title: "test", cwd: process.cwd(), parent }; } @@ -197,6 +206,241 @@ test("stub subagent completes and delivers a final result", async () => { }); }); +test("terminal text is persisted before the bounded settlement snapshot", async () => { + let persisted: string | undefined; + let settlementObserved = false; + await withManager( + async (manager, runtime) => { + manager.view.setOnSettled((snap) => { + settlementObserved = true; + assert.deepEqual( + snap.resultArtifact, + resultArtifactRef(persisted ?? ""), + ); + assert.equal(persisted, snap.finalText); + }); + + const snap = await runTool( + runtime, + manager.spawn("pi", task("Persist this exact result")), + ); + await runTool(runtime, manager.waitFor([snap.id])); + assert.equal(settlementObserved, true); + assert.equal(manager.view.get(snap.id)?.status, "done"); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.match(persisted ?? "", /Persist this exact result/); + }, + { + persistResultArtifact: (content) => { + persisted = content; + return resultArtifactRef(content); + }, + }, + ); +}); + +test("artifact cache failures cannot block settlement, waiters, or result delivery", async () => { + let writerCalls = 0; + await withManager( + async (manager, runtime) => { + const settled: Array<{ + status: SubagentStatus; + resultArtifact?: ResultArtifactRef; + finalText: string; + }> = []; + manager.view.setOnSettled((snap) => { + settled.push({ + status: snap.status, + resultArtifact: snap.resultArtifact, + finalText: snap.finalText, + }); + }); + + const snap = await runTool( + runtime, + manager.spawn("pi", task("deliver despite cache failure")), + ); + const notifications: SubagentStatus[] = []; + const unsubscribe = manager.view.subscribeTo(snap.id, () => { + const status = manager.view.get(snap.id)?.status; + if (status) notifications.push(status); + }); + await runTool(runtime, manager.waitFor([snap.id])); + unsubscribe(); + + const done = manager.view.get(snap.id); + assert.equal(done?.status, "done"); + assert.equal(done?.resultArtifact, undefined); + assert.ok(notifications.includes("done")); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(writerCalls, 1); + assert.deepEqual( + settled.map(({ status, resultArtifact }) => ({ + status, + resultArtifact, + })), + [{ status: "done", resultArtifact: undefined }], + ); + assert.match(settled[0]?.finalText ?? "", /cache failure/); + + // The terminal event still releases capacity for a new run. + const fresh = await runTool( + runtime, + manager.spawn("pi", task("next run")), + ); + assert.equal(fresh.status, "running"); + }, + { + persistResultArtifact: () => { + writerCalls++; + throw new Error("result cache cleanup failed"); + }, + }, + ); +}); + +test("an unrepresentable artifact reference cannot block settlement", async () => { + await withManager( + async (manager, runtime) => { + const settled: SubagentStatus[] = []; + manager.view.setOnSettled((snap) => settled.push(snap.status)); + const snap = await runTool( + runtime, + manager.spawn("pi", task("deliver despite an oversized artifact path")), + ); + + await runTool(runtime, manager.waitFor([snap.id])); + const done = manager.view.get(snap.id); + assert.equal(done?.status, "done"); + assert.equal(done?.resultArtifact, undefined); + assert.deepEqual(settled, ["done"]); + }, + { + maxSnapshotBytes: 4_096, + persistResultArtifact: () => + ({ + version: 1, + digest: "x".repeat(16 * 1024), + }) as unknown as ResultArtifactRef, + }, + ); +}); + +test("a valid-looking artifact reference must match the settled text", async () => { + await withManager( + async (manager, runtime) => { + const settled: Array<{ status: SubagentStatus; finalText: string }> = []; + manager.view.setOnSettled((snap) => + settled.push({ status: snap.status, finalText: snap.finalText }), + ); + const snap = await runTool( + runtime, + manager.spawn("pi", task("reject a mismatched artifact reference")), + ); + const notifications: SubagentStatus[] = []; + const unsubscribe = manager.view.subscribeTo(snap.id, () => { + const status = manager.view.get(snap.id)?.status; + if (status) notifications.push(status); + }); + await runTool(runtime, manager.waitFor([snap.id])); + unsubscribe(); + const done = manager.view.get(snap.id); + assert.equal(done?.status, "done"); + assert.equal(done?.resultArtifact, undefined); + assert.match(done?.finalText ?? "", /mismatched artifact reference/); + assert.ok(notifications.includes("done")); + assert.equal(settled[0]?.status, "done"); + assert.match( + settled[0]?.finalText ?? "", + /mismatched artifact reference/, + ); + const fresh = await runTool( + runtime, + manager.spawn("pi", task("slot released after wrong digest")), + ); + assert.equal(fresh.status, "running"); + }, + { + persistResultArtifact: () => resultArtifactRef("different content"), + }, + ); +}); + +test("bounded projections leave the takeover snapshot intact", async () => { + await withManager( + async (manager, runtime) => { + const snap = await runTool( + runtime, + manager.spawn("pi", task("MANYTOOLS: keep all takeover activity")), + ); + await new Promise((resolve, reject) => { + let unsubscribe = () => {}; + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out waiting for takeover activity")); + }, STATUS_WAIT_TIMEOUT_MS); + const observe = () => { + if ((manager.view.getFull?.(snap.id)?.liveTools.length ?? 0) < 130) + return; + clearTimeout(timer); + unsubscribe(); + resolve(); + }; + unsubscribe = manager.view.subscribeTo(snap.id, observe); + observe(); + }); + + const projected = manager.view.get(snap.id); + const full = manager.view.getFull?.(snap.id); + assert.ok(projected?.snapshot?.truncated); + assert.ok(full); + assert.equal(full.liveTools.length, 130); + assert.ok(projected.liveTools.length < full.liveTools.length); + + await runTool(runtime, manager.waitFor([snap.id])); + }, + { maxSnapshotBytes: 4_096 }, + ); +}); + +test("activity projection never drops raw active tool ids", async () => { + await withManager(async (manager, runtime) => { + const snap = await runTool( + runtime, + manager.spawn("pi", task("MANYTOOLS: preserve active tool ids")), + ); + await new Promise((resolve, reject) => { + let unsubscribe = () => {}; + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out waiting for all active tools")); + }, STATUS_WAIT_TIMEOUT_MS); + const observe = () => { + if ((manager.view.get(snap.id)?.liveTools.length ?? 0) < 130) return; + clearTimeout(timer); + unsubscribe(); + resolve(); + }; + unsubscribe = manager.view.subscribeTo(snap.id, observe); + observe(); + }); + await runTool(runtime, manager.waitFor([snap.id])); + }); +}); + +test("an impossible snapshot cap rejects spawn without retaining the entry", async () => { + await withManager( + async (manager, runtime) => { + await assert.rejects( + runTool(runtime, manager.spawn("pi", task("Too little room"))), + /minimum identity budget/, + ); + assert.equal(manager.view.size(), 0); + }, + { maxSnapshotBytes: 32 }, + ); +}); + test("FAIL: prompts settle as errors; unconsumed settles are delivered", async () => { await withManager(async (manager, runtime) => { const settled: Array<{ id: string; consumed: boolean }> = []; diff --git a/tests/extensions/subagents/result-artifact.test.ts b/tests/extensions/subagents/result-artifact.test.ts index 2749289c..a11e8fa0 100644 --- a/tests/extensions/subagents/result-artifact.test.ts +++ b/tests/extensions/subagents/result-artifact.test.ts @@ -1,20 +1,38 @@ import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; import { + access, + link, lstat, mkdtemp, + readdir, readFile, rm, symlink, + utimes, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { + MAX_RESULT_ARTIFACT_BYTES, + MAX_RESULT_ARTIFACT_FILES, + pageResultText, persistResultArtifact, projectResult, + readResultArtifact, + resolveExactResultText, + resultArtifactPath, + resultArtifactRefMatchesContent, } from "../../../extensions/subagents/src/result-artifact.ts"; +const skipUnsupportedArtifactCache = + process.platform !== "linux" + ? "artifact cache requires Linux descriptor-relative no-follow filesystem APIs" + : false; + test("short results pass through without creating an artifact", () => { let writes = 0; const result = projectResult("short report", { @@ -36,6 +54,7 @@ test("byte truncation keeps head and tail and points to the exact artifact", () const result = projectResult(content, { maxBytes: 120, maxLines: 100, + recoveryId: "sa-test", writeArtifact: (value) => { persisted = value; return "/tmp/final.txt"; @@ -43,14 +62,27 @@ test("byte truncation keeps head and tail and points to the exact artifact", () }); assert.equal(result.truncated, true); - assert.equal(result.artifactPath, "/tmp/final.txt"); assert.equal(persisted, content); + assert.ok(Buffer.byteLength(result.text, "utf8") <= 120); assert.match(result.text, /^BEGIN/); assert.match(result.text, /FINAL-VERDICT/); - assert.match(result.text, /\[\.\.\. middle omitted \.\.\.\]/); - assert.match(result.text, /Full final answer: "\/tmp\/final\.txt"/); - assert.match(result.text, /offset=\d+, limit=200/); - assert.match(result.text, /\d+ total lines/); + assert.match(result.text, /middle omitted|\.\.\./); + assert.match( + result.text, + /Full final answer available via subagent_result\(id="sa-test", offset=1, limit=200\)/, + ); + assert.doesNotMatch(result.text, /\/tmp\/final\.txt/); +}); + +test("a long artifact path is omitted without exceeding the output budget", () => { + const result = projectResult("BEGIN\n" + "x\n".repeat(100) + "END", { + maxBytes: 120, + maxLines: 20, + writeArtifact: () => `/${"x".repeat(10_000)}`, + }); + + assert.ok(Buffer.byteLength(result.text, "utf8") <= 120); + assert.doesNotMatch(result.text, /x{1000}/); }); test("the complete projection stays within its byte budget", () => { @@ -58,6 +90,7 @@ test("the complete projection stays within its byte budget", () => { const result = projectResult(content, { maxBytes: 512, maxLines: 100, + recoveryId: "sa-budget", writeArtifact: () => "/tmp/final.txt", }); @@ -65,7 +98,12 @@ test("the complete projection stays within its byte budget", () => { assert.ok(Buffer.byteLength(result.text, "utf8") <= 512); assert.match(result.text, /^BEGIN/); assert.match(result.text, /FINAL-VERDICT/); - assert.match(result.text, /Full final answer:/); + assert.match(result.text, /\[\.\.\. middle omitted \.\.\.\]/); + assert.match( + result.text, + /Full final answer available via subagent_result\(id="sa-budget"/, + ); + assert.doesNotMatch(result.text, /\/tmp\/final\.txt/); }); test("projection budgets remain hard caps across UTF-8 sizes", () => { @@ -112,6 +150,64 @@ test("a long UTF-8 line keeps valid characters at both ends", () => { assert.doesNotMatch(result.text, /�/); }); +test("pathological byte budgets stay within the hard cap", () => { + for (const maxBytes of [0, 1, 5, 17]) { + const result = projectResult("BEGIN\n" + "x".repeat(200) + "\nEND", { + maxBytes, + maxLines: 10, + writeArtifact: () => "/tmp/tiny.txt", + }); + assert.equal(result.truncated, true); + assert.ok(Buffer.byteLength(result.text, "utf8") <= maxBytes); + assert.doesNotMatch(result.text, /tmp[/\\]tiny\.txt/); + } +}); + +test("maxLines: 1, empty text, a single long line, and a trailing newline stay bounded", () => { + const cases = [ + { content: "", maxBytes: 10, maxLines: 1 }, + { + content: "only-one-very-long-line-" + "x".repeat(200), + maxBytes: 40, + maxLines: 1, + }, + { + content: "head\n" + "mid\n".repeat(40) + "tail\n", + maxBytes: 80, + maxLines: 1, + }, + { content: "line-0\nline-1\n", maxBytes: 10_000, maxLines: 1 }, + ]; + for (const sample of cases) { + const result = projectResult(sample.content, { + ...sample, + writeArtifact: () => "/tmp/lines.txt", + }); + assert.ok(Buffer.byteLength(result.text, "utf8") <= sample.maxBytes); + assert.doesNotMatch(result.text, /tmp[/\\]lines\.txt/); + if ( + sample.content.length > 0 && + Buffer.byteLength(sample.content, "utf8") > sample.maxBytes + ) { + assert.equal(result.truncated, true); + } + } +}); + +test("a multibyte character at the budget boundary is never split", () => { + const content = `${"中".repeat(40)}\n${"证".repeat(40)}\n${"尾".repeat(10)}`; + for (const maxBytes of [7, 8, 9, 16, 17]) { + const result = projectResult(content, { + maxBytes, + maxLines: 10, + writeArtifact: () => "/tmp/utf8.txt", + }); + assert.ok(Buffer.byteLength(result.text, "utf8") <= maxBytes); + assert.doesNotMatch(result.text, /�/); + assert.doesNotMatch(result.text, /tmp[/\\]utf8\.txt/); + } +}); + test("artifact failure is explicit and never advertises a false path", () => { const result = projectResult("start\n" + "x\n".repeat(100) + "end", { maxBytes: 80, @@ -122,35 +218,225 @@ test("artifact failure is explicit and never advertises a false path", () => { }); assert.equal(result.truncated, true); - assert.equal(result.artifactPath, undefined); + assert.equal(result.artifactPersisted, undefined); assert.equal(result.artifactSaveFailed, true); assert.match(result.text, /could not be saved/); + assert.ok(Buffer.byteLength(result.text, "utf8") <= 80); assert.doesNotMatch(result.text, /Full final answer:/); }); -test("content-addressed artifacts are exact, private, and reusable", async () => { +test("content-addressed artifacts are exact, private, and reusable", { + skip: skipUnsupportedArtifactCache, +}, async () => { const agentDir = await mkdtemp( path.join(tmpdir(), "openpi-result-artifact-"), ); try { const content = "complete final answer\nwith verdict"; - const first = persistResultArtifact(agentDir, content); - const second = persistResultArtifact(agentDir, content); + const firstRef = persistResultArtifact(agentDir, content); + const secondRef = persistResultArtifact(agentDir, content); + const first = resultArtifactPath(agentDir, firstRef); + const second = resultArtifactPath(agentDir, secondRef); assert.equal(first, second); assert.equal(await readFile(first, "utf8"), content); - assert.equal((await lstat(first)).mode & 0o777, 0o600); + assert.equal(readResultArtifact(agentDir, firstRef), content); + assert.deepEqual( + (await readdir(path.dirname(first))).filter((name) => + name.endsWith(".tmp"), + ), + [], + ); + if (process.platform !== "win32") { + assert.equal((await lstat(first)).mode & 0o777, 0o600); + } assert.equal(path.basename(first).length, 68); } finally { await rm(agentDir, { recursive: true, force: true }); } }); -test("artifact persistence refuses a symlinked cache component", async () => { +test("artifact retention evicts the oldest owned files under count and byte caps", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-retention-"), + ); + const limits = { maxFiles: 2, maxBytes: 11 }; + try { + const firstRef = persistResultArtifact(agentDir, "first", limits); + const first = resultArtifactPath(agentDir, firstRef); + await utimes(first, new Date(1_000), new Date(1_000)); + const secondRef = persistResultArtifact(agentDir, "second", limits); + const second = resultArtifactPath(agentDir, secondRef); + await utimes(second, new Date(2_000), new Date(2_000)); + const thirdRef = persistResultArtifact(agentDir, "third", limits); + const third = resultArtifactPath(agentDir, thirdRef); + + await assert.rejects(access(first)); + assert.equal(await readFile(second, "utf8"), "second"); + assert.equal(await readFile(third, "utf8"), "third"); + assert.deepEqual( + persistResultArtifact(agentDir, "second", limits), + secondRef, + ); + + const directory = path.dirname(third); + const retained = (await readdir(directory)).filter((name) => + /^[a-f0-9]{64}\.txt$/.test(name), + ); + assert.equal(retained.length, 2); + const totalBytes = ( + await Promise.all( + retained.map( + async (name) => (await lstat(path.join(directory, name))).size, + ), + ) + ).reduce((total, size) => total + size, 0); + assert.ok(totalBytes <= limits.maxBytes); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("an artifact too large for the cache leaves no partial file and later writes recover", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-oversized-"), + ); + const limits = { maxFiles: 2, maxBytes: 5 }; + try { + assert.throws( + () => persistResultArtifact(agentDir, "too large", limits), + /exceeds the 5-byte cache capacity/, + ); + const recoveredRef = persistResultArtifact(agentDir, "small", limits); + const recovered = resultArtifactPath(agentDir, recoveredRef); + assert.equal(await readFile(recovered, "utf8"), "small"); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("custom cache limits reject zero, negative, and over-cap maxBytes", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-tiny-limit-"), + ); + try { + for (const maxBytes of [0, -1, MAX_RESULT_ARTIFACT_BYTES + 1]) { + assert.throws( + () => + persistResultArtifact(agentDir, "small", { maxFiles: 1, maxBytes }), + /maxBytes must be a positive safe integer|maxBytes must not exceed/, + ); + } + const recovered = persistResultArtifact(agentDir, "ok", { + maxFiles: 1, + maxBytes: 2, + }); + assert.equal(readResultArtifact(agentDir, recovered), "ok"); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("artifact references reject path-bearing extra fields", () => { + const digest = createHash("sha256").update("content", "utf8").digest("hex"); + assert.equal( + resultArtifactRefMatchesContent( + { version: 1, digest, path: "C:/secret/cache/result.txt" }, + "content", + ), + false, + ); +}); + +test("custom cache limits cannot exceed the reader limit", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-limit-validation-"), + ); + try { + assert.throws( + () => + persistResultArtifact(agentDir, "small", { + maxFiles: MAX_RESULT_ARTIFACT_FILES + 1, + maxBytes: 32, + }), + /maxFiles must not exceed 64 files/, + ); + assert.throws( + () => + persistResultArtifact(agentDir, "small", { + maxFiles: 1, + maxBytes: 64 * 1024 * 1024 + 1, + }), + /maxBytes must not exceed 67108864 bytes/, + ); + assert.equal( + await access(path.join(agentDir, "cache")) + .then(() => true) + .catch(() => false), + false, + ); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("stale crash metadata is reclaimed under the cache lock", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-stale-metadata-"), + ); + try { + const seed = persistResultArtifact(agentDir, "seed metadata cleanup"); + const directory = path.dirname(resultArtifactPath(agentDir, seed)); + const old = new Date(1_000); + const tempName = `.${"a".repeat(64)}.${randomUUID()}.tmp`; + const ownerName = `.retention-lock.owner.999999.${randomUUID()}`; + const recoveryName = `.retention-lock.recovery.${randomUUID()}`; + for (const [name, content] of [ + [tempName, "abandoned payload"], + [ownerName, "abandoned owner"], + [recoveryName, "abandoned recovery"], + ] as const) { + const file = path.join(directory, name); + await writeFile(file, content, "utf8"); + await utimes(file, old, old); + } + + persistResultArtifact(agentDir, "run metadata cleanup"); + const remaining = await readdir(directory); + assert.doesNotMatch( + remaining.join("\n"), + /retention-lock\.(owner|recovery)|\.tmp/, + ); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("artifact persistence refuses a symlinked cache component", { + skip: skipUnsupportedArtifactCache, +}, async (t) => { const agentDir = await mkdtemp(path.join(tmpdir(), "openpi-result-symlink-")); const outside = await mkdtemp(path.join(tmpdir(), "openpi-result-outside-")); try { - await symlink(outside, path.join(agentDir, "cache")); + try { + await symlink(outside, path.join(agentDir, "cache")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") { + t.skip("symlink creation requires an enabled Windows developer mode"); + return; + } + throw error; + } assert.throws( () => persistResultArtifact(agentDir, "do not write outside"), /Unsafe result artifact directory/, @@ -167,14 +453,221 @@ test("artifact persistence refuses a symlinked cache component", async () => { } }); -test("artifact persistence refuses an existing file with the wrong content", async () => { +test("artifact references cannot read an external digest-named file", async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-external-ref-"), + ); + const outside = await mkdtemp(path.join(tmpdir(), "openpi-result-external-")); + try { + const content = "external content must not be accepted"; + const digest = createHash("sha256").update(content, "utf8").digest("hex"); + await writeFile(path.join(outside, `${digest}.txt`), content, "utf8"); + assert.equal( + readResultArtifact(agentDir, { version: 1, digest }), + undefined, + ); + } finally { + await rm(agentDir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}); + +test("cache lock contention fails closed without bypassing retention", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-lock-contention-"), + ); + try { + const limits = { maxFiles: 1, maxBytes: 32 }; + const existing = persistResultArtifact(agentDir, "existing", limits); + const directory = path.dirname(resultArtifactPath(agentDir, existing)); + const lockPath = path.join(directory, ".retention-lock"); + await writeFile(lockPath, "uncertain lock\n", "utf8"); + + assert.throws( + () => persistResultArtifact(agentDir, "new result", limits), + /busy or has uncertain ownership/, + ); + assert.equal(await readFile(lockPath, "utf8"), "uncertain lock\n"); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("a live lock owner is never stolen by age and stays fail-closed", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-live-lock-"), + ); + try { + const limits = { maxFiles: 1, maxBytes: 32 }; + persistResultArtifact(agentDir, "seed live lock", limits); + const directory = path.join( + agentDir, + "cache", + "openpi", + "subagent-results", + ); + const owner = { + version: 1, + pid: process.pid, + token: randomUUID(), + createdAt: 1, + }; + const claim = path.join( + directory, + `.retention-lock.owner.${owner.pid}.${owner.token}`, + ); + const lock = path.join(directory, ".retention-lock"); + await writeFile(claim, `${JSON.stringify(owner)}\n`, "utf8"); + await utimes(claim, new Date(1_000), new Date(1_000)); + try { + await rm(lock, { force: true }); + } catch { + // The previous publication already released the lock. + } + await link(claim, lock); + await utimes(lock, new Date(1_000), new Date(1_000)); + assert.throws( + () => persistResultArtifact(agentDir, "must not steal live lock", limits), + /busy or has uncertain ownership/, + ); + assert.equal(await readFile(lock, "utf8"), `${JSON.stringify(owner)}\n`); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("a lock left by a dead owner is reclaimed only after ownership checks", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-stale-lock-"), + ); + try { + const limits = { maxFiles: 1, maxBytes: 32 }; + persistResultArtifact(agentDir, "seed", limits); + const directory = path.join( + agentDir, + "cache", + "openpi", + "subagent-results", + ); + const dead = spawn(process.execPath, ["-e", ""], { + stdio: "ignore", + }); + const deadPid = dead.pid; + assert.ok(deadPid); + await new Promise((resolve, reject) => { + dead.once("error", reject); + dead.once("close", () => resolve()); + }); + const owner = { + version: 1, + pid: deadPid, + token: randomUUID(), + createdAt: Date.now(), + }; + const claim = path.join( + directory, + `.retention-lock.owner.${owner.pid}.${owner.token}`, + ); + const lock = path.join(directory, ".retention-lock"); + await writeFile(claim, `${JSON.stringify(owner)}\n`, "utf8"); + await link(claim, lock); + + const recovered = persistResultArtifact(agentDir, "recovered", limits); + assert.equal(readResultArtifact(agentDir, recovered), "recovered"); + assert.equal( + await access(lock) + .then(() => true) + .catch(() => false), + false, + ); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("cooperating processes keep retention caps under concurrent writes", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-concurrent-retention-"), + ); + try { + const moduleUrl = new URL( + "../../../extensions/subagents/src/result-artifact.ts", + import.meta.url, + ).href; + const source = + `import { persistResultArtifact } from ${JSON.stringify(moduleUrl)};\n` + + `try { persistResultArtifact(process.env.OPENPI_RESULT_DIR, process.env.OPENPI_RESULT_CONTENT, { maxFiles: 2, maxBytes: 32 }); process.exit(0); } catch { process.exit(2); }`; + const children = Array.from( + { length: 8 }, + (_, index) => + new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + "--experimental-strip-types", + "--input-type=module", + "--eval", + source, + ], + { + env: { + ...process.env, + OPENPI_RESULT_DIR: agentDir, + OPENPI_RESULT_CONTENT: `concurrent-${index}`, + }, + stdio: ["ignore", "ignore", "ignore"], + }, + ); + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); + }), + ); + const statuses = await Promise.all(children); + assert.ok(statuses.includes(0)); + + const directory = path.join( + agentDir, + "cache", + "openpi", + "subagent-results", + ); + const retained = (await readdir(directory)).filter((name) => + /^[a-f0-9]{64}\.txt$/u.test(name), + ); + assert.ok(retained.length <= 2); + const totalBytes = ( + await Promise.all( + retained.map( + async (name) => (await lstat(path.join(directory, name))).size, + ), + ) + ).reduce((total, size) => total + size, 0); + assert.ok(totalBytes <= 32); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("artifact persistence refuses an existing file with the wrong content", { + skip: skipUnsupportedArtifactCache, +}, async () => { const agentDir = await mkdtemp( path.join(tmpdir(), "openpi-result-collision-"), ); try { const content = "original final answer"; - const artifactPath = persistResultArtifact(agentDir, content); + const artifactRef = persistResultArtifact(agentDir, content); + const artifactPath = resultArtifactPath(agentDir, artifactRef); await writeFile(artifactPath, "tampered", "utf8"); + assert.equal(readResultArtifact(agentDir, artifactRef), undefined); assert.throws( () => persistResultArtifact(agentDir, content), /Result artifact collision/, @@ -183,3 +676,200 @@ test("artifact persistence refuses an existing file with the wrong content", asy await rm(agentDir, { recursive: true, force: true }); } }); + +test("a syntactically valid ResultArtifactRef is rejected when the digest is wrong", () => { + const content = "settled exact result"; + const wrong = { + version: 1 as const, + digest: createHash("sha256").update("other", "utf8").digest("hex"), + }; + assert.equal(resultArtifactRefMatchesContent(wrong, content), false); + assert.equal( + resultArtifactRefMatchesContent( + { + version: 1, + digest: createHash("sha256").update(content, "utf8").digest("hex"), + }, + content, + ), + true, + ); +}); + +test("exact result paging is 0-based and never treats a projection as canonical", () => { + const lines = Array.from({ length: 5 }, (_, i) => `line-${i}`).join("\n"); + const first = pageResultText(lines, { offset: 0, limit: 2 }); + assert.equal(first.text, "line-0\nline-1"); + assert.equal(first.hasMore, true); + const middle = pageResultText(lines, { offset: 3, limit: 2 }); + assert.equal(middle.text, "line-3\nline-4"); + assert.equal(middle.hasMore, false); + const empty = pageResultText(lines, { offset: 8, limit: 2 }); + assert.match(empty.text, /No result lines at offset 8/); + const truncated = pageResultText("x".repeat(200), { maxBytes: 40, limit: 1 }); + assert.equal(truncated.truncated, true); + assert.match(truncated.text, /page truncated/); + assert.ok((truncated.nextByteOffset ?? 0) > 0); + assert.ok(Buffer.byteLength(truncated.text, "utf8") <= 40); + + const longLine = `${"a".repeat(20 * 1024)}SENTINEL`; + let cursor = 0; + let recovered = ""; + for (let i = 0; i < 30; i++) { + const page = pageResultText(longLine, { + byteOffset: cursor, + maxBytes: 1024, + }); + assert.ok(Buffer.byteLength(page.text, "utf8") <= 1024); + assert.equal(page.text.includes("�"), false); + recovered += page.text.replace(/\n\[page truncated; next \d+\]$/u, ""); + if (!page.hasMore) break; + assert.ok( + page.nextByteOffset !== undefined && page.nextByteOffset > cursor, + ); + cursor = page.nextByteOffset; + } + assert.match(recovered, /SENTINEL$/); + assert.equal(pageResultText(longLine, { byteOffset: cursor }).hasMore, false); + assert.throws( + () => pageResultText("a😀b", { byteOffset: 2 }), + /code-point boundary/, + ); + assert.throws( + () => pageResultText("abc", { offset: 0, byteOffset: 0 }), + /either offset or byteOffset/, + ); + + assert.equal( + resolveExactResultText({ + artifactText: undefined, + retainedFinalText: "projected only", + resultIsCanonical: false, + omittedFinalTextBytes: 100, + }), + undefined, + ); + assert.equal( + resolveExactResultText({ + artifactText: undefined, + retainedFinalText: "canonical retained", + resultIsCanonical: true, + omittedFinalTextBytes: 100, + }), + "canonical retained", + ); + assert.equal( + resolveExactResultText({ + artifactText: undefined, + retainedFinalText: "truncated prefix", + resultIsCanonical: true, + finalTextTruncated: true, + omittedFinalTextBytes: 100, + }), + undefined, + ); + assert.equal( + resolveExactResultText({ + artifactText: "from artifact", + retainedFinalText: "canonical retained", + resultIsCanonical: false, + omittedFinalTextBytes: 100, + }), + "from artifact", + ); +}); + +test("uncertain recovery entries are never relinked as the published lock", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-poisoned-lock-"), + ); + try { + const seed = persistResultArtifact(agentDir, "seed lock recovery"); + const directory = path.dirname(resultArtifactPath(agentDir, seed)); + const lock = path.join(directory, ".retention-lock"); + const outside = path.join(agentDir, "outside-target"); + await writeFile(outside, "not a lock\n", "utf8"); + await rm(lock, { force: true }); + try { + await symlink(outside, lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") { + return; + } + throw error; + } + assert.throws( + () => persistResultArtifact(agentDir, "must not poison lock"), + /busy or has uncertain ownership/, + ); + assert.equal((await lstat(lock)).isSymbolicLink(), true); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("stale metadata cleanup skips symlink and unknown entries", { + skip: skipUnsupportedArtifactCache, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-skip-unknown-"), + ); + try { + const seed = persistResultArtifact(agentDir, "seed skip unknown"); + const directory = path.dirname(resultArtifactPath(agentDir, seed)); + const old = new Date(1_000); + const unknown = path.join(directory, "not-an-owned-name.bin"); + await writeFile(unknown, "keep me", "utf8"); + await utimes(unknown, old, old); + const linkName = path.join( + directory, + `.retention-lock.recovery.${randomUUID()}`, + ); + try { + await symlink(unknown, linkName); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EPERM") throw error; + } + persistResultArtifact(agentDir, "cleanup after unknown"); + assert.equal(await readFile(unknown, "utf8"), "keep me"); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); + +test("non-Linux artifact cache fails closed before touching the filesystem", { + skip: process.platform === "linux" ? "non-Linux-only" : false, +}, async () => { + const agentDir = await mkdtemp( + path.join(tmpdir(), "openpi-result-windows-disabled-"), + ); + try { + assert.throws( + () => + persistResultArtifact(agentDir, "must not write", { + maxFiles: 1, + maxBytes: 32, + }), + /cache is unavailable/, + ); + assert.equal( + await access(path.join(agentDir, "cache")) + .then(() => true) + .catch(() => false), + false, + ); + assert.equal( + readResultArtifact(agentDir, { + version: 1, + digest: createHash("sha256") + .update("must not write", "utf8") + .digest("hex"), + }), + undefined, + ); + } finally { + await rm(agentDir, { recursive: true, force: true }); + } +}); diff --git a/tests/extensions/subagents/snapshot.test.ts b/tests/extensions/subagents/snapshot.test.ts new file mode 100644 index 00000000..20798e2b --- /dev/null +++ b/tests/extensions/subagents/snapshot.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { + ResultArtifactRef, + SubagentSnapshot, +} from "../../../extensions/subagents/src/domain.ts"; +import { + measureSubagentSnapshotBytes, + measureSubagentSnapshotsBytes, + projectSubagentSnapshot, + projectSubagentSnapshots, + truncateUtf8Head, + truncateUtf8Tail, +} from "../../../extensions/subagents/src/snapshot.ts"; + +function snapshot(id: string, overrides: Partial = {}) { + return { + id, + origin: "model" as const, + backend: "pi" as const, + title: `Agent ${id}`, + prompt: "Inspect the repository and report the result.", + cwd: "C:/work/openpi", + status: "done" as const, + createdAt: 1, + settledAt: 2, + meta: { backend: "pi" as const, modelLabel: "test/model" }, + usage: { tokens: 10, contextWindow: 1000 }, + transcriptVersion: 0, + transcript: [ + { kind: "user" as const, text: "Inspect this" }, + { + kind: "assistant" as const, + parts: [{ type: "text" as const, text: "The result is ready." }], + }, + ], + liveTools: [], + queued: [], + finalText: "BEGIN\nThe exact final result is here.\nEND", + turns: 1, + ...overrides, + } satisfies SubagentSnapshot; +} + +test("UTF-8 head and tail truncation never split a multibyte character", () => { + const emoji = "😀"; + assert.deepEqual( + [1, 2, 3, 4].map((n) => truncateUtf8Head(emoji, n)), + ["", "", "", emoji], + ); + for (let n = 0; n <= 6; n++) { + assert.equal(truncateUtf8Head(`a${emoji}b`, n).includes("�"), false); + assert.equal(truncateUtf8Tail(`a${emoji}b`, n).includes("�"), false); + } + assert.equal( + truncateUtf8Head(`${"a".repeat(4093)}${emoji}`, 4096), + "a".repeat(4093), + ); + const value = "开头" + "中".repeat(20) + "结尾"; + for (const limit of [1, 2, 3, 4, 7, 11, 23]) { + const head = truncateUtf8Head(value, limit); + const tail = truncateUtf8Tail(value, limit); + assert.ok(Buffer.byteLength(head, "utf8") <= limit); + assert.ok(Buffer.byteLength(tail, "utf8") <= limit); + assert.doesNotMatch(`${head}${tail}`, /�/); + } + assert.equal(truncateUtf8Tail(value, 6), "结尾"); +}); + +test("a giant agent is bounded in serialized UTF-8 bytes and marked", () => { + const source = snapshot("giant", { + finalText: `开头\n${"中间证据\n".repeat(20_000)}最终结论`, + transcript: Array.from({ length: 100 }, (_, index) => ({ + kind: "user" as const, + text: `消息 ${index}`, + })), + }); + const projected = projectSubagentSnapshot(source, 4096); + assert.ok(projected); + assert.ok(measureSubagentSnapshotBytes(projected) <= 4096); + assert.equal(projected.id, "giant"); + assert.equal(projected.status, "done"); + assert.equal(projected.snapshot?.truncated, true); + assert.ok((projected.snapshot?.omittedBytes ?? 0) > 0); + assert.match(projected.finalText, /^开头/); + assert.match(projected.finalText, /最终结论$/); +}); + +test("aggregate projection gives every agent identity under one UTF-8 cap", () => { + const source = [ + snapshot("giant", { finalText: "中".repeat(100_000) }), + ...["medium-1", "medium-2", "medium-3"].map((id) => + snapshot(id, { finalText: "证据".repeat(10_000) }), + ), + ]; + const projected = projectSubagentSnapshots(source, 12_000); + assert.ok(projected); + assert.ok(measureSubagentSnapshotsBytes(projected) <= 12_000); + assert.deepEqual( + projected.map((entry) => [entry.id, entry.status]), + source.map((entry) => [entry.id, entry.status]), + ); + assert.ok(projected.every((entry) => entry.snapshot?.truncated)); +}); + +test("projection is detached and leaves its source transcript intact", () => { + const source = snapshot("source", { + transcript: Array.from({ length: 80 }, (_, index) => ({ + kind: "user" as const, + text: `message ${index}`, + })), + finalText: `BEGIN\n${"evidence\n".repeat(10_000)}END`, + }); + const originalTranscript = source.transcript; + const projected = projectSubagentSnapshot(source, 4096); + + assert.ok(projected); + assert.equal(source.transcript, originalTranscript); + assert.equal(source.transcript.length, 80); + assert.notEqual(projected.transcript, source.transcript); + assert.notEqual(projected.finalText, source.finalText); +}); + +test("reprojecting does not inflate omission statistics", () => { + const source = snapshot("repeat", { + finalText: "首" + "中".repeat(20_000) + "尾", + }); + const first = projectSubagentSnapshot(source, 4096); + assert.ok(first); + const second = projectSubagentSnapshot(first, 4096); + assert.ok(second); + assert.deepEqual(second.snapshot?.omitted, first.snapshot?.omitted); + assert.equal(second.snapshot?.omittedBytes, first.snapshot?.omittedBytes); + assert.equal( + measureSubagentSnapshotBytes(second), + measureSubagentSnapshotBytes(first), + ); +}); + +test("artifact references remain exact while display text is projected", () => { + const artifactRef: ResultArtifactRef = { + version: 1, + digest: "a".repeat(64), + }; + const projected = projectSubagentSnapshot( + snapshot("artifact", { + resultArtifact: artifactRef, + finalText: "中".repeat(20_000), + }), + 4096, + ); + assert.ok(projected); + assert.deepEqual(projected.resultArtifact, artifactRef); + assert.equal(projected.snapshot?.truncated, true); +}); + +test("projection fails when the minimum identity cannot fit", () => { + const source = snapshot("agent-with-an-identity-that-is-too-large", { + title: "标题".repeat(100), + }); + assert.equal(projectSubagentSnapshot(source, 32), undefined); + assert.equal(projectSubagentSnapshots([source], 32), undefined); +}); diff --git a/tests/extensions/subagents/takeover.test.ts b/tests/extensions/subagents/takeover.test.ts index cda6ac6c..9cf7e955 100644 --- a/tests/extensions/subagents/takeover.test.ts +++ b/tests/extensions/subagents/takeover.test.ts @@ -4,15 +4,15 @@ import type { KeybindingsManager, Theme, } from "@earendil-works/pi-coding-agent"; -import { visibleWidth, type TUI } from "@earendil-works/pi-tui"; +import { type TUI, visibleWidth } from "@earendil-works/pi-tui"; import type { SubagentSnapshot } from "../../../extensions/subagents/src/domain.ts"; import type { SubagentReadModel } from "../../../extensions/subagents/src/manager.ts"; import { + type DashboardSelection, reconcileDashboardSelection, - sanitizeSubagentDisplayLine, SubagentDashboard, + sanitizeSubagentDisplayLine, TakeoverView, - type DashboardSelection, } from "../../../extensions/subagents/src/ui/takeover.ts"; const theme = { @@ -201,6 +201,37 @@ test("chrome rows stay width-bounded and takeover uses three rules", () => { } }); +test("takeover rehydrates the full snapshot instead of the bounded projection", () => { + const projected = snap("run", "done", { + transcript: [ + { + kind: "assistant", + parts: [{ type: "text", text: "bounded projection only" }], + }, + ], + }); + const full = snap("run", "done", { + transcript: [ + { + kind: "assistant", + parts: [{ type: "text", text: "complete retained history" }], + }, + ], + }); + const list: SubagentReadModel = { + ...model([projected]), + getFull: (id) => (id === "run" ? full : undefined), + }; + const view = new TakeoverView(tui(), theme, keys, "run", list, () => {}); + try { + const output = view.render(80).join("\n"); + assert.match(output, /complete retained history/); + assert.doesNotMatch(output, /bounded projection only/); + } finally { + view.dispose(); + } +}); + test("takeover is a read-only child page without a message editor", () => { const running = snap("run"); let sends = 0; diff --git a/tests/support/subagents-stub.ts b/tests/support/subagents-stub.ts index 451ca6f0..d3939f13 100644 --- a/tests/support/subagents-stub.ts +++ b/tests/support/subagents-stub.ts @@ -147,26 +147,33 @@ const makeStubSession = ( { type: "toolCall", toolId, name: profile.toolName, argsPreview }, ], }); - yield* emit({ - _tag: "ToolStart", - toolId, - name: profile.toolName, - argsPreview, - }); + const toolIds = userText.trimStart().startsWith("MANYTOOLS:") + ? Array.from({ length: 130 }, (_, index) => `${toolId}-${index}`) + : [toolId]; + for (const activeToolId of toolIds) { + yield* emit({ + _tag: "ToolStart", + toolId: activeToolId, + name: profile.toolName, + argsPreview, + }); + } yield* pause; yield* emit({ _tag: "ToolUpdate", - toolId, + toolId: toolIds[0]!, outputPreview: "src docs package.json", }); yield* pause; - yield* emit({ - _tag: "ToolEnd", - toolId, - name: profile.toolName, - isError: false, - outputPreview: "src docs package.json", - }); + for (const activeToolId of toolIds) { + yield* emit({ + _tag: "ToolEnd", + toolId: activeToolId, + name: profile.toolName, + isError: false, + outputPreview: "src docs package.json", + }); + } yield* emit({ _tag: "UsageChanged", tokens: Math.min(profile.contextWindow, 2400 * (turn + 1)),