Skip to content

harness: truncation marker can push output past its documented char budget #655

Description

@teyrebaz33

Summary
Two truncation helpers in packages/harness write the shared PAYLOAD_TRUNCATION_MARKER format and have the same bug: they slice content to exactly the configured budget, then append the truncation marker AFTER the slice — so the actual output is budget + marker.length characters, not budget.

  • truncateForPayload (core/collector/normalizer.ts), used for toolInput/toolResponseSummary in the collector's hook normalization.
  • clip (core/record-archive.ts), used by compactSessionRecord for prompt/assistantText/tool input/responseSummary. This one also documents an idempotency guarantee ("a second pass finds a body already at the cap and returns it untouched"); that guarantee itself holds (verified with a 56-case brute-force sweep — 0 idempotency violations in the current code), but it converges on a value that's already over budget, since the bug is in the first pass's arithmetic, not the unwrap/fold logic.

Reproduction

truncateForPayload("y".repeat(200), 50).length
// -> more than 50

Impact
Low in practice today: the aggregate RECORD_MAX_BYTES cap in compactSessionRecord is enforced separately, via a real Buffer.byteLength() measurement taken after clipping, and drops whole turns oldest-first until the total fits — so it doesn't trust clip()'s per-field promise, and the real storage/transport cap is safe regardless. But both functions directly contradict their own documented "bounded" behavior, and any future caller relying on the documented per-field bound (or a tightened per-field budget) would be silently wrong.

Suggested fix
Since the marker's own length depends on the dropped-char count, which depends on where the text is sliced, which depends on the marker's length, this needs a small converging loop (shrink the slice point until slice + marker fits within budget) in both functions, plus a hard backstop for degenerate tiny budgets. I have a fix ready, verified with a 56-case brute-force sweep checking both the bound and the idempotency property together. Happy to open a PR.

Metadata

Metadata

Assignees

No one assigned

    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