diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts index cd55d2db..f0586da9 100644 --- a/apps/hub/src/builds/sidecar-tools.ts +++ b/apps/hub/src/builds/sidecar-tools.ts @@ -387,8 +387,9 @@ export function createSidecarCodingTools(policy: { }; const workspace = await realpath(env.toolCwd); const path = (call.arguments as { path?: unknown } | undefined)?.path; - // A large result is spilled to tool-output://; the sandboxed child has no blob - // reader, so read_file of one is answered here from the run's own storage. + // A result the size cap spilled or compaction trimmed is saved under a tool-output:/// URI; + // the sandboxed child has no blob reader, so read_file of one is answered here from the + // run's own storage. if ( call.name === "read_file" && typeof path === "string" && diff --git a/apps/hub/src/workflows/compaction.test.ts b/apps/hub/src/workflows/compaction.test.ts new file mode 100644 index 00000000..07e4d199 --- /dev/null +++ b/apps/hub/src/workflows/compaction.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { createBlobReader } from "@intx/types/runtime"; +import type { ConversationTurn, ReactorState } from "@intx/types/runtime"; +import { trimHistory } from "./compaction.ts"; + +const state: ReactorState = { + turns: [], + activeForks: [], + pendingOperations: [], + activeGates: [], + tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + lastCycleUsage: null, + lastCycleSource: null, + sessionId: "session", +}; + +const ran = ( + callId: string, + output: string, + timestamp: number, +): ConversationTurn[] => [ + { + role: "assistant", + content: [ + { + type: "tool_call", + id: callId, + name: "run_shell", + arguments: { command: "check-app" }, + }, + ], + timestamp, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId, + content: [{ type: "text", text: output }], + }, + ], + timestamp: timestamp + 1, + }, +]; + +const notes = (turns: ConversationTurn[]) => + turns + .flatMap((turn) => turn.content) + .flatMap((block) => + block.type === "tool_result" && block.content[0]?.type === "text" + ? [block.content[0].text] + : [], + ); + +test("a trimmed tool result's note points at its whole output, saved where read_file finds it", async () => { + // Gemini numbers calls from 0 in every response, so two results share the call id "0". + const first = `check failed: 1 problem(s)${"\n✗ a rule".repeat(200)}`; + const second = `check passed: built, started${"\n✓ a rule".repeat(200)}`; + const spilled = `${"x".repeat(700)}\n[Tool output truncated: omitted 9000 chars. Full output available at tool-output:///0 -- use read_file with that URI to see the rest.]`; + const { output: turns, blobs } = await trimHistory(1).apply( + [ + { + role: "user", + content: [{ type: "text", text: "Build the app." }], + timestamp: 0, + }, + ...ran("0", first, 10), + ...ran("0", second, 20), + ...ran("0", spilled, 30), + ], + { state, trigger: "context" }, + ); + + if (!blobs) throw new Error("trim_saved_nothing"); + const saved = new Map(blobs.map((blob) => [blob.key, blob.bytes])); + const reader = createBlobReader({ + readBlob: async (key) => { + const bytes = saved.get(key); + if (!bytes) throw new Error(`no blob ${key}`); + return bytes; + }, + }); + const [firstNote, secondNote, spilledNote] = notes(turns); + const readBack = async (note = "") => { + expect(note.length).toBeLessThan(600); + const uri = note.match(/tool-output:\/\/\/\S+/)?.[0]; + if (!uri) throw new Error(`note_without_uri: ${note}`); + return new TextDecoder().decode(await reader.read(uri)); + }; + expect(await readBack(firstNote)).toBe(first); + expect(await readBack(secondNote)).toBe(second); + // The size cap keyed its spill by the bare id "0", which a later spill overwrites. + expect(await readBack(spilledNote)).toBe(spilled); + expect(blobs).toHaveLength(3); +}); diff --git a/apps/hub/src/workflows/compaction.ts b/apps/hub/src/workflows/compaction.ts index 6e100fd9..7f70c00f 100644 --- a/apps/hub/src/workflows/compaction.ts +++ b/apps/hub/src/workflows/compaction.ts @@ -2,22 +2,20 @@ import type { Compactor, ContentBlock, ConversationTurn, + StrategyBlob, } from "@intx/types/runtime"; export const COMPACTOR = "@corbits-builder/trim-history"; -// Old tool arguments and results longer than this are replaced by a note saying what they were. Never +// Old tool arguments and results longer than this are replaced by a note. Never // keep a clipped opening: a coder read its own clipped writes as broken files and spent a whole turn // repeating "let me rewrite it cleanly". const KEEP_CHARS = 600; -const elided = (length: number, what: string) => - `[${length} characters ${what}, not kept after compaction]`; - function elideValue(value: unknown): unknown { if (typeof value === "string") return value.length <= KEEP_CHARS ? value - : elided(value.length, "written; the file holds them"); + : `[${value.length} characters written; the file holds them, not kept after compaction]`; if (Array.isArray(value)) return value.map(elideValue); if (value && typeof value === "object") return Object.fromEntries( @@ -26,30 +24,53 @@ function elideValue(value: unknown): unknown { return value; } -function trimBlock(block: ContentBlock): ContentBlock | undefined { +/** The text of a turn's or a tool result's blocks, joined. */ +export const textOf = (blocks: readonly ContentBlock[]) => + blocks + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + +// A trimmed result is saved where the agent's read_file finds it, as the size cap's are; asking for a +// rerun cost the coder a whole check-app. The key carries the turn's time because a provider such as +// Gemini numbers calls from 0 in every response, so call ids repeat within one conversation. A result +// the size cap spilled is saved too: its spill is keyed by the bare call id, which a later spill overwrites. +function trimBlock( + block: ContentBlock, + timestamp: number, + blobs: StrategyBlob[], +): ContentBlock[] { if (block.type === "thinking" || block.type === "redacted_thinking") - return undefined; + return []; if (block.type === "tool_call") - return { - ...block, - arguments: elideValue(block.arguments) as typeof block.arguments, - }; - if (block.type === "tool_result") - return { - ...block, - content: block.content.map((item) => - item.type === "text" && item.text.length > KEEP_CHARS - ? { - ...item, - text: elided( - item.text.length, - "of an earlier result; run the tool again if it is still needed", - ), - } - : item, - ), - }; - return block; + return [ + { + ...block, + arguments: elideValue(block.arguments) as typeof block.arguments, + }, + ]; + if (block.type === "tool_result") { + const long = (item: (typeof block.content)[number]) => + item.type === "text" && item.text.length > KEEP_CHARS; + if (!block.content.some(long)) return [block]; + const text = textOf(block.content); + const key = `trimmed-${timestamp}-${block.callId}`; + blobs.push({ + key, + bytes: new TextEncoder().encode(text), + contentType: "text/plain", + }); + const note = `[${text.length} characters of an earlier result, not kept after compaction; read_file tool-output:///${key} returns it]`; + return [ + { + ...block, + content: block.content.map((item) => + item.type === "text" && long(item) ? { ...item, text: note } : item, + ), + }, + ]; + } + return [block]; } // Provider token counts on overflow against the characters of the prompt sent: 3.9 characters per @@ -78,18 +99,20 @@ const withoutReasoning = (turn: ConversationTurn): ConversationTurn => ({ /** * Keeps the task, and the newest turns that fit in half of inputTokens (the context window less the * completion reserve) as they are, with reasoning only on the last two assistant turns. From older - * turns it drops the reasoning and replaces long tool arguments and results with a note of what - * they were. Every turn and every call/result pair stays, so the history remains a valid conversation. + * turns it drops the reasoning and replaces long tool arguments with a note of what they were, and + * long tool results with a note of where read_file finds them whole. Every turn and every + * call/result pair stays, so the history remains a valid conversation. */ export function trimHistory(inputTokens: number): Compactor { const budget = Math.floor(inputTokens * KEPT_SHARE); return { name: COMPACTOR, - version: "3", + version: "4", apply: async (turns: ConversationTurn[]) => { let used = estimateTokens(turns.slice(0, 1)); let assistantTurns = 0; let trimmedTurns = 0; + const blobs: StrategyBlob[] = []; const recentFirst = turns .slice(1) .reverse() @@ -106,7 +129,9 @@ export function trimHistory(inputTokens: number): Compactor { trimmedTurns += 1; return { ...turn, - content: turn.content.flatMap((block) => trimBlock(block) ?? []), + content: turn.content.flatMap((block) => + trimBlock(block, turn.timestamp, blobs), + ), }; }); const output = [...turns.slice(0, 1), ...recentFirst.reverse()]; @@ -114,7 +139,7 @@ export function trimHistory(inputTokens: number): Compactor { output, record: { strategy: COMPACTOR, - version: "3", + version: "4", parameters: { budget, charsPerToken: CHARS_PER_TOKEN, @@ -123,10 +148,12 @@ export function trimHistory(inputTokens: number): Compactor { reason: "context", decisions: { trimmedTurns, + savedResults: blobs.length, tokensBefore: estimateTokens(turns), tokensAfter: estimateTokens(output), }, }, + blobs, }; }, }; diff --git a/apps/hub/src/workflows/director.ts b/apps/hub/src/workflows/director.ts index 2c8b3d1c..20132d36 100644 --- a/apps/hub/src/workflows/director.ts +++ b/apps/hub/src/workflows/director.ts @@ -2,7 +2,6 @@ import { deflateSync } from "node:zlib"; import { type } from "arktype"; import { defaultDirectorFactory, defineDirector } from "@intx/agent/authoring"; import type { - ContentBlock, ConversationTurn, InferenceError, ReactorAction, @@ -11,7 +10,7 @@ import type { } from "@intx/types/runtime"; import { REPETITION_ABORT } from "../inference/repetition.ts"; import { readCheckDetail, type CheckDetail } from "../builds/check-detail.ts"; -import { COMPACTOR, DROP_DEGENERATE } from "./compaction.ts"; +import { COMPACTOR, DROP_DEGENERATE, textOf } from "./compaction.ts"; // About 1 coder turn in 100 skipped reasoning and repeated one sentence ("Ok to write. Let me // write.") for 220000 to 280000 characters until the output cap. The default director mailed it to @@ -43,12 +42,6 @@ const compressesBelow = (text: string, ratio: number) => text.length > 8000 && deflateSync(text).length < Buffer.byteLength(text) * ratio; -const textOf = (blocks: readonly ContentBlock[]) => - blocks - .filter((block) => block.type === "text") - .map((block) => block.text) - .join(""); - function isDegenerate(turn: ConversationTurn): boolean { if ( turn.role !== "assistant" ||