fix(coding-agent): compact oversized trailing tool results

Fall back to the latest safe assistant boundary when trailing tool results exceed the retained-token budget, allowing older history to compact before the next provider request.

Closes #9740
This commit is contained in:
Vegard Stikbakke 2026-09-18 13:34:33 +02:00
parent 5901446094
commit 8bdcd4498a
4 changed files with 46 additions and 11 deletions

View file

@ -21,6 +21,7 @@
### Fixed
- Fixed mid-run threshold compaction silently skipping oversized trailing tool results ([#9740](https://github.com/earendil-works/pi/issues/9740)).
- Fixed signal-terminated local shell commands being reported as successful with partial output ([#9577](https://github.com/earendil-works/pi/issues/9577) by [@BrendanJMurphy](https://github.com/BrendanJMurphy)).
- Fixed local clipboard failures reporting success when the terminal ignored the fallback OSC 52 write, and added platform-specific setup guidance when no clipboard backend works ([#9618](https://github.com/earendil-works/pi/issues/9618)).
- Capped agent-level retry backoff at `retry.maxAgentDelayMs` (60s by default) so long retry runs stay responsive during prolonged transient outages ([#8826](https://github.com/earendil-works/pi/issues/8826)).

View file

@ -442,13 +442,10 @@ export function findCutPoint(
// Check if we've exceeded the budget
if (accumulatedTokens >= keepRecentTokens) {
// Find the closest valid cut point at or after this entry
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) {
cutIndex = cutPoints[c];
break;
}
}
// Prefer the closest valid cut point at or after this entry. If trailing
// tool results exceed the budget by themselves, keep their preceding
// assistant tool call instead of falling back to the first message.
cutIndex = cutPoints.find((candidate) => candidate >= i) ?? cutPoints[cutPoints.length - 1];
break;
}
}

View file

@ -373,6 +373,42 @@ describe("findCutPoint", () => {
expect(customFitsBudget.isSplitTurn).toBe(false);
expect(customFitsBudget.turnStartIndex).toBe(-1);
});
// Regression test for #9740.
it("should fall back to the latest valid cut point before oversized trailing tool results", () => {
const oldUser = createMessageEntry(createUserMessage("old history"));
const oldAssistant = createMessageEntry(createAssistantMessage("old answer"));
const currentUser = createMessageEntry(createUserMessage("read the large file"));
const toolCall = createMessageEntry({
...createAssistantMessage(""),
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "big.txt" } }],
stopReason: "toolUse",
});
const toolResult = createMessageEntry({
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "x".repeat(8000) }],
isError: false,
timestamp: Date.now(),
});
const entries = [oldUser, oldAssistant, currentUser, toolCall, toolResult];
const result = findCutPoint(entries, 0, entries.length, 1000);
expect(result).toEqual({
firstKeptEntryIndex: 3,
turnStartIndex: 2,
isSplitTurn: true,
});
const preparation = prepareCompaction(entries, {
...DEFAULT_COMPACTION_SETTINGS,
keepRecentTokens: 1000,
});
expect(preparation?.firstKeptEntryId).toBe(toolCall.id);
expect(preparation?.messagesToSummarize).toEqual([oldUser.message, oldAssistant.message]);
expect(preparation?.turnPrefixMessages).toEqual([currentUser.message]);
});
});
describe("buildSessionContext", () => {

View file

@ -471,10 +471,11 @@ describe("AgentSession compaction characterization", () => {
});
// Regression coverage for #8133: model overrides must also apply between assistant turns.
// Regression coverage for #9740: an oversized trailing tool result must still produce a cut point.
it.each([false, true])(
"compacts after a tool result in the same run (model override: %s)",
"compacts after an oversized tool result in the same run (model override: %s)",
async (modelOverride) => {
const toolResult = `large-tool-result:${"x".repeat(6800)}`;
const toolResult = `large-tool-result:${"x".repeat(8000)}`;
const largeTool: AgentTool = {
name: "large_result",
label: "Large result",
@ -532,8 +533,8 @@ describe("AgentSession compaction characterization", () => {
const agentStartsBefore = harness.eventsOfType("agent_start").length;
await harness.session.prompt("run the large tool");
expect(order).toEqual(["compaction", "provider"]);
expect(observedSettings).toEqual([{ enabled: true, reserveTokens: 400, keepRecentTokens: 1750 }]);
expect(order.slice(0, 2)).toEqual(["compaction", "provider"]);
expect(observedSettings[0]).toEqual({ enabled: true, reserveTokens: 400, keepRecentTokens: 1750 });
expect(harness.eventsOfType("agent_start")).toHaveLength(agentStartsBefore + 1);
expect(harness.eventsOfType("compaction_start").at(-1)).toEqual({
type: "compaction_start",