diff --git a/.changeset/recover-invalid-tool-input.md b/.changeset/recover-invalid-tool-input.md new file mode 100644 index 0000000000..65be97da97 --- /dev/null +++ b/.changeset/recover-invalid-tool-input.md @@ -0,0 +1,7 @@ +--- +'@workflow/ai': patch +--- + +DurableAgent: recover from invalid tool-call input instead of aborting the stream + +When a model emits a tool call whose arguments fail `inputSchema` validation (and no `experimental_repairToolCall` fixes it), `executeTool` now returns the validation error to the model as an `error-text` tool result — the same way tool *execution* errors are already handled — instead of throwing and aborting the whole agent stream. In a durable workflow that throw fails the entire run, so a single occasionally-malformed model tool-call could kill a long-running task with no chance for the agent to self-correct. The agent now sees the error as a tool result and can fix the arguments and retry within its step budget. diff --git a/packages/ai/src/agent/durable-agent.test.ts b/packages/ai/src/agent/durable-agent.test.ts index f78346ade2..2f3ff01343 100644 --- a/packages/ai/src/agent/durable-agent.test.ts +++ b/packages/ai/src/agent/durable-agent.test.ts @@ -2006,6 +2006,82 @@ describe('DurableAgent', () => { }); }); + it('should convert invalid tool input to error-text result instead of failing stream', async () => { + const tools: ToolSet = { + strictTool: { + description: 'A tool with a strict input schema', + inputSchema: z.object({ requiredField: z.string().min(1) }), + execute: async () => ({ ok: true }), + }, + }; + + const mockModel = createMockModel(); + + const agent = new DurableAgent({ + model: async () => mockModel, + tools, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const mockMessages: LanguageModelV3Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'test' }] }, + ]; + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + toolCalls: [ + { + toolCallId: 'test-call-id', + toolName: 'strictTool', + // Valid JSON, but violates the schema (empty string fails .min(1)). + input: '{"requiredField":""}', + } as LanguageModelV3ToolCall, + ], + messages: mockMessages, + }, + }) + .mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + // Invalid tool input should be handled gracefully, not reject the stream. + await expect( + agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }) + ).resolves.not.toThrow(); + + // Verify the validation error was sent back as an error-text tool result + // (so the model can correct its arguments and retry). + expect(mockIterator.next).toHaveBeenCalledTimes(2); + const toolResultsCall = mockIterator.next.mock.calls[1][0]; + expect(toolResultsCall).toBeDefined(); + expect(toolResultsCall).toHaveLength(1); + expect(toolResultsCall[0]).toMatchObject({ + type: 'tool-result', + toolCallId: 'test-call-id', + toolName: 'strictTool', + output: { + type: 'error-text', + }, + }); + expect(toolResultsCall[0].output.value).toContain( + 'Invalid input for tool "strictTool"' + ); + }); + it('should call onFinish with steps and messages when streaming completes', async () => { const mockModel = createMockModel(); diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts index b3fbfe6e3c..c96ba9183a 100644 --- a/packages/ai/src/agent/durable-agent.ts +++ b/packages/ai/src/agent/durable-agent.ts @@ -1656,7 +1656,21 @@ async function executeTool( ); } } - throw parseError; + // Input that fails to parse or validate (even after repair) is recoverable, + // exactly like a tool execution error below: feed the error back to the model + // as an error-text result so the agent can correct the call and retry, instead + // of aborting the entire stream. This aligns with AI SDK's streamText behavior + // for tool failures. Reaches here both for malformed JSON and for the + // re-thrown "Invalid input for tool ..." schema-validation error above. + return { + type: 'tool-result' as const, + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: { + type: 'error-text' as const, + value: getErrorMessage(parseError), + }, + }; } return recordSpan({