mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
26e2fde0a8
commit
99f196e16c
5 changed files with 393 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
245
apps/x/packages/core/src/turns/context-elision.test.ts
Normal file
245
apps/x/packages/core/src/turns/context-elision.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
129
apps/x/packages/core/src/turns/context-elision.ts
Normal file
129
apps/x/packages/core/src/turns/context-elision.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue