Skip to content

Commit 4fdcacd

Browse files
committed
fix(agents): preserve latest read output during compaction
1 parent 92fa7ad commit 4fdcacd

5 files changed

Lines changed: 45 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ Docs: https://docs.openclaw.ai
130130
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
131131
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
132132
- Memory: keep `memory-core` builtin embedding registration on the already-registered path so selecting `memory-core` no longer recurses through plugin discovery and crashes during startup. (#61402) Thanks @ngutman.
133-
- Agents/tool results: keep larger `read` outputs visible on big-window models by raising the live tool-result ceiling instead of compacting normal file reads right after the first section. Thanks @vincentkoc.
133+
- Agents/tool results: preserve the latest `read` output during tool-result context compaction so fresh file reads stop getting replaced by compacted stubs when older tool output can absorb the overflow budget. Thanks @vincentkoc.
134134
- Memory/QMD: prefer modern `qmd collection add --glob`, accept newer single-line JSON hit metadata while keeping legacy line fields, refresh QMD docs/doctor install guidance and model-override guidance, and keep older QMD releases working. Thanks @vincentkoc.
135135
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) Thanks @Ted-developer and @hyojin.
136136
- MS Teams: replace the deprecated Teams SDK HttpPlugin stub with `httpServerAdapter` so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.

src/agents/pi-embedded-runner/tool-result-context-guard.test.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ function expectReadableToolSlice(text: string, prefix: string) {
110110
).toBe(true);
111111
}
112112

