Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/recover-invalid-tool-input.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions packages/ai/src/agent/durable-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
16 changes: 15 additions & 1 deletion packages/ai/src/agent/durable-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading