feat(core): elide oversized historic tool results from model context

Tool results from prior turns (skill loads, file reads, HTTP fetches)
dominate resent context and are rarely needed verbatim. A decorator over
the context resolver now replaces prior-turn tool results above a size
threshold with a short placeholder telling the model to re-run the tool
if it needs the output. The current turn's in-flight results are always
sent verbatim, the durable log is untouched, and elision is a pure
per-message function so resolved prefixes stay byte-stable for provider
prefix caching.

Policy lives in config/context.json (elideHistoricToolResults, default
on; elideHistoricToolResultsThresholdChars, default 10000). The inspect
CLI composes through the same decorated resolver so debug output still
matches transmitted bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-07-07 03:09:32 +05:30 committed by Ramnique Singh
parent 26e2fde0a8
commit 99f196e16c
5 changed files with 393 additions and 4 deletions

View file

@ -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

View file

@ -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<ITurnRepo>(FSTurnRepo).singleton(),
contextResolver: asClass<IContextResolver>(TurnRepoContextResolver).singleton(),
contextResolver: asFunction<IContextResolver>(({ turnRepo }) =>
createContextResolver({ turnRepo }),
).singleton(),
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
usageReporter: asClass<IUsageReporter>(RealUsageReporter).singleton(),
agentResolver: asFunction<IAgentResolver>(() => new RealAgentResolver()).singleton(),

View file

@ -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<typeof TurnEvent>;
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<typeof TurnContext>,
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");
});
});

View file

@ -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<z.infer<typeof ConversationMessage>>,
thresholdChars: number,
): Array<z.infer<typeof ConversationMessage>> {
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<typeof TurnContext>,
): Promise<Array<z.infer<typeof ConversationMessage>>> {
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<typeof ResolvedAgentSnapshot>,
): Promise<z.infer<typeof ResolvedAgent>> {
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 }),
});
}

View file

@ -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<typeof convertFromMessages>[0]) =>
convertFromMessages(messages) as unknown as JsonValue[];