113+
function expectCompactedOrPlaceholder(text: string, prefix: string) {
114+
if (text === PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER) {
115+
return;
116+
}
117+
expectReadableCompaction(text, prefix);
118+
}
119+
113120
describe("installToolResultContextGuard", () => {
114121
it("returns a cloned guarded context so original tool output stays visible", async () => {
115122
const agent = makeGuardableAgent();
@@ -124,7 +131,7 @@ describe("installToolResultContextGuard", () => {
124131
expect(getToolResultText(contextForNextCall[2])).toBe("y".repeat(1_000));
125132
});
126133

127-
it("keeps readable slices of overflowing tool results before using a placeholder", async () => {
134+
it("keeps at least one readable older slice before falling back to a placeholder", async () => {
128135
const agent = makeGuardableAgent();
129136

130137
installToolResultContextGuard({
@@ -149,11 +156,13 @@ describe("installToolResultContextGuard", () => {
149156
const third = getToolResultText(transformed[3]);
150157

151158
expectReadableCompaction(first, "a");
152-
expectReadableCompaction(second, "b");
153-
expect(third).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
159+
expectReadableCompaction(third, "c");
160+
expect(
161+
second === "b".repeat(800) || second === PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER,
162+
).toBe(true);
154163
});
155164

156-
it("survives repeated large tool results by compacting the newest output each turn", async () => {
165+
it("keeps the newest large tool result visible when an older one can absorb overflow", async () => {
157166
const agent = makeGuardableAgent();
158167

159168
installToolResultContextGuard({
@@ -175,11 +184,10 @@ describe("installToolResultContextGuard", () => {
175184
.filter((msg) => msg.role === "toolResult")
176185
.map((msg) => getToolResultText(msg as AgentMessage));
177186

178-
// Large outputs are capped per-tool before aggregate compaction kicks in.
179-
expect(toolResultTexts[0]?.length).toBe(50_000);
180187
expect(toolResultTexts[0]).toContain(CONTEXT_LIMIT_TRUNCATION_NOTICE);
181-
expectReadableCompaction(toolResultTexts[3] ?? "", "4");
182-
expect(toolResultTexts[3]).not.toContain(CONTEXT_LIMIT_TRUNCATION_NOTICE);
188+
expectReadableCompaction(toolResultTexts[1] ?? "", "2");
189+
expectReadableCompaction(toolResultTexts[2] ?? "", "3");
190+
expectReadableToolSlice(toolResultTexts[3] ?? "", "4");
183191
});
184192

185193
it("truncates an individually oversized tool result with a context-limit notice", async () => {
@@ -202,7 +210,7 @@ describe("installToolResultContextGuard", () => {
202210
expect(newResultText).toContain(CONTEXT_LIMIT_TRUNCATION_NOTICE);
203211
});
204212

205-
it("keeps compacting newest-first until overflow clears, reaching older tool results when needed", async () => {
213+
it("falls back to compacting the newest tool result when older ones are insufficient", async () => {
206214
const agent = makeGuardableAgent();
207215

208216
installToolResultContextGuard({
@@ -220,8 +228,8 @@ describe("installToolResultContextGuard", () => {
220228
contextForNextCall,
221229
new AbortController().signal,
222230
)) as AgentMessage[];
223-
expectReadableCompaction(getToolResultText(transformed[1]), "x");
224-
expect(getToolResultText(transformed[2])).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
231+
expectCompactedOrPlaceholder(getToolResultText(transformed[1]), "x");
232+
expectCompactedOrPlaceholder(getToolResultText(transformed[2]), "y");
225233
});
226234

227235
it("wraps an existing transformContext and guards the transformed output", async () => {

src/agents/pi-embedded-runner/tool-result-context-guard.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ function compactToPlaceholderInPlace(params: {
145145
}
146146

147147
let reduced = 0;
148-
for (let i = messages.length - 1; i >= 0; i--) {
148+
for (const i of resolveToolResultCompactionOrder(messages)) {
149149
const msg = messages[i];
150150
if (!isToolResultMessage(msg)) {
151151
continue;
@@ -215,11 +215,10 @@ function compactExistingToolResultsInPlace(params: {
215215
}
216216

217217
let reduced = 0;
218-
// Compact newest-first so more of the cached prefix survives: rewriting
219-
// messages[k] for small k invalidates the provider prompt cache from that point onward.
220-
// Keep a truncated slice of newer tool output before falling back to a
221-
// full placeholder so recent, user-visible results remain readable when possible.
222-
for (let i = messages.length - 1; i >= 0; i--) {
218+
// Keep the most recent tool result visible as long as older tool outputs can
219+
// absorb the overflow. Among older tool results, compact newest-first so we
220+
// still preserve as much of the cached prefix as possible.
221+
for (const i of resolveToolResultCompactionOrder(messages)) {
223222
const msg = messages[i];
224223
if (!isToolResultMessage(msg)) {
225224
continue;
@@ -264,6 +263,21 @@ function compactExistingToolResultsInPlace(params: {
264263
return reduced;
265264
}
266265

266+
function resolveToolResultCompactionOrder(messages: AgentMessage[]): number[] {
267+
const toolResultIndexes: number[] = [];
268+
for (let i = 0; i < messages.length; i += 1) {
269+
if (isToolResultMessage(messages[i])) {
270+
toolResultIndexes.push(i);
271+
}
272+
}
273+
if (toolResultIndexes.length <= 1) {
274+
return toolResultIndexes;
275+
}
276+
const newestIndex = toolResultIndexes[toolResultIndexes.length - 1];
277+
const olderIndexes = toolResultIndexes.slice(0, -1).toReversed();
278+
return [...olderIndexes, newestIndex];
279+
}
280+
267281
function cloneMessagesForGuard(messages: AgentMessage[]): AgentMessage[] {
268282
return messages.map(
269283
(msg) => ({ ...(msg as unknown as Record<string, unknown>) }) as unknown as AgentMessage,
@@ -334,7 +348,7 @@ function enforceToolResultContextBudgetInPlace(params: {
334348
return;
335349
}
336350

337-
// Compact newest tool outputs first so more of the cached prefix survives;
351+
// Prefer compacting older tool outputs before sacrificing the newest one;
338352
// stop once the context is back under budget.
339353
compactExistingToolResultsInPlace({
340354
messages,

src/agents/pi-embedded-runner/tool-result-truncation.test.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ describe("calculateMaxToolResultChars", () => {
199199
});
200200

201201
it("exports the live cap through both constant names", () => {
202-
expect(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS).toBe(120_000);
202+
expect(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS).toBe(40_000);
203203
expect(HARD_MAX_TOOL_RESULT_CHARS).toBe(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS);
204204
});
205205

@@ -212,20 +212,6 @@ describe("calculateMaxToolResultChars", () => {
212212
const result = calculateMaxToolResultChars(128_000);
213213
expect(result).toBe(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS);
214214
});
215-
216-
it("keeps moderately large reads intact on 128K contexts", () => {
217-
const messages: AgentMessage[] = [
218-
makeUserMessage("hello"),
219-
makeAssistantMessage("reading changelog"),
220-
makeToolResult("x".repeat(60_000)),
221-
];
222-
const { messages: result, truncatedCount } = truncateOversizedToolResultsInMessages(
223-
messages,
224-
128_000,
225-
);
226-
expect(truncatedCount).toBe(0);
227-
expect(result).toEqual(messages);
228-
});
229215
});
230216

231217
describe("isOversizedToolResult", () => {

src/agents/pi-embedded-runner/tool-result-truncation.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,10 @@ const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3;
1717
* Default hard cap for a single live tool result text block.
1818
*
1919
* Pi already truncates tool results aggressively when serializing old history
20-
* for compaction summaries. For the live request path we keep a larger slice so
21-
* the model can still act on recent tool output, especially large read results
22-
* on modern 128K+ context models, while still keeping a bounded request-local
23-
* ceiling that cannot dominate the next turn.
20+
* for compaction summaries. For the live request path we still keep a bounded
21+
* request-local ceiling so oversized tool output cannot dominate the next turn.
2422
*/
25-
export const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 120_000;
23+
export const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 40_000;
2624

2725
/**
2826
* Backwards-compatible alias for older call sites/tests.

0 commit comments

Comments
 (0)