Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-invalid-tool-call-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@perstack/core": patch
"@perstack/runtime": patch
---

fix: reject invalid tool call inputs and retry instead of sending to API
4 changes: 2 additions & 2 deletions packages/core/src/schemas/message-part.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,14 @@ export interface ToolCallPart extends BasePart {
/** Name of the tool to call */
toolName: string
/** Arguments to pass to the tool */
args: unknown
args: Record<string, unknown>
}

export const toolCallPartSchema = basePartSchema.extend({
type: z.literal("toolCallPart"),
toolCallId: z.string(),
toolName: z.string(),
args: z.unknown(),
args: z.record(z.string(), z.unknown()),
})
toolCallPartSchema satisfies z.ZodType<ToolCallPart>

Expand Down
34 changes: 34 additions & 0 deletions packages/runtime/src/state-machine/states/generating-tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,40 @@ export async function generatingToolCallLogic({
const thinkingParts = extractThinkingParts(reasoning as ReasoningPart[] | undefined)
const thinkingText = extractThinkingText(reasoning as ReasoningPart[] | undefined)

// Check for invalid tool calls (e.g., malformed input from the model)
// When the AI SDK fails to parse tool call input, it marks the tool call as invalid.
// These must be rejected and retried to avoid sending non-dict input to the API.
const invalidToolCalls = toolCalls.filter((tc: { invalid?: boolean }) => tc.invalid === true)
if (invalidToolCalls.length > 0) {
const currentRetryCount = checkpoint.retryCount ?? 0
if (currentRetryCount >= setting.maxRetries) {
return stopRunByError(setting, checkpoint, {
checkpoint: {
...checkpoint,
status: "stoppedByError",
},
step: {
...step,
finishedAt: Date.now(),
},
error: {
name: "InvalidToolCallInput",
message: `Max retries (${setting.maxRetries}) exceeded: Invalid tool call input for ${invalidToolCalls.map((tc) => tc.toolName).join(", ")}`,
isRetryable: false,
},
})
}
const reason = JSON.stringify({
error: "Error: Invalid tool call input",
message: `The following tool call(s) had invalid input: ${invalidToolCalls.map((tc) => tc.toolName).join(", ")}. Ensure tool call arguments are valid JSON objects matching the tool schema. Try again.`,
})
return retry(setting, checkpoint, {
reason,
newMessages: [createUserMessage([{ type: "textPart", text: reason }])],
usage,
})
}

// Text only = implicit completion (no tool calls)
if (toolCalls.length === 0 && text) {
const contents: Array<
Expand Down