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
|
||||
applies transmit-time elision to the materialized prefix: tool results from
|
||||
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
|
||||
image parts from prior turns (video-mode webcam and screen-share frames) are
|
||||
telling the model to re-run the tool if it needs the output; inline image
|
||||
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
|
||||
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
|
||||
byte-stable across calls (provider prefix caches keep working), and the
|
||||
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
|
||||
in `config/context.json` (`elideHistoricToolResults`, default true;
|
||||
`elideHistoricToolResultsThresholdChars`, default 10000;
|
||||
`elideHistoricImages`, default true). The inspect CLI composes through the
|
||||
same decorated resolver.
|
||||
`elideHistoricImages`, default true; `elideHistoricMiddlePaneContent`,
|
||||
default true). The inspect CLI composes through the same decorated resolver.
|
||||
|
||||
### 6.7 Agent snapshot inheritance
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { TurnContext, TurnEvent } from "@x/shared/dist/turns.js";
|
|||
import {
|
||||
ElidingContextResolver,
|
||||
elideHistoricImages,
|
||||
elideHistoricMiddlePaneContent,
|
||||
elideHistoricToolResults,
|
||||
} from "./context-elision.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 = {
|
||||
toolResults: false,
|
||||
toolResultThresholdChars: 100,
|
||||
images: false,
|
||||
middlePaneContent: false,
|
||||
};
|
||||
|
||||
describe("ElidingContextResolver", () => {
|
||||
|
|
@ -327,4 +378,23 @@ describe("ElidingContextResolver", () => {
|
|||
expect(input.content.some((p) => p.type === "image")).toBe(false);
|
||||
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
|
||||
// message. Unlike tool results they cannot be re-fetched, so the
|
||||
// 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
|
||||
// stay byte-stable across calls and turns (provider prefix caches keep
|
||||
|
|
@ -35,18 +40,25 @@ export interface ElisionPolicy {
|
|||
toolResults: boolean;
|
||||
toolResultThresholdChars: number;
|
||||
images: boolean;
|
||||
middlePaneContent: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ELISION_POLICY: ElisionPolicy = {
|
||||
toolResults: true,
|
||||
toolResultThresholdChars: 10_000,
|
||||
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({
|
||||
elideHistoricToolResults: z.boolean().optional(),
|
||||
elideHistoricToolResultsThresholdChars: z.number().int().min(0).optional(),
|
||||
elideHistoricImages: z.boolean().optional(),
|
||||
elideHistoricMiddlePaneContent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const CONTEXT_CONFIG_PATH = path.join(WorkDir, "config", "context.json");
|
||||
|
|
@ -70,6 +82,9 @@ export function loadElisionPolicy(): ElisionPolicy {
|
|||
DEFAULT_ELISION_POLICY.toolResultThresholdChars,
|
||||
images:
|
||||
parsed.elideHistoricImages ?? DEFAULT_ELISION_POLICY.images,
|
||||
middlePaneContent:
|
||||
parsed.elideHistoricMiddlePaneContent ??
|
||||
DEFAULT_ELISION_POLICY.middlePaneContent,
|
||||
};
|
||||
} catch {
|
||||
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
|
||||
// cross-turn prefix. Agent snapshot resolution is delegated untouched.
|
||||
export class ElidingContextResolver implements IContextResolver {
|
||||
|
|
@ -155,6 +198,9 @@ export class ElidingContextResolver implements IContextResolver {
|
|||
if (policy.images) {
|
||||
prefix = elideHistoricImages(prefix);
|
||||
}
|
||||
if (policy.middlePaneContent) {
|
||||
prefix = elideHistoricMiddlePaneContent(prefix);
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue