Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions packages/agent-runtime/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8703,3 +8703,103 @@ describe("context estimate calibration", () => {
});
});
});

describe("tool-result tiering under context pressure", () => {
/** A stored Read window the way host-core renders one: header, then `N: line`. */
const readWindow = (lines: number): string =>
`[src/app.ts#a1b2]\n${Array.from(
{ length: lines },
(_value, index) => `${index + 1}:${"x".repeat(200)}`,
).join("\n")}`;

const readCall = (id: string, path: string) => ({
role: "assistant",
content: [{ type: "toolCall", id, name: "Read", arguments: { file_path: path } }],
});
const readResult = (id: string, text: string) => ({
role: "toolResult",
toolCallId: id,
toolName: "Read",
content: [{ type: "text", text }],
});
const budget = (tokens: number) => ({
tokens,
hardLimit: 224_000,
requestHeadroom: 32_000,
keepRecentTokens: 44_800,
});

/**
* An old row, then nine newer file-touching calls. The working set is the
* newest eight paths, so `src/old.ts` is outside it and its row is eligible
* while its siblings are not.
*/
const viewWithOldRead = () =>
[
readCall("call-old", "src/old.ts"),
readResult("call-old", readWindow(200)),
...Array.from({ length: 9 }, (_value, index) => [
readCall(`call-${index}`, `src/f${index}.ts`),
readResult(`call-${index}`, "x".repeat(50)),
]).flat(),
] as never[];

it("leaves the view untouched below the pressure gate", async () => {
const runtime = createRuntime();
vi.spyOn(runtime as any, "contextBudget").mockReturnValue(budget(10_000));
const view = viewWithOldRead();

expect((runtime as any).narrowToolResultsUnderPressure(view)).toBe(view);
await runtime.dispose();
});

it("tiers an old Read result once the context is under pressure", async () => {
const runtime = createRuntime();
vi.spyOn(runtime as any, "contextBudget").mockReturnValue(budget(160_000));
const view = viewWithOldRead();
const narrowed = (runtime as any).narrowToolResultsUnderPressure(view);

expect(narrowed).not.toBe(view);
const first = (narrowed[1] as any).content[0].text as string;
expect(first).toContain("[tool result narrowed:");
expect(first).toContain('Continue with Read path="src/old.ts" offset=');
// The rows the working set protects keep their identity.
expect(narrowed[3]).toBe(view[3]);
await runtime.dispose();
});

it("tiers a spilled shell result without consulting the working set", async () => {
const runtime = createRuntime();
vi.spyOn(runtime as any, "contextBudget").mockReturnValue(budget(160_000));
const spill =
"y".repeat(30_000) +
"\n[truncated: kept the first 4000 of 51234 lines; limit 4000 lines / 96KB. " +
"Full output saved to C:\\scratch\\s1\\tool-output\\bash-1-1.log — " +
"Grep it, or Read it with offset/limit.]";
const view = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "Bash", arguments: { command: "npm test" } }],
},
{
role: "toolResult",
toolCallId: "call-1",
toolName: "Bash",
content: [{ type: "text", text: spill }],
},
...Array.from({ length: 6 }, () => ({
role: "toolResult",
toolCallId: "recent",
toolName: "Bash",
content: [{ type: "text", text: "x".repeat(50) }],
})),
] as never[];
const narrowed = (runtime as any).narrowToolResultsUnderPressure(view);

expect(narrowed).not.toBe(view);
const first = (narrowed[1] as any).content[0].text as string;
expect(first).toContain("tool-output\\bash-1-1.log");
expect(first).toContain("Read it with offset/limit, or Grep it");
await runtime.dispose();
});
});
40 changes: 38 additions & 2 deletions packages/agent-runtime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ import {
ContextEstimateCalibration,
type ContextCalibration,
} from "./context-calibration.js";
import {
narrowToolResults,
TOOL_RESULT_TIER_PRESSURE,
workingSetPathsFrom,
} from "./tool-result-tier.js";

import { rebuildNodeNetworkTransport } from "./node-proxy.js";
import {
Expand Down Expand Up @@ -1925,9 +1930,12 @@ Delegation rules:
// The provider's rule that a tool-call id is unique is enforced here, on
// the last view before the wire: the request is the only place it can be
// guaranteed for both a rebuilt context and one that grew in this process.
// Old tool results are tiered in the same place: that pass is a no-op below
// its pressure gate and returns the same array when nothing qualified, so
// an ordinary request is byte-identical to what it was before it existed.
convertToLlm: (messages) =>
alignRetainedReasoningIdentity(
convertToLlm(this.dropDuplicateToolCalls(messages)),
convertToLlm(this.narrowToolResultsUnderPressure(this.dropDuplicateToolCalls(messages))),
this.reasoningReplayIdentity(),
),
prepareNextTurnWithContext: (context, signal) =>
Expand Down Expand Up @@ -5828,11 +5836,39 @@ Delegation rules:
additionalMessages: AgentMessage[] = [],
): boolean {
const context = this.liveSessionContext();
const messages = [...context.messages, ...additionalMessages];
// Decide on the view the request would actually carry: narrowing old tool
// results shrinks it, and reading the un-narrowed projection here would
// make the saving invisible to this check, so compaction would fire while
// real room remained.
const messages = [
...this.narrowToolResultsUnderPressure(context.messages),
...additionalMessages,
];
const budget = this.contextBudget(messages);
return this.compactionEnabled && budget.tokens >= budget.hardLimit;
}

/**
* Shorten old tool results in an outgoing view, but only once the context is
* actually under pressure — below the gate the view comes back untouched, so
* the common case pays nothing. The full text of every narrowed result stays
* reachable through the pointer the pass embeds (see `tool-result-tier.ts`).
*/
private narrowToolResultsUnderPressure(messages: AgentMessage[]): AgentMessage[] {
if (messages.length === 0) return messages;
const budget = this.contextBudget(messages);
if (budget.tokens < budget.hardLimit * TOOL_RESULT_TIER_PRESSURE) {
return messages;
}
// The newest file-touching calls define what the task is about right now; a
// result for a file the session re-opened stays whole even when its row is
// old. `[keep]` immunity, the excluded tool families and the clear-at-least
// floor come from the module's own defaults.
return narrowToolResults(messages, {
workingSetPaths: workingSetPathsFrom(messages),
}).messages;
}

private retainedUserMessageBudget(budget: ContextBudget): number {
return retainedUserMessageBudget(budget);
}
Expand Down
Loading
Loading