mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(core): elide historic middle-pane note snapshots from model context
Every user message sent while a note is open carries a full snapshot of that note in userMessageContext, so a long chat over one open note resends N full copies on every model call. The elision decorator now rewrites prior-turn user messages to keep the pane kind and path but replace note content above a small floor with a placeholder pointing at the still-readable file. The current message's snapshot is untouched, so "summarize this" keeps working; the system prompt already tells the model later middle-pane context overrides earlier. Config: elideHistoricMiddlePaneContent, default on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
05d7fa41cf
commit
4fbca2dbad
3 changed files with 124 additions and 5 deletions
|
|
@ -393,18 +393,21 @@ Rules:
|
||||||
The app wires the resolver through a decorator (`context-elision.ts`) that
|
The app wires the resolver through a decorator (`context-elision.ts`) that
|
||||||
applies transmit-time elision to the materialized prefix: tool results from
|
applies transmit-time elision to the materialized prefix: tool results from
|
||||||
prior turns above a size threshold are replaced with a short placeholder
|
prior turns above a size threshold are replaced with a short placeholder
|
||||||
telling the model to re-run the tool if it needs the output, and inline
|
telling the model to re-run the tool if it needs the output; inline image
|
||||||
image parts from prior turns (video-mode webcam and screen-share frames) are
|
parts from prior turns (video-mode webcam and screen-share frames) are
|
||||||
replaced with a text part recording how many frames of each kind were
|
replaced with a text part recording how many frames of each kind were
|
||||||
dropped. The durable log is untouched; only the transmitted bytes change.
|
dropped; and middle-pane note snapshots on prior-turn user messages keep
|
||||||
|
their kind and path but have their content replaced with a placeholder
|
||||||
|
pointing at the still-readable file. The durable log is untouched; only the
|
||||||
|
transmitted bytes change.
|
||||||
Elision is a pure function of each message, so resolved prefixes stay
|
Elision is a pure function of each message, so resolved prefixes stay
|
||||||
byte-stable across calls (provider prefix caches keep working), and the
|
byte-stable across calls (provider prefix caches keep working), and the
|
||||||
current turn's own messages never pass through the resolver, so in-flight
|
current turn's own messages never pass through the resolver, so in-flight
|
||||||
tool results and just-captured frames are always sent verbatim. Policy lives
|
tool results and just-captured frames are always sent verbatim. Policy lives
|
||||||
in `config/context.json` (`elideHistoricToolResults`, default true;
|
in `config/context.json` (`elideHistoricToolResults`, default true;
|
||||||
`elideHistoricToolResultsThresholdChars`, default 10000;
|
`elideHistoricToolResultsThresholdChars`, default 10000;
|
||||||
`elideHistoricImages`, default true). The inspect CLI composes through the
|
`elideHistoricImages`, default true; `elideHistoricMiddlePaneContent`,
|
||||||
same decorated resolver.
|
default true). The inspect CLI composes through the same decorated resolver.
|
||||||
|
|
||||||
### 6.7 Agent snapshot inheritance
|
### 6.7 Agent snapshot inheritance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { TurnContext, TurnEvent } from "@x/shared/dist/turns.js";
|
||||||
import {
|
import {
|
||||||
ElidingContextResolver,
|
ElidingContextResolver,
|
||||||
elideHistoricImages,
|
elideHistoricImages,
|
||||||
|
elideHistoricMiddlePaneContent,
|
||||||
elideHistoricToolResults,
|
elideHistoricToolResults,
|
||||||
} from "./context-elision.js";
|
} from "./context-elision.js";
|
||||||
import { TurnRepoContextResolver } from "./context-resolver.js";
|
import { TurnRepoContextResolver } from "./context-resolver.js";
|
||||||
|
|
@ -238,10 +239,60 @@ describe("elideHistoricImages", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function noteMessage(content: string) {
|
||||||
|
return {
|
||||||
|
role: "user" as const,
|
||||||
|
content: "make this punchier",
|
||||||
|
userMessageContext: {
|
||||||
|
currentDateTime: "Thursday, July 2, 2026 at 10:00 AM GMT",
|
||||||
|
middlePane: {
|
||||||
|
kind: "note" as const,
|
||||||
|
path: "knowledge/Notes/Draft.md",
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("elideHistoricMiddlePaneContent", () => {
|
||||||
|
it("replaces large note snapshots, keeping kind and path", () => {
|
||||||
|
const [elided] = elideHistoricMiddlePaneContent([
|
||||||
|
noteMessage("n".repeat(600)),
|
||||||
|
]);
|
||||||
|
if (elided.role !== "user") throw new Error("expected user message");
|
||||||
|
const middlePane = elided.userMessageContext?.middlePane;
|
||||||
|
if (middlePane?.kind !== "note") throw new Error("expected note pane");
|
||||||
|
expect(middlePane.path).toBe("knowledge/Notes/Draft.md");
|
||||||
|
expect(middlePane.content).toContain("omitted from history");
|
||||||
|
expect(middlePane.content).toContain("600");
|
||||||
|
expect(elided.userMessageContext?.currentDateTime).toBeDefined();
|
||||||
|
expect(elided.content).toBe("make this punchier");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps small notes and non-note panes untouched", () => {
|
||||||
|
const small = noteMessage("short todo list");
|
||||||
|
const browser = {
|
||||||
|
role: "user" as const,
|
||||||
|
content: "what is this page?",
|
||||||
|
userMessageContext: {
|
||||||
|
middlePane: {
|
||||||
|
kind: "browser" as const,
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "Example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const plain = user("no context at all");
|
||||||
|
const messages = [small, browser, plain];
|
||||||
|
expect(elideHistoricMiddlePaneContent(messages)).toEqual(messages);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const POLICY_OFF = {
|
const POLICY_OFF = {
|
||||||
toolResults: false,
|
toolResults: false,
|
||||||
toolResultThresholdChars: 100,
|
toolResultThresholdChars: 100,
|
||||||
images: false,
|
images: false,
|
||||||
|
middlePaneContent: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("ElidingContextResolver", () => {
|
describe("ElidingContextResolver", () => {
|
||||||
|
|
@ -327,4 +378,23 @@ describe("ElidingContextResolver", () => {
|
||||||
expect(input.content.some((p) => p.type === "image")).toBe(false);
|
expect(input.content.some((p) => p.type === "image")).toBe(false);
|
||||||
expect(JSON.stringify(input.content)).toContain("1 webcam frame");
|
expect(JSON.stringify(input.content)).toContain("1 webcam frame");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("elides middle-pane note snapshots in the resolved prefix when enabled", async () => {
|
||||||
|
const repo = new InMemoryTurnRepo();
|
||||||
|
const log = toolTurnLog(T1, [], "small").map((event) =>
|
||||||
|
event.type === "turn_created"
|
||||||
|
? { ...event, input: noteMessage("n".repeat(600)) }
|
||||||
|
: event,
|
||||||
|
);
|
||||||
|
repo.seed(log);
|
||||||
|
const resolved = await resolver(repo, {
|
||||||
|
...POLICY_OFF,
|
||||||
|
middlePaneContent: true,
|
||||||
|
}).resolve({ previousTurnId: T1 });
|
||||||
|
const input = resolved[0];
|
||||||
|
if (input.role !== "user") throw new Error("expected user message");
|
||||||
|
const middlePane = input.userMessageContext?.middlePane;
|
||||||
|
if (middlePane?.kind !== "note") throw new Error("expected note pane");
|
||||||
|
expect(middlePane.content).toContain("omitted from history");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ import type { ITurnRepo } from "./repo.js";
|
||||||
// text carries the takeaway, and fresh frames arrive with every new call
|
// text carries the takeaway, and fresh frames arrive with every new call
|
||||||
// message. Unlike tool results they cannot be re-fetched, so the
|
// message. Unlike tool results they cannot be re-fetched, so the
|
||||||
// placeholder only records what was there.
|
// placeholder only records what was there.
|
||||||
|
// - Middle-pane note content: every user message sent while a note is
|
||||||
|
// open carries a full snapshot of that note in userMessageContext. Only
|
||||||
|
// the newest snapshot matters (the system prompt already tells the model
|
||||||
|
// later middle-pane context overrides earlier), and the current file is
|
||||||
|
// always re-readable at the recorded path.
|
||||||
//
|
//
|
||||||
// Elision is a pure function of each message's content, so resolved prefixes
|
// Elision is a pure function of each message's content, so resolved prefixes
|
||||||
// stay byte-stable across calls and turns (provider prefix caches keep
|
// stay byte-stable across calls and turns (provider prefix caches keep
|
||||||
|
|
@ -35,18 +40,25 @@ export interface ElisionPolicy {
|
||||||
toolResults: boolean;
|
toolResults: boolean;
|
||||||
toolResultThresholdChars: number;
|
toolResultThresholdChars: number;
|
||||||
images: boolean;
|
images: boolean;
|
||||||
|
middlePaneContent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_ELISION_POLICY: ElisionPolicy = {
|
export const DEFAULT_ELISION_POLICY: ElisionPolicy = {
|
||||||
toolResults: true,
|
toolResults: true,
|
||||||
toolResultThresholdChars: 10_000,
|
toolResultThresholdChars: 10_000,
|
||||||
images: true,
|
images: true,
|
||||||
|
middlePaneContent: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notes smaller than this stay verbatim in history: below the floor the
|
||||||
|
// placeholder saves nothing and a short note may carry useful context.
|
||||||
|
const MIDDLE_PANE_CONTENT_FLOOR_CHARS = 500;
|
||||||
|
|
||||||
const ContextConfig = z.object({
|
const ContextConfig = z.object({
|
||||||
elideHistoricToolResults: z.boolean().optional(),
|
elideHistoricToolResults: z.boolean().optional(),
|
||||||
elideHistoricToolResultsThresholdChars: z.number().int().min(0).optional(),
|
elideHistoricToolResultsThresholdChars: z.number().int().min(0).optional(),
|
||||||
elideHistoricImages: z.boolean().optional(),
|
elideHistoricImages: z.boolean().optional(),
|
||||||
|
elideHistoricMiddlePaneContent: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const CONTEXT_CONFIG_PATH = path.join(WorkDir, "config", "context.json");
|
const CONTEXT_CONFIG_PATH = path.join(WorkDir, "config", "context.json");
|
||||||
|
|
@ -70,6 +82,9 @@ export function loadElisionPolicy(): ElisionPolicy {
|
||||||
DEFAULT_ELISION_POLICY.toolResultThresholdChars,
|
DEFAULT_ELISION_POLICY.toolResultThresholdChars,
|
||||||
images:
|
images:
|
||||||
parsed.elideHistoricImages ?? DEFAULT_ELISION_POLICY.images,
|
parsed.elideHistoricImages ?? DEFAULT_ELISION_POLICY.images,
|
||||||
|
middlePaneContent:
|
||||||
|
parsed.elideHistoricMiddlePaneContent ??
|
||||||
|
DEFAULT_ELISION_POLICY.middlePaneContent,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_ELISION_POLICY;
|
return DEFAULT_ELISION_POLICY;
|
||||||
|
|
@ -124,6 +139,34 @@ export function elideHistoricImages(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function elideHistoricMiddlePaneContent(
|
||||||
|
messages: Array<z.infer<typeof ConversationMessage>>,
|
||||||
|
): Array<z.infer<typeof ConversationMessage>> {
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (message.role !== "user") {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const middlePane = message.userMessageContext?.middlePane;
|
||||||
|
if (
|
||||||
|
!middlePane ||
|
||||||
|
middlePane.kind !== "note" ||
|
||||||
|
middlePane.content.length <= MIDDLE_PANE_CONTENT_FLOOR_CHARS
|
||||||
|
) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
userMessageContext: {
|
||||||
|
...message.userMessageContext,
|
||||||
|
middlePane: {
|
||||||
|
...middlePane,
|
||||||
|
content: `[Note content (${middlePane.content.length} characters) omitted from history to save context. This snapshot is stale; read the file at the path above if its content is needed now.]`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// IContextResolver decorator: applies the elision policy to the materialized
|
// IContextResolver decorator: applies the elision policy to the materialized
|
||||||
// cross-turn prefix. Agent snapshot resolution is delegated untouched.
|
// cross-turn prefix. Agent snapshot resolution is delegated untouched.
|
||||||
export class ElidingContextResolver implements IContextResolver {
|
export class ElidingContextResolver implements IContextResolver {
|
||||||
|
|
@ -155,6 +198,9 @@ export class ElidingContextResolver implements IContextResolver {
|
||||||
if (policy.images) {
|
if (policy.images) {
|
||||||
prefix = elideHistoricImages(prefix);
|
prefix = elideHistoricImages(prefix);
|
||||||
}
|
}
|
||||||
|
if (policy.middlePaneContent) {
|
||||||
|
prefix = elideHistoricMiddlePaneContent(prefix);
|
||||||
|
}
|
||||||
return prefix;
|
return prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue