Skip to content

bug(runtime): history compaction keeps earlier oversized tool results in a multi-result tool message #4454

Description

@liuxiaocs7

What happened

fitHistoryCompactMessages (packages/runtime/src/history-compact-input-fit.ts) bounds a compaction request by replacing old, oversized tool-result payloads with a placeholder, keeping every call/result pair intact.

When a single tool message carries more than one tool-result part — which happens whenever the model issues parallel tool calls, so the provider batches their results into one message — only the last replaced part survives. Each replacement copies from the original message.content:

const content = [...message.content]; // line 65 — always the ORIGINAL parts
content[partIndex] = replacement;
bounded[messageIndex] = { ...message, content };

so an earlier part's placeholder is overwritten and its payload stays at full size in the returned history — even though the budget accounting (estimatedChars) has already been decremented for it.

Result: the function can return a history that still far exceeds maxInputEstimatedTokens while treating it as "fit", so the downstream compaction / recap model call is sent over its intended input budget (risking a provider context-window error). With other sizes the same accounting mismatch instead throws input_too_large for input that would actually fit. Either way the helper fails at exactly the case it exists to handle.

The sibling active-tool-result-prune.ts handles the same multi-part shape correctly by accumulating replacements (nextContent = originalContent.slice(0, index) then push), so multi-result tool messages are a known, supported shape.

How to reproduce

Build first (npm run build), then run this against the compiled function on main:

import { fitHistoryCompactMessages } from './packages/runtime/dist/history-compact-input-fit.js';
import { stableJsonLength } from './packages/runtime/dist/context-budget-helpers.js';

const big = 'raw-tool-output-'.repeat(1024); // ~16 KB each
const messages = [
  { role: 'assistant', content: [
    { type: 'tool-call', toolCallId: 'call-1', toolName: 'shell', input: { command: 'a' } },
    { type: 'tool-call', toolCallId: 'call-2', toolName: 'shell', input: { command: 'b' } },
  ]},
  { role: 'tool', content: [ // provider batches BOTH parallel results into one message
    { type: 'tool-result', toolCallId: 'call-1', toolName: 'shell', output: { type: 'text', value: big } },
    { type: 'tool-result', toolCallId: 'call-2', toolName: 'shell', output: { type: 'text', value: big } },
  ]},
  { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
];

const bounded = fitHistoryCompactMessages(messages, { maxInputEstimatedTokens: 1000, charsPerToken: 1 });
console.log('returned "fit" chars:', stableJsonLength(bounded), '(budget 1000)');
console.log('full payloads left  :', JSON.stringify(bounded).split(big).length - 1, '(expected 0)');

Observed on main:

returned "fit" chars: 16974 (budget 1000)
full payloads left  : 1 (expected 0)

Expected: both payloads replaced, result within the requested budget.

Environment

  • Maka commit: origin/main 920d7142d
  • OS and version: macOS 15 (arm64)
  • Surface: Runtime
  • Node.js: 24

Logs, screenshots, or additional context

  • Buggy line: packages/runtime/src/history-compact-input-fit.ts:65 copies from the original message.content on every replacement instead of carrying earlier replacements forward.
  • Affected callers: history-compact-summarizer.ts, session-recap.ts, openai-codex-history-compactor.ts.
  • The loop has been unchanged since it was introduced in fix(runtime): bound text history summarizer inputs #3113.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions