Skip to content

Commit a09b829

Browse files
committed
Support "provider-defined" tools in DurableAgent
Closes #433.
1 parent af9867f commit a09b829

File tree

5 files changed

+140
-21
lines changed

5 files changed

+140
-21
lines changed

.changeset/eleven-breads-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/ai": patch
3+
---
4+
5+
Support "provider-defined" tools in `DurableAgent`

packages/ai/src/agent/do-stream-step.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
LanguageModelV2Prompt,
55
LanguageModelV2StreamPart,
66
LanguageModelV2ToolCall,
7+
LanguageModelV2ToolResultPart,
78
} from '@ai-sdk/provider';
89
import {
910
gateway,
@@ -38,6 +39,7 @@ export async function doStreamStep(
3839
'Invalid "model initialization" argument. Must be a string or a function that returns a LanguageModelV2 instance.'
3940
);
4041
}
42+
console.log('doStreamStep tools', tools);
4143

4244
const result = await model.doStream({
4345
prompt: conversationPrompt,
@@ -46,17 +48,34 @@ export async function doStreamStep(
4648

4749
let finish: FinishPart | undefined;
4850
const toolCalls: LanguageModelV2ToolCall[] = [];
51+
const providerToolResults: LanguageModelV2ToolResultPart[] = [];
4952
const chunks: LanguageModelV2StreamPart[] = [];
5053

5154
await result.stream
5255
.pipeThrough(
5356
new TransformStream({
5457
transform(chunk, controller) {
58+
console.log('doStreamStep chunk', chunk);
5559
if (chunk.type === 'tool-call') {
5660
toolCalls.push({
5761
...chunk,
5862
input: chunk.input || '{}',
5963
});
64+
} else if (chunk.type === 'tool-result') {
65+
// Capture provider-executed tool results from the stream
66+
// Convert from stream result format (result: unknown) to prompt format (output: LanguageModelV2ToolResultOutput)
67+
providerToolResults.push({
68+
type: 'tool-result',
69+
toolCallId: chunk.toolCallId,
70+
toolName: chunk.toolName,
71+
output: {
72+
type: 'text',
73+
value:
74+
typeof chunk.result === 'string'
75+
? chunk.result
76+
: JSON.stringify(chunk.result),
77+
},
78+
});
6079
} else if (chunk.type === 'finish') {
6180
finish = chunk;
6281
}
@@ -380,7 +399,7 @@ export async function doStreamStep(
380399
// }
381400

382401
const step = chunksToStep(chunks, toolCalls, conversationPrompt, finish);
383-
return { toolCalls, finish, step };
402+
return { toolCalls, providerToolResults, finish, step };
384403
}
385404

386405
// This is a stand-in for logic in the AI-SDK streamText code which aggregates

packages/ai/src/agent/durable-agent.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ import {
1414
import { convertToLanguageModelPrompt, standardizePrompt } from 'ai/internal';
1515
import { FatalError } from 'workflow';
1616
import { streamTextIterator } from './stream-text-iterator.js';
17+
import type { ProviderDefinedTool } from './tools-to-model-tools.js';
18+
19+
// Re-export for convenience
20+
export type { ProviderDefinedTool } from './tools-to-model-tools.js';
21+
22+
/**
23+
* Extended tool set that supports both user-defined and provider-defined tools.
24+
*/
25+
export type DurableAgentToolSet = Record<
26+
string,
27+
ToolSet[string] | ProviderDefinedTool
28+
>;
1729

1830
/**
1931
* Configuration options for creating a {@link DurableAgent} instance.
@@ -31,8 +43,12 @@ export interface DurableAgentOptions {
3143
* A set of tools available to the agent.
3244
* Tools can be implemented as workflow steps for automatic retries and persistence,
3345
* or as regular workflow-level logic using core library features like sleep() and Hooks.
46+
*
47+
* Supports both user-defined tools (with `execute` functions) and provider-defined tools
48+
* (with `type: 'provider-defined'`). Provider-defined tools are executed by the model
49+
* provider directly (e.g., Anthropic's computer use tools).
3450
*/
35-
tools?: ToolSet;
51+
tools?: DurableAgentToolSet;
3652

3753
/**
3854
* Optional system prompt to guide the agent's behavior.
@@ -121,7 +137,7 @@ export interface DurableAgentStreamOptions {
121137
*/
122138
export class DurableAgent {
123139
private model: string | (() => Promise<LanguageModelV2>);
124-
private tools: ToolSet;
140+
private tools: DurableAgentToolSet;
125141
private system?: string;
126142

127143
constructor(options: DurableAgentOptions) {
@@ -207,16 +223,38 @@ async function closeStream(
207223
}
208224
}
209225

226+
function isProviderDefinedTool(
227+
tool: DurableAgentToolSet[string]
228+
): tool is ProviderDefinedTool {
229+
return (
230+
typeof tool === 'object' &&
231+
tool !== null &&
232+
'type' in tool &&
233+
tool.type === 'provider-defined'
234+
);
235+
}
236+
210237
async function executeTool(
211238
toolCall: LanguageModelV2ToolCall,
212-
tools: ToolSet
239+
tools: DurableAgentToolSet
213240
): Promise<LanguageModelV2ToolResultPart> {
214241
const tool = tools[toolCall.toolName];
215242
if (!tool) throw new Error(`Tool "${toolCall.toolName}" not found`);
216-
if (typeof tool.execute !== 'function')
243+
244+
// Provider-defined tools should not reach here as they are filtered out earlier
245+
// and executed by the provider. This is a safety check.
246+
if (isProviderDefinedTool(tool)) {
247+
throw new Error(
248+
`Tool "${toolCall.toolName}" is a provider-defined tool and should be executed by the provider, not the client`
249+
);
250+
}
251+
252+
if (typeof tool.execute !== 'function') {
217253
throw new Error(
218254
`Tool "${toolCall.toolName}" does not have an execute function`
219255
);
256+
}
257+
220258
const schema = asSchema(tool.inputSchema);
221259
const input = await schema.validate?.(JSON.parse(toolCall.input || '{}'));
222260
if (!input?.success) {

packages/ai/src/agent/stream-text-iterator.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import type {
77
import type {
88
StepResult,
99
StreamTextOnStepFinishCallback,
10-
ToolSet,
1110
UIMessageChunk,
1211
} from 'ai';
12+
import type { DurableAgentToolSet } from './durable-agent.js';
1313
import { doStreamStep, type ModelStopCondition } from './do-stream-step.js';
1414
import { toolsToModelTools } from './tools-to-model-tools.js';
1515

@@ -24,7 +24,7 @@ export async function* streamTextIterator({
2424
onStepFinish,
2525
}: {
2626
prompt: LanguageModelV2Prompt;
27-
tools: ToolSet;
27+
tools: DurableAgentToolSet;
2828
writable: WritableStream<UIMessageChunk>;
2929
model: string | (() => Promise<LanguageModelV2>);
3030
stopConditions?: ModelStopCondition[] | ModelStopCondition;
@@ -42,7 +42,7 @@ export async function* streamTextIterator({
4242
let isFirstIteration = true;
4343

4444
while (!done) {
45-
const { toolCalls, finish, step } = await doStreamStep(
45+
const { toolCalls, providerToolResults, finish, step } = await doStreamStep(
4646
conversationPrompt,
4747
model,
4848
writable,
@@ -66,14 +66,24 @@ export async function* streamTextIterator({
6666
})),
6767
});
6868

69-
// Yield the tool calls and wait for results
70-
const toolResults = yield toolCalls;
69+
// Filter to only client-executed tool calls (not provider-executed)
70+
const clientToolCalls = toolCalls.filter(
71+
(toolCall) => !toolCall.providerExecuted
72+
);
7173

72-
await writeToolOutputToUI(writable, toolResults);
74+
// Get client tool results by yielding only the client tool calls
75+
let clientToolResults: LanguageModelV2ToolResultPart[] = [];
76+
if (clientToolCalls.length > 0) {
77+
clientToolResults = yield clientToolCalls;
78+
await writeToolOutputToUI(writable, clientToolResults);
79+
}
80+
81+
// Merge provider tool results with client tool results
82+
const allToolResults = [...providerToolResults, ...clientToolResults];
7383

7484
conversationPrompt.push({
7585
role: 'tool',
76-
content: toolResults,
86+
content: allToolResults,
7787
});
7888

7989
if (stopConditions) {
Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,60 @@
1-
import type { LanguageModelV2FunctionTool } from '@ai-sdk/provider';
1+
import type {
2+
LanguageModelV2FunctionTool,
3+
LanguageModelV2ProviderDefinedTool,
4+
} from '@ai-sdk/provider';
25
import { asSchema, type ToolSet } from 'ai';
36

7+
/**
8+
* A provider-defined tool configuration.
9+
* These tools are executed by the provider (e.g., Anthropic's computer use)
10+
* rather than client-side.
11+
*/
12+
export interface ProviderDefinedTool {
13+
type: 'provider-defined';
14+
/**
15+
* The ID of the tool. Should follow the format `<provider-name>.<unique-tool-name>`.
16+
*/
17+
id: `${string}.${string}`;
18+
/**
19+
* Optional description of the tool.
20+
*/
21+
description?: string;
22+
/**
23+
* The arguments for configuring the tool.
24+
*/
25+
args?: Record<string, unknown>;
26+
}
27+
28+
type ToolEntry = ToolSet[string] | ProviderDefinedTool;
29+
30+
function isProviderDefinedTool(tool: ToolEntry): tool is ProviderDefinedTool {
31+
return (
32+
typeof tool === 'object' &&
33+
tool !== null &&
34+
'type' in tool &&
35+
tool.type === 'provider-defined'
36+
);
37+
}
38+
439
export function toolsToModelTools(
5-
tools: ToolSet
6-
): LanguageModelV2FunctionTool[] {
7-
return Object.entries(tools).map(([name, tool]) => ({
8-
type: 'function',
9-
name,
10-
description: tool.description,
11-
inputSchema: asSchema(tool.inputSchema).jsonSchema,
12-
}));
40+
tools: Record<string, ToolEntry>
41+
): Array<LanguageModelV2FunctionTool | LanguageModelV2ProviderDefinedTool> {
42+
return Object.entries(tools).map(([name, tool]) => {
43+
if (isProviderDefinedTool(tool)) {
44+
return {
45+
type: 'provider-defined' as const,
46+
id: tool.id,
47+
name,
48+
args: tool.args ?? {},
49+
};
50+
}
51+
52+
// User-defined function tool
53+
return {
54+
type: 'function' as const,
55+
name,
56+
description: tool.description,
57+
inputSchema: asSchema(tool.inputSchema).jsonSchema,
58+
};
59+
});
1360
}

0 commit comments

Comments
 (0)