( messages: Message[], state: ContentReplacementState, skipToolNames: ReadonlySet<string> = new Set(), )
| 767 | * Caller persists these to the transcript for resume reconstruction. |
| 768 | */ |
| 769 | export async function enforceToolResultBudget( |
| 770 | messages: Message[], |
| 771 | state: ContentReplacementState, |
| 772 | skipToolNames: ReadonlySet<string> = new Set(), |
| 773 | ): Promise<{ |
| 774 | messages: Message[] |
| 775 | newlyReplaced: ToolResultReplacementRecord[] |
| 776 | }> { |
| 777 | const candidatesByMessage = collectCandidatesByMessage(messages) |
| 778 | const nameByToolUseId = |
| 779 | skipToolNames.size > 0 ? buildToolNameMap(messages) : undefined |
| 780 | const shouldSkip = (id: string): boolean => |
| 781 | nameByToolUseId !== undefined && |
| 782 | skipToolNames.has(nameByToolUseId.get(id) ?? '') |
| 783 | // Resolve once per call. A mid-session flag change only affects FRESH |
| 784 | // messages (prior decisions are frozen via seenIds/replacements), so |
| 785 | // prompt cache for already-seen content is preserved regardless. |
| 786 | const limit = getPerMessageBudgetLimit() |
| 787 | |
| 788 | // Walk each API-level message group independently. For previously-processed messages |
| 789 | // (all IDs in seenIds) this just re-applies cached replacements. For the |
| 790 | // single new message this turn added, it runs the budget check. |
| 791 | const replacementMap = new Map<string, string>() |
| 792 | const toPersist: ToolResultCandidate[] = [] |
| 793 | let reappliedCount = 0 |
| 794 | let messagesOverBudget = 0 |
| 795 | |
| 796 | for (const candidates of candidatesByMessage) { |
| 797 | const { mustReapply, frozen, fresh } = partitionByPriorDecision( |
| 798 | candidates, |
| 799 | state, |
| 800 | ) |
| 801 | |
| 802 | // Re-apply: pure Map lookups. No file I/O, byte-identical, cannot fail. |
| 803 | mustReapply.forEach(c => replacementMap.set(c.toolUseId, c.replacement)) |
| 804 | reappliedCount += mustReapply.length |
| 805 | |
| 806 | // Fresh means this is a new message. Check its per-message budget. |
| 807 | // (A previously-processed message has fresh.length === 0 because all |
| 808 | // its IDs were added to seenIds when first seen.) |
| 809 | if (fresh.length === 0) { |
| 810 | // mustReapply/frozen are already in seenIds from their first pass — |
| 811 | // re-adding is a no-op but keeps the invariant explicit. |
| 812 | candidates.forEach(c => state.seenIds.add(c.toolUseId)) |
| 813 | continue |
| 814 | } |
| 815 | |
| 816 | // Tools with maxResultSizeChars: Infinity (Read) — never persist. |
| 817 | // Mark as seen (frozen) so the decision sticks across turns. They don't |
| 818 | // count toward freshSize; if that lets the group slip under budget and |
| 819 | // the wire message is still large, that's the contract — Read's own |
| 820 | // maxTokens is the bound, not this wrapper. |
| 821 | const skipped = fresh.filter(c => shouldSkip(c.toolUseId)) |
| 822 | skipped.forEach(c => state.seenIds.add(c.toolUseId)) |
| 823 | const eligible = fresh.filter(c => !shouldSkip(c.toolUseId)) |
| 824 | |
| 825 | const frozenSize = frozen.reduce((sum, c) => sum + c.size, 0) |
| 826 | const freshSize = eligible.reduce((sum, c) => sum + c.size, 0) |
no test coverage detected