From d8c9049ed6cda225734d13b742043db5fd1fbbf5 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Thu, 19 Feb 2026 06:13:32 +0000 Subject: [PATCH 1/3] fix: reject invalid tool call inputs and retry instead of sending to API When the AI SDK fails to parse tool call input (marking it as invalid), the runtime was previously casting the malformed input as Record and sending it to the API, causing "Input should be a valid dictionary" errors from Anthropic. Now invalid tool calls are detected and trigger a retry with error context, giving the LLM a chance to regenerate valid tool calls. Also tightens ToolCallPart.args schema from z.unknown() to z.record() for defense-in-depth validation. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/schemas/message-part.ts | 4 +-- .../states/generating-tool-call.ts | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) 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..c4e875e7 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,42 @@ 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< From 98638419c7baa61c3bdb627e63a990b47cfb65c6 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Thu, 19 Feb 2026 06:14:02 +0000 Subject: [PATCH 2/3] chore: add changeset Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-invalid-tool-call-input.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fix-invalid-tool-call-input.md 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 From 9f647020be7edb4ce9d471416eab7605726f2247 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Thu, 19 Feb 2026 12:59:28 +0000 Subject: [PATCH 3/3] fix: format generating-tool-call.ts to pass biome lint Co-Authored-By: Claude Opus 4.6 --- .../runtime/src/state-machine/states/generating-tool-call.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 c4e875e7..0bcdf011 100644 --- a/packages/runtime/src/state-machine/states/generating-tool-call.ts +++ b/packages/runtime/src/state-machine/states/generating-tool-call.ts @@ -179,9 +179,7 @@ export async function generatingToolCallLogic({ // 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, - ) + const invalidToolCalls = toolCalls.filter((tc: { invalid?: boolean }) => tc.invalid === true) if (invalidToolCalls.length > 0) { const currentRetryCount = checkpoint.retryCount ?? 0 if (currentRetryCount >= setting.maxRetries) {