Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/hub/src/builds/sidecar-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://<callId>; 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" &&
Expand Down
96 changes: 96 additions & 0 deletions apps/hub/src/workflows/compaction.test.ts
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);
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
expect(blobs).toHaveLength(3);
});
91 changes: 59 additions & 32 deletions apps/hub/src/workflows/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -123,10 +148,12 @@ export function trimHistory(inputTokens: number): Compactor {
reason: "context",
decisions: {
trimmedTurns,
savedResults: blobs.length,
tokensBefore: estimateTokens(turns),
tokensAfter: estimateTokens(output),
},
},
blobs,
};
},
};
Expand Down
9 changes: 1 addition & 8 deletions apps/hub/src/workflows/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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" ||
Expand Down