diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index cfff8cf2..b6bb53b0 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -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 diff --git a/apps/x/packages/core/src/turns/context-elision.test.ts b/apps/x/packages/core/src/turns/context-elision.test.ts index e08ebfa1..0864d206 100644 --- a/apps/x/packages/core/src/turns/context-elision.test.ts +++ b/apps/x/packages/core/src/turns/context-elision.test.ts @@ -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"); + }); }); diff --git a/apps/x/packages/core/src/turns/context-elision.ts b/apps/x/packages/core/src/turns/context-elision.ts index 87a51129..05fbfb43 100644 --- a/apps/x/packages/core/src/turns/context-elision.ts +++ b/apps/x/packages/core/src/turns/context-elision.ts @@ -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>, +): Array> { + 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; }