Repository navigation
fix(hub): point trimmed tool results at their saved output #164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]`; | ||
|
Comment on lines
+55
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 · A result the size cap already spilled is saved only as its cut-down inline copy, not the whole output The size cap keeps only the first maxChars characters inline, followed by a marker pointing at tool-output:///. That inline copy is what gets put in the new blob, so the blob holds a cut-down result plus the old pointer. The PR says that pointer can be overwritten by a later spill with the same call id. So the full output is still lost, even though the note says read_file 'returns it', and the character count in the note is the inline length, not the original length. Gemini (call id "0") returns a result over the size cap, then a later response's call "0" spills again, then history trimming cuts the first result. The new trimmed-… blob holds only the first maxChars characters plus a pointer that now returns the second result. Resolve this comment. Triage will not finish while it is open. |
||
| 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,15 +129,17 @@ 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()]; | ||
| return { | ||
| 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, | ||
| }; | ||
| }, | ||
| }; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.