diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index d199fabe..0dcc8026 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -390,6 +390,18 @@ Rules: It rejects the execution and does not append `turn_failed`. - The reducer treats `context` as opaque data and never resolves references. +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. 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 in-flight tool +results never pass through the resolver, so they are always sent verbatim. +Policy lives in `config/context.json` (`elideHistoricToolResults`, default +true; `elideHistoricToolResultsThresholdChars`, default 10000). The inspect +CLI composes through the same decorated resolver. + ### 6.7 Agent snapshot inheritance Session turns whose resolved system prompt and tool set are byte-identical diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 77bda6bb..306ebc33 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -31,7 +31,8 @@ import type { INotificationService } from "../application/notification/service.j import { SystemClock, type IClock } from "../turns/clock.js"; import { FSTurnRepo } from "../turns/fs-repo.js"; import type { ITurnRepo } from "../turns/repo.js"; -import { TurnRepoContextResolver, type IContextResolver } from "../turns/context-resolver.js"; +import type { IContextResolver } from "../turns/context-resolver.js"; +import { createContextResolver } from "../turns/context-elision.js"; import { EmitterTurnLifecycleBus, type ITurnLifecycleBus } from "../turns/bus.js"; import { RealUsageReporter } from "../turns/bridges/real-usage-reporter.js"; import type { IUsageReporter } from "../turns/usage-reporter.js"; @@ -117,7 +118,9 @@ container.register({ turnsRootDir: asValue(path.join(WorkDir, "storage", "turns")), sessionsRootDir: asValue(path.join(WorkDir, "storage", "sessions")), turnRepo: asClass(FSTurnRepo).singleton(), - contextResolver: asClass(TurnRepoContextResolver).singleton(), + contextResolver: asFunction(({ turnRepo }) => + createContextResolver({ turnRepo }), + ).singleton(), lifecycleBus: asClass(EmitterTurnLifecycleBus).singleton(), usageReporter: asClass(RealUsageReporter).singleton(), agentResolver: asFunction(() => new RealAgentResolver()).singleton(), diff --git a/apps/x/packages/core/src/turns/context-elision.test.ts b/apps/x/packages/core/src/turns/context-elision.test.ts new file mode 100644 index 00000000..9543a46f --- /dev/null +++ b/apps/x/packages/core/src/turns/context-elision.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import type { z } from "zod"; +import type { TurnContext, TurnEvent } from "@x/shared/dist/turns.js"; +import { + ElidingContextResolver, + elideHistoricToolResults, +} from "./context-elision.js"; +import { TurnRepoContextResolver } from "./context-resolver.js"; +import { InMemoryTurnRepo } from "./in-memory-turn-repo.js"; + +type TEvent = z.infer; + +function user(text: string) { + return { role: "user" as const, content: text }; +} + +function assistant(text: string) { + return { role: "assistant" as const, content: text }; +} + +function toolMsg(content: string, toolName = "file-readText") { + return { + role: "tool" as const, + content, + toolCallId: "tc1", + toolName, + }; +} + +// A completed turn containing one tool round trip whose result has the given +// output, followed by a final text response. +function toolTurnLog( + turnId: string, + context: z.infer, + toolOutput: string, +): TEvent[] { + const ts = "2026-07-02T10:00:00Z"; + const echo = { + toolId: "tool.echo", + name: "echo", + description: "Echo", + inputSchema: {}, + execution: "sync" as const, + requiresHuman: false, + }; + const call = { + role: "assistant" as const, + content: [ + { + type: "tool-call" as const, + toolCallId: "tc1", + toolName: "echo", + arguments: {}, + }, + ], + }; + return [ + { + type: "turn_created", + schemaVersion: 1, + turnId, + ts, + sessionId: "sess-1", + agent: { + requested: { agentId: "copilot" }, + resolved: { + agentId: "copilot", + systemPrompt: "SYS", + model: { provider: "fake", model: "m" }, + tools: [echo], + }, + }, + context, + input: user("do it"), + config: { + autoPermission: false, + humanAvailable: true, + maxModelCalls: 20, + }, + }, + { + type: "model_call_requested", + turnId, + ts, + modelCallIndex: 0, + request: { + ...(Array.isArray(context) ? {} : { contextRef: context }), + messages: + Array.isArray(context) && context.length > 0 + ? ["context", "input"] + : ["input"], + parameters: {}, + }, + }, + { + type: "model_call_completed", + turnId, + ts, + modelCallIndex: 0, + message: call, + finishReason: "tool-calls", + usage: {}, + }, + { + type: "tool_invocation_requested", + turnId, + ts, + toolCallId: "tc1", + toolId: "tool.echo", + toolName: "echo", + execution: "sync", + input: {}, + }, + { + type: "tool_result", + turnId, + ts, + toolCallId: "tc1", + toolName: "echo", + source: "sync", + result: { output: toolOutput, isError: false }, + }, + { + type: "model_call_requested", + turnId, + ts, + modelCallIndex: 1, + request: { + messages: ["assistant:0", "toolResult:tc1"], + parameters: {}, + }, + }, + { + type: "model_call_completed", + turnId, + ts, + modelCallIndex: 1, + message: assistant("done"), + finishReason: "stop", + usage: {}, + }, + { + type: "turn_completed", + turnId, + ts, + output: assistant("done"), + finishReason: "stop", + usage: {}, + }, + ]; +} + +const T1 = "2026-07-02T10-00-00Z-0000001-000"; + +describe("elideHistoricToolResults", () => { + it("replaces tool results above the threshold with a placeholder", () => { + const big = "x".repeat(101); + const elided = elideHistoricToolResults([toolMsg(big)], 100); + expect(elided).toHaveLength(1); + expect(elided[0]).toMatchObject({ + role: "tool", + toolCallId: "tc1", + toolName: "file-readText", + }); + expect(elided[0].content).toContain("elided"); + expect(elided[0].content).toContain("file-readText"); + expect(elided[0].content).toContain("101"); + }); + + it("keeps tool results at or below the threshold verbatim", () => { + const exact = toolMsg("x".repeat(100)); + expect(elideHistoricToolResults([exact], 100)).toEqual([exact]); + }); + + it("leaves non-tool messages untouched", () => { + const messages = [user("q".repeat(500)), assistant("a".repeat(500))]; + expect(elideHistoricToolResults(messages, 100)).toEqual(messages); + }); + + it("is idempotent at realistic thresholds (placeholder is well under them)", () => { + const once = elideHistoricToolResults([toolMsg("x".repeat(5000))], 1000); + expect(once[0].content.length).toBeLessThan(1000); + expect(elideHistoricToolResults(once, 1000)).toEqual(once); + }); + + it("is deterministic for the same input", () => { + const messages = [toolMsg("x".repeat(5000)), user("q"), assistant("a")]; + expect(elideHistoricToolResults(messages, 100)).toEqual( + elideHistoricToolResults(messages, 100), + ); + }); +}); + +describe("ElidingContextResolver", () => { + function resolver( + repo: InMemoryTurnRepo, + policy: { enabled: boolean; thresholdChars: number }, + ) { + return new ElidingContextResolver({ + inner: new TurnRepoContextResolver({ turnRepo: repo }), + loadPolicy: () => policy, + }); + } + + it("elides oversized tool results in the resolved prefix", async () => { + const repo = new InMemoryTurnRepo(); + repo.seed(toolTurnLog(T1, [], "x".repeat(200))); + const resolved = await resolver(repo, { + enabled: true, + thresholdChars: 100, + }).resolve({ previousTurnId: T1 }); + const tool = resolved.find((m) => m.role === "tool"); + expect(tool?.content).toContain("elided"); + // The rest of the transcript is intact. + expect(resolved.map((m) => m.role)).toEqual([ + "user", + "assistant", + "tool", + "assistant", + ]); + }); + + it("returns the prefix unchanged when the policy is disabled", async () => { + const repo = new InMemoryTurnRepo(); + const output = "x".repeat(200); + repo.seed(toolTurnLog(T1, [], output)); + const resolved = await resolver(repo, { + enabled: false, + thresholdChars: 100, + }).resolve({ previousTurnId: T1 }); + const tool = resolved.find((m) => m.role === "tool"); + expect(tool?.content).toBe(output); + }); + + it("keeps small tool results verbatim when enabled", async () => { + const repo = new InMemoryTurnRepo(); + repo.seed(toolTurnLog(T1, [], "small")); + const resolved = await resolver(repo, { + enabled: true, + thresholdChars: 100, + }).resolve({ previousTurnId: T1 }); + const tool = resolved.find((m) => m.role === "tool"); + expect(tool?.content).toBe("small"); + }); +}); diff --git a/apps/x/packages/core/src/turns/context-elision.ts b/apps/x/packages/core/src/turns/context-elision.ts new file mode 100644 index 00000000..6c88445c --- /dev/null +++ b/apps/x/packages/core/src/turns/context-elision.ts @@ -0,0 +1,129 @@ +import fs from "fs"; +import path from "path"; +import { z } from "zod"; +import type { + ConversationMessage, + ResolvedAgent, + ResolvedAgentSnapshot, + TurnContext, +} from "@x/shared/dist/turns.js"; +import { WorkDir } from "../config/config.js"; +import type { IContextResolver } from "./context-resolver.js"; +import { TurnRepoContextResolver } from "./context-resolver.js"; +import type { ITurnRepo } from "./repo.js"; + +// Transmit-time elision of historic tool results (the cross-turn prefix +// only — the current turn's own messages never pass through the resolver, so +// in-flight tool results are always sent verbatim). Large tool outputs from +// earlier turns (skill loads, file reads, HTTP fetches) dominate resent +// context; the model rarely needs them verbatim and can re-run the tool when +// it does. Elision is a pure function of each message's content, so resolved +// prefixes stay byte-stable across calls and turns (provider prefix caches +// keep working), and the durable JSONL log is untouched — only the +// transmitted bytes change. + +export interface ToolResultElisionPolicy { + enabled: boolean; + thresholdChars: number; +} + +export const DEFAULT_ELISION_POLICY: ToolResultElisionPolicy = { + enabled: true, + thresholdChars: 10_000, +}; + +const ContextConfig = z.object({ + elideHistoricToolResults: z.boolean().optional(), + elideHistoricToolResultsThresholdChars: z.number().int().min(0).optional(), +}); + +const CONTEXT_CONFIG_PATH = path.join(WorkDir, "config", "context.json"); + +// Read the elision policy from config/context.json, falling back to defaults +// for missing keys or an unreadable file. Read per resolve so a config edit +// applies to the next turn without a restart. +export function loadElisionPolicy(): ToolResultElisionPolicy { + try { + if (!fs.existsSync(CONTEXT_CONFIG_PATH)) { + return DEFAULT_ELISION_POLICY; + } + const raw = fs.readFileSync(CONTEXT_CONFIG_PATH, "utf-8"); + const parsed = ContextConfig.parse(JSON.parse(raw)); + return { + enabled: + parsed.elideHistoricToolResults ?? + DEFAULT_ELISION_POLICY.enabled, + thresholdChars: + parsed.elideHistoricToolResultsThresholdChars ?? + DEFAULT_ELISION_POLICY.thresholdChars, + }; + } catch { + return DEFAULT_ELISION_POLICY; + } +} + +export function elideHistoricToolResults( + messages: Array>, + thresholdChars: number, +): Array> { + return messages.map((message) => { + if ( + message.role !== "tool" || + message.content.length <= thresholdChars + ) { + return message; + } + return { + ...message, + content: `[Tool result elided to save context: "${message.toolName}" returned ${message.content.length} characters in an earlier turn. Call the tool again if you need this output now.]`, + }; + }); +} + +// IContextResolver decorator: applies the elision policy to the materialized +// cross-turn prefix. Agent snapshot resolution is delegated untouched. +export class ElidingContextResolver implements IContextResolver { + private readonly inner: IContextResolver; + private readonly loadPolicy: () => ToolResultElisionPolicy; + + constructor({ + inner, + loadPolicy, + }: { + inner: IContextResolver; + loadPolicy?: () => ToolResultElisionPolicy; + }) { + this.inner = inner; + this.loadPolicy = loadPolicy ?? loadElisionPolicy; + } + + async resolve( + context: z.infer, + ): Promise>> { + const prefix = await this.inner.resolve(context); + const policy = this.loadPolicy(); + if (!policy.enabled) { + return prefix; + } + return elideHistoricToolResults(prefix, policy.thresholdChars); + } + + resolveAgent( + resolved: z.infer, + ): Promise> { + return this.inner.resolveAgent(resolved); + } +} + +// The one context resolver the app should construct (DI container and the +// inspect CLI both use this), so the debug view reproduces the same bytes +// the loop transmits. +export function createContextResolver({ + turnRepo, +}: { + turnRepo: ITurnRepo; +}): IContextResolver { + return new ElidingContextResolver({ + inner: new TurnRepoContextResolver({ turnRepo }), + }); +} diff --git a/apps/x/packages/core/src/turns/inspect-cli.ts b/apps/x/packages/core/src/turns/inspect-cli.ts index b662e407..3140148f 100644 --- a/apps/x/packages/core/src/turns/inspect-cli.ts +++ b/apps/x/packages/core/src/turns/inspect-cli.ts @@ -27,7 +27,7 @@ import { convertFromMessages } from "../agents/runtime.js"; import { WorkDir } from "../config/config.js"; import { FSSessionRepo } from "../sessions/fs-repo.js"; import { composeModelRequest } from "./compose-model-request.js"; -import { TurnRepoContextResolver } from "./context-resolver.js"; +import { createContextResolver } from "./context-elision.js"; import { FSTurnRepo } from "./fs-repo.js"; const turnRepo = new FSTurnRepo({ @@ -36,7 +36,7 @@ const turnRepo = new FSTurnRepo({ const sessionRepo = new FSSessionRepo({ sessionsRootDir: path.join(WorkDir, "storage", "sessions"), }); -const resolver = new TurnRepoContextResolver({ turnRepo }); +const resolver = createContextResolver({ turnRepo }); const encode = (messages: Parameters[0]) => convertFromMessages(messages) as unknown as JsonValue[];