Skip to content

Commit 596a6fc

Browse files
authored
Repair malformed tool call inputs (#580)
1 parent 0202046 commit 596a6fc

3 files changed

Lines changed: 142 additions & 18 deletions

File tree

.github/workflows/freebuff-e2e.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,20 @@ jobs:
7373
- uses: ./.github/actions/setup-project
7474

7575
- name: Install tmux
76-
run: sudo apt-get update && sudo apt-get install -y tmux
76+
run: |
77+
if command -v tmux >/dev/null 2>&1; then
78+
tmux -V
79+
exit 0
80+
fi
81+
82+
timeout 120s sudo apt-get install -y --no-install-recommends tmux || (
83+
timeout 120s sudo apt-get update \
84+
-o Acquire::Retries=3 \
85+
-o Acquire::http::Timeout=20 \
86+
-o Acquire::https::Timeout=20 &&
87+
timeout 120s sudo apt-get install -y --no-install-recommends tmux
88+
)
89+
tmux -V
7790
7891
- name: Download Freebuff binary
7992
uses: actions/download-artifact@v8

packages/agent-runtime/src/__tests__/tool-validation-error.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,51 @@ describe('tool validation error handling', () => {
174174
}
175175
})
176176

177+
it('should summarize missing replacement fields without implying deletion', () => {
178+
const result = parseRawToolCall({
179+
rawToolCall: {
180+
toolName: 'str_replace',
181+
toolCallId: 'missing-new-tool-call-id',
182+
input: {
183+
path: 'test.ts',
184+
replacements: [
185+
{ old: 'before', new: 'after' },
186+
{ old: 'delete me' },
187+
{ old: 'delete me too' },
188+
],
189+
},
190+
},
191+
})
192+
193+
expect('error' in result).toBe(true)
194+
if ('error' in result) {
195+
expect(result.error).toContain('Missing required replacement fields:')
196+
expect(result.error).toContain('- replacements[1].new')
197+
expect(result.error).toContain('- replacements[2].new')
198+
expect(result.error).toContain(
199+
'If the intent is deletion, set "new": "" explicitly.',
200+
)
201+
expect(result.error).toContain('Raw validation issues:')
202+
}
203+
})
204+
205+
it('should include JSON parse details for incomplete stringified input', () => {
206+
const result = parseRawToolCall({
207+
rawToolCall: {
208+
toolName: 'write_file',
209+
toolCallId: 'incomplete-stringified-tool-call-id',
210+
input:
211+
'{"path": ".agents/deep-thinkers/meta-coordinator.ts", "instructions": "Creates a meta-coordinator"',
212+
},
213+
})
214+
215+
expect('error' in result).toBe(true)
216+
if ('error' in result) {
217+
expect(result.error).toContain('The JSON parser reported:')
218+
expect(result.error).toContain('If the arguments are incomplete')
219+
}
220+
})
221+
177222
it('should emit error event instead of tool result when spawn_agents receives invalid parameters', async () => {
178223
// This simulates what happens when the LLM passes a string instead of an array to spawn_agents
179224
// The error from Anthropic was: "Invalid parameters for spawn_agents: expected array, received string"

packages/agent-runtime/src/tools/tool-executor.ts

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,12 @@ function repairBareStringFieldObject(input: string, toolName: string): unknown {
9494
return { [field]: value }
9595
}
9696

97-
function parseStringifiedToolInput(input: unknown, toolName: string): unknown {
97+
function parseStringifiedToolInput(
98+
input: unknown,
99+
toolName: string,
100+
): { input: unknown; parseError?: string } {
98101
let parsed = input
102+
let parseError: string | undefined
99103

100104
// Some providers/models double-encode tool arguments, for example an input
101105
// value like "\"{\\\"path\\\":\\\"file.ts\\\"}\"". Repeated JSON.parse
@@ -104,27 +108,76 @@ function parseStringifiedToolInput(input: unknown, toolName: string): unknown {
104108
const stringInput = parsed
105109
try {
106110
parsed = JSON.parse(stringInput)
107-
} catch {
111+
parseError = undefined
112+
} catch (error) {
108113
const repaired = repairBareStringFieldObject(stringInput, toolName)
109114
if (repaired !== undefined) {
110115
parsed = repaired
116+
parseError = undefined
117+
} else {
118+
parseError = error instanceof Error ? error.message : String(error)
111119
}
112120
break
113121
}
114122
}
115123

116-
return parsed
124+
return { input: parsed, parseError }
117125
}
118126

119-
function stringInputError(toolName: string, toolCallId: string): ToolCallError {
127+
function stringInputError(
128+
toolName: string,
129+
toolCallId: string,
130+
parseError?: string,
131+
): ToolCallError {
132+
const parseDetails = parseError
133+
? ` The JSON parser reported: ${parseError}. If the arguments are incomplete, re-issue the full object.`
134+
: ''
120135
return {
121136
toolName,
122137
toolCallId,
123138
input: {},
124-
error: `Invalid parameters for ${toolName}: tool arguments were a string, not a JSON object. The runtime tried to parse stringified JSON before validation, but the value was still not a JSON object. Re-issue the tool call as a JSON object with properly escaped string values.`,
139+
error: `Invalid parameters for ${toolName}: tool arguments were a string, not a JSON object. The runtime tried to parse stringified JSON before validation, but the value was still not a JSON object.${parseDetails} Re-issue the tool call as a JSON object with properly escaped string values.`,
125140
}
126141
}
127142

143+
function summarizeMissingReplacementFields(
144+
toolName: string,
145+
issues: Array<{
146+
expected?: unknown
147+
code?: string
148+
path?: PropertyKey[]
149+
message?: string
150+
}>,
151+
): string | undefined {
152+
if (toolName !== 'str_replace' && toolName !== 'propose_str_replace') {
153+
return undefined
154+
}
155+
156+
const missingFields = issues.flatMap((issue) => {
157+
const [root, index, field] = issue.path ?? []
158+
const isMissingReplacementString =
159+
issue.code === 'invalid_type' &&
160+
issue.expected === 'string' &&
161+
issue.message?.includes('received undefined') &&
162+
root === 'replacements' &&
163+
typeof index === 'number' &&
164+
(field === 'old' || field === 'new')
165+
166+
return isMissingReplacementString ? [`replacements[${index}].${field}`] : []
167+
})
168+
169+
if (missingFields.length !== issues.length || missingFields.length === 0) {
170+
return undefined
171+
}
172+
173+
return [
174+
'Missing required replacement fields:',
175+
...missingFields.map((field) => `- ${field}`),
176+
'',
177+
'If the intent is deletion, set "new": "" explicitly.',
178+
].join('\n')
179+
}
180+
128181
function getToolValidationHint(toolName: string): string | undefined {
129182
if (toolName === 'str_replace' || toolName === 'propose_str_replace') {
130183
return 'Expected shape: { "path": string, "replacements": [{ "old": string, "new": string, "allowMultiple"?: boolean }] }.'
@@ -151,23 +204,32 @@ export function parseRawToolCall<T extends ToolName = ToolName>(params: {
151204
)
152205
const paramsSchema = toolParams[toolName].inputSchema
153206

154-
if (typeof processedParameters === 'string') {
155-
return stringInputError(toolName, rawToolCall.toolCallId)
207+
if (typeof processedParameters.input === 'string') {
208+
return stringInputError(
209+
toolName,
210+
rawToolCall.toolCallId,
211+
processedParameters.parseError,
212+
)
156213
}
157214

158-
const result = paramsSchema.safeParse(processedParameters)
215+
const result = paramsSchema.safeParse(processedParameters.input)
159216

160217
if (!result.success) {
161218
const hint = getToolValidationHint(toolName)
219+
const summary = summarizeMissingReplacementFields(
220+
toolName,
221+
result.error.issues,
222+
)
223+
const validationDetails = JSON.stringify(result.error.issues, null, 2)
162224
return {
163225
toolName,
164226
toolCallId: rawToolCall.toolCallId,
165227
input: rawToolCall.input,
166-
error: `Invalid parameters for ${toolName}: ${JSON.stringify(
167-
result.error.issues,
168-
null,
169-
2,
170-
)}${hint ? `\n\n${hint}` : ''}`,
228+
error: `Invalid parameters for ${toolName}: ${
229+
summary
230+
? `${summary}\n\nRaw validation issues:\n${validationDetails}`
231+
: validationDetails
232+
}${hint ? `\n\n${hint}` : ''}`,
171233
}
172234
}
173235

@@ -496,12 +558,16 @@ export function parseRawCustomToolCall(params: {
496558

497559
const parsedInput = parseStringifiedToolInput(rawToolCall.input, toolName)
498560

499-
if (typeof parsedInput === 'string') {
500-
return stringInputError(toolName, rawToolCall.toolCallId)
561+
if (typeof parsedInput.input === 'string') {
562+
return stringInputError(
563+
toolName,
564+
rawToolCall.toolCallId,
565+
parsedInput.parseError,
566+
)
501567
}
502568

503569
const processedParameters: Record<string, any> = {}
504-
for (const [param, val] of Object.entries(parsedInput ?? {})) {
570+
for (const [param, val] of Object.entries(parsedInput.input ?? {})) {
505571
processedParameters[param] = val
506572
}
507573

@@ -530,7 +596,7 @@ export function parseRawCustomToolCall(params: {
530596
}
531597
}
532598

533-
const input = JSON.parse(JSON.stringify(parsedInput))
599+
const input = JSON.parse(JSON.stringify(parsedInput.input))
534600
if (endsAgentStepParam in input) {
535601
delete input[endsAgentStepParam]
536602
}

0 commit comments

Comments
 (0)