diff --git a/.changeset/fix-invalid-tool-call-input.md b/.changeset/fix-invalid-tool-call-input.md new file mode 100644 index 00000000..826337c9 --- /dev/null +++ b/.changeset/fix-invalid-tool-call-input.md @@ -0,0 +1,6 @@ +--- +"@perstack/core": patch +"@perstack/runtime": patch +--- + +fix: reject invalid tool call inputs and retry instead of sending to API diff --git a/packages/core/src/schemas/message-part.ts b/packages/core/src/schemas/message-part.ts index 789a8384..4a5e52ad 100644 --- a/packages/core/src/schemas/message-part.ts +++ b/packages/core/src/schemas/message-part.ts @@ -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 } 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 diff --git a/packages/runtime/src/state-machine/states/generating-tool-call.ts b/packages/runtime/src/state-machine/states/generating-tool-call.ts index 1f7fd8a7..0bcdf011 100644 --- a/packages/runtime/src/state-machine/states/generating-tool-call.ts +++ b/packages/runtime/src/state-machine/states/generating-tool-call.ts @@ -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<