fix: trim persisted tool results and sanitize MCP schemas

This commit is contained in:
sooth
2026-04-02 07:49:58 -04:00
parent 9f48bb4431
commit 5c4469fe81
10 changed files with 449 additions and 26 deletions

View File

@@ -0,0 +1,54 @@
import { expect, test } from 'bun:test'
import { createUserMessage } from './messages.ts'
import { applyToolResultReplacementsToMessages } from './toolResultStorage.ts'
test('applyToolResultReplacementsToMessages replaces matching tool results and preserves unrelated messages', () => {
const unrelated = createUserMessage({ content: 'keep me' })
const oversizedResult = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'very large tool output',
is_error: false,
},
],
})
const messages = [unrelated, oversizedResult]
const replacement =
'<persisted-output>\nOutput too large. Preview\n</persisted-output>'
const next = applyToolResultReplacementsToMessages(
messages,
new Map([['tool-1', replacement]]),
)
expect(next).not.toBe(messages)
expect(next[0]).toBe(unrelated)
expect(next[1]).not.toBe(oversizedResult)
expect((next[1]!.message.content as Array<{ content: string }>)[0]!.content).toBe(
replacement,
)
})
test('applyToolResultReplacementsToMessages is idempotent when messages are already hydrated', () => {
const hydrated = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'tool-1',
content: '<persisted-output>\nPreview\n</persisted-output>',
is_error: false,
},
],
})
const messages = [hydrated]
const next = applyToolResultReplacementsToMessages(
messages,
new Map([['tool-1', '<persisted-output>\nPreview\n</persisted-output>']]),
)
expect(next).toBe(messages)
})

View File

@@ -698,17 +698,22 @@ function selectFreshToReplace(
*/
function replaceToolResultContents(
messages: Message[],
replacementMap: Map<string, string>,
replacementMap: ReadonlyMap<string, string>,
): Message[] {
return messages.map(message => {
let changed = false
const nextMessages = messages.map(message => {
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
return message
}
const content = message.message.content
const needsReplace = content.some(
b => b.type === 'tool_result' && replacementMap.has(b.tool_use_id),
b =>
b.type === 'tool_result' &&
replacementMap.has(b.tool_use_id) &&
b.content !== replacementMap.get(b.tool_use_id),
)
if (!needsReplace) return message
changed = true
return {
...message,
message: {
@@ -716,13 +721,28 @@ function replaceToolResultContents(
content: content.map(block => {
if (block.type !== 'tool_result') return block
const replacement = replacementMap.get(block.tool_use_id)
return replacement === undefined
return replacement === undefined || block.content === replacement
? block
: { ...block, content: replacement }
}),
},
}
})
return changed ? nextMessages : messages
}
/**
* Mirror already-known tool-result replacements back into an in-memory
* transcript. Used by the interactive REPL so once a large result has been
* persisted/replaced for model use, the original oversized string can be
* dropped from live session state as well.
*/
export function applyToolResultReplacementsToMessages(
messages: Message[],
replacements: ReadonlyMap<string, string>,
): Message[] {
if (replacements.size === 0) return messages
return replaceToolResultContents(messages, replacements)
}
async function buildReplacement(
@@ -926,13 +946,16 @@ export async function applyToolResultBudget(
state: ContentReplacementState | undefined,
writeToTranscript?: (records: ToolResultReplacementRecord[]) => void,
skipToolNames?: ReadonlySet<string>,
): Promise<Message[]> {
if (!state) return messages
): Promise<{
messages: Message[]
newlyReplaced: ToolResultReplacementRecord[]
}> {
if (!state) return { messages, newlyReplaced: [] }
const result = await enforceToolResultBudget(messages, state, skipToolNames)
if (result.newlyReplaced.length > 0) {
writeToTranscript?.(result.newlyReplaced)
}
return result.messages
return result
}
/**