fix: comprehensive tool argument normalization hardening
- Remove all { raw: ... } returns that caused InputValidationError with
z.strictObject schemas — return {} instead for clean Zod errors
- Extend normalizeAtStop buffering to all mapped tools (Read, Write,
Edit, Glob, Grep) so streaming paths also get normalized
- Make repairPossiblyTruncatedObjectJson generic — repair any valid
JSON object, not just ones with a command field
- Export hasToolFieldMapping for streaming normalizeAtStop decision
- Skip normalization on finish_reason: length to preserve raw truncated
buffer
- Update all test expectations to match new behavior
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -744,7 +744,7 @@ test('keeps terminal empty Bash tool arguments invalid in non-streaming response
|
|||||||
type: 'tool_use',
|
type: 'tool_use',
|
||||||
id: 'function-call-1',
|
id: 'function-call-1',
|
||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
input: { raw: '' },
|
input: {},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -1094,7 +1094,7 @@ test('keeps terminal whitespace-only Bash arguments invalid in streaming respons
|
|||||||
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
expect(normalizedInput).toBe('{"raw":" "}')
|
expect(normalizedInput).toBe('{}')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('normalizes streaming Bash arguments that begin with bracket syntax', async () => {
|
test('normalizes streaming Bash arguments that begin with bracket syntax', async () => {
|
||||||
@@ -1423,7 +1423,7 @@ test('does not normalize incomplete streamed Bash commands when finish_reason is
|
|||||||
expect(streamedInput).toBe('rg --fi')
|
expect(streamedInput).toBe('rg --fi')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does not repair truncated Bash objects that do not contain command', async () => {
|
test('repairs truncated JSON objects even without command field', async () => {
|
||||||
globalThis.fetch = (async (_input, _init) => {
|
globalThis.fetch = (async (_input, _init) => {
|
||||||
const chunks = makeStreamChunks([
|
const chunks = makeStreamChunks([
|
||||||
{
|
{
|
||||||
@@ -1496,7 +1496,7 @@ test('does not repair truncated Bash objects that do not contain command', async
|
|||||||
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
expect(streamedInput).toBe('{"raw":"{\\"cwd\\":\\"/tmp\\""}')
|
expect(streamedInput).toBe('{"cwd":"/tmp"}')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('preserves raw input for unknown plain string tool arguments', async () => {
|
test('preserves raw input for unknown plain string tool arguments', async () => {
|
||||||
@@ -1554,7 +1554,7 @@ test('preserves raw input for unknown plain string tool arguments', async () =>
|
|||||||
type: 'tool_use',
|
type: 'tool_use',
|
||||||
id: 'function-call-1',
|
id: 'function-call-1',
|
||||||
name: 'UnknownTool',
|
name: 'UnknownTool',
|
||||||
input: { raw: 'pwd' },
|
input: {},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
|
|||||||
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
||||||
import {
|
import {
|
||||||
normalizeToolArguments,
|
normalizeToolArguments,
|
||||||
|
hasToolFieldMapping,
|
||||||
} from './toolArgumentNormalization.js'
|
} from './toolArgumentNormalization.js'
|
||||||
|
|
||||||
type SecretValueSource = Partial<{
|
type SecretValueSource = Partial<{
|
||||||
@@ -486,7 +487,7 @@ const JSON_REPAIR_SUFFIXES = [
|
|||||||
function repairPossiblyTruncatedObjectJson(raw: string): string | null {
|
function repairPossiblyTruncatedObjectJson(raw: string): string | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && typeof (parsed as Record<string, unknown>).command === 'string'
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
? raw
|
? raw
|
||||||
: null
|
: null
|
||||||
} catch {
|
} catch {
|
||||||
@@ -494,12 +495,7 @@ function repairPossiblyTruncatedObjectJson(raw: string): string | null {
|
|||||||
try {
|
try {
|
||||||
const repaired = raw + combo
|
const repaired = raw + combo
|
||||||
const parsed = JSON.parse(repaired)
|
const parsed = JSON.parse(repaired)
|
||||||
if (
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
parsed &&
|
|
||||||
typeof parsed === 'object' &&
|
|
||||||
!Array.isArray(parsed) &&
|
|
||||||
typeof (parsed as Record<string, unknown>).command === 'string'
|
|
||||||
) {
|
|
||||||
return repaired
|
return repaired
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -619,7 +615,7 @@ async function* openaiStreamToAnthropic(
|
|||||||
|
|
||||||
const toolBlockIndex = contentBlockIndex
|
const toolBlockIndex = contentBlockIndex
|
||||||
const initialArguments = tc.function.arguments ?? ''
|
const initialArguments = tc.function.arguments ?? ''
|
||||||
const normalizeAtStop = tc.function.name === 'Bash'
|
const normalizeAtStop = hasToolFieldMapping(tc.function.name)
|
||||||
activeToolCalls.set(tc.index, {
|
activeToolCalls.set(tc.index, {
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
|
|||||||
@@ -19,20 +19,18 @@ describe('normalizeToolArguments', () => {
|
|||||||
).toEqual({ command: 'echo hi' })
|
).toEqual({ command: 'echo hi' })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns { raw } for blank string', () => {
|
test('returns empty object for blank string', () => {
|
||||||
expect(normalizeToolArguments('Bash', '')).toEqual({ raw: '' })
|
expect(normalizeToolArguments('Bash', '')).toEqual({})
|
||||||
expect(normalizeToolArguments('Bash', ' ')).toEqual({ raw: ' ' })
|
expect(normalizeToolArguments('Bash', ' ')).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns { raw } for JSON-encoded blank string', () => {
|
test('returns parsed blank for JSON-encoded blank string', () => {
|
||||||
expect(normalizeToolArguments('Bash', '""')).toEqual({ raw: '' })
|
expect(normalizeToolArguments('Bash', '""')).toEqual('')
|
||||||
expect(normalizeToolArguments('Bash', '" "')).toEqual({ raw: ' ' })
|
expect(normalizeToolArguments('Bash', '" "')).toEqual(' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns { raw } for likely structured object literal that fails parse', () => {
|
test('returns empty object for malformed structured object literal', () => {
|
||||||
expect(normalizeToolArguments('Bash', '{ "command": "pwd"')).toEqual({
|
expect(normalizeToolArguments('Bash', '{ "command": "pwd"')).toEqual({})
|
||||||
raw: '{ "command": "pwd"',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@@ -40,9 +38,9 @@ describe('normalizeToolArguments', () => {
|
|||||||
["{'command':'pwd'}"],
|
["{'command':'pwd'}"],
|
||||||
['{command: pwd}'],
|
['{command: pwd}'],
|
||||||
])(
|
])(
|
||||||
'returns { raw } for malformed object-shaped string %s (does not wrap into command)',
|
'returns empty object for malformed object-shaped string %s (does not wrap into command)',
|
||||||
(input) => {
|
(input) => {
|
||||||
expect(normalizeToolArguments('Bash', input)).toEqual({ raw: input })
|
expect(normalizeToolArguments('Bash', input)).toEqual({})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -157,10 +155,8 @@ describe('normalizeToolArguments', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('unknown tools', () => {
|
describe('unknown tools', () => {
|
||||||
test('returns { raw } for plain string', () => {
|
test('returns empty object for plain string (no known field mapping)', () => {
|
||||||
expect(normalizeToolArguments('UnknownTool', 'some value')).toEqual({
|
expect(normalizeToolArguments('UnknownTool', 'some value')).toEqual({})
|
||||||
raw: 'some value',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('passes through structured JSON object', () => {
|
test('passes through structured JSON object', () => {
|
||||||
@@ -175,7 +171,7 @@ describe('normalizeToolArguments', () => {
|
|||||||
expect(normalizeToolArguments('UnknownTool', '[]')).toEqual([])
|
expect(normalizeToolArguments('UnknownTool', '[]')).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns JSON-encoded string as parsed string for unknown tools', () => {
|
test('returns parsed string for JSON-encoded string on unknown tools', () => {
|
||||||
expect(normalizeToolArguments('UnknownTool', '"hello"')).toEqual(
|
expect(normalizeToolArguments('UnknownTool', '"hello"')).toEqual(
|
||||||
'hello',
|
'hello',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ function getPlainStringToolArgumentField(toolName: string): string | null {
|
|||||||
return STRING_ARGUMENT_TOOL_FIELDS[toolName] ?? null
|
return STRING_ARGUMENT_TOOL_FIELDS[toolName] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasToolFieldMapping(toolName: string): boolean {
|
||||||
|
return toolName in STRING_ARGUMENT_TOOL_FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
function wrapPlainStringToolArguments(
|
function wrapPlainStringToolArguments(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
value: string,
|
value: string,
|
||||||
@@ -46,25 +50,20 @@ export function normalizeToolArguments(
|
|||||||
if (isRecord(parsed)) {
|
if (isRecord(parsed)) {
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
if (toolName === 'Bash') {
|
// Parsed as a non-object JSON value (string, number, boolean, null, array)
|
||||||
if (typeof parsed === 'string') {
|
if (typeof parsed === 'string' && !isBlankString(parsed)) {
|
||||||
if (isBlankString(parsed)) {
|
|
||||||
return { raw: parsed }
|
|
||||||
}
|
|
||||||
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
if (typeof parsed === 'string') {
|
|
||||||
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
|
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
|
||||||
}
|
}
|
||||||
|
// For blank strings, booleans, null, arrays — pass through as-is
|
||||||
|
// and let Zod schema validation produce a meaningful error
|
||||||
return parsed
|
return parsed
|
||||||
} catch {
|
} catch {
|
||||||
|
// rawArguments is not valid JSON — treat as a plain string
|
||||||
if (isBlankString(rawArguments) || isLikelyStructuredObjectLiteral(rawArguments)) {
|
if (isBlankString(rawArguments) || isLikelyStructuredObjectLiteral(rawArguments)) {
|
||||||
return { raw: rawArguments }
|
// Blank or looks like a malformed object literal — don't wrap into
|
||||||
|
// a tool field to avoid turning garbage into executable input
|
||||||
|
return {}
|
||||||
}
|
}
|
||||||
return (
|
return wrapPlainStringToolArguments(toolName, rawArguments) ?? {}
|
||||||
wrapPlainStringToolArguments(toolName, rawArguments) ?? { raw: rawArguments }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user