Skip to content

Commit fff103f

Browse files
committed
Support "provider-defined" tools in DurableAgent
Closes #433.
1 parent 172e015 commit fff103f

File tree

5 files changed

+148
-23
lines changed

5 files changed

+148
-23
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
@@ -16,6 +16,18 @@ import {
1616
import { convertToLanguageModelPrompt, standardizePrompt } from 'ai/internal';
1717
import { FatalError } from 'workflow';
1818
import { streamTextIterator } from './stream-text-iterator.js';
19+
import type { ProviderDefinedTool } from './tools-to-model-tools.js';
20+
21+
// Re-export for convenience
22+
export type { ProviderDefinedTool } from './tools-to-model-tools.js';
23+
24+
/**
25+
* Extended tool set that supports both user-defined and provider-defined tools.
26+
*/
27+
export type DurableAgentToolSet = Record<
28+
string,
29+
ToolSet[string] | ProviderDefinedTool
30+
>;
1931

2032
/**
2133
* Information passed to the prepareStep callback.
@@ -84,8 +96,12 @@ export interface DurableAgentOptions {
8496
* A set of tools available to the agent.
8597
* Tools can be implemented as workflow steps for automatic retries and persistence,
8698
* or as regular workflow-level logic using core library features like sleep() and Hooks.
99+
*
100+
* Supports both user-defined tools (with `execute` functions) and provider-defined tools
101+
* (with `type: 'provider-defined'`). Provider-defined tools are executed by the model
102+
* provider directly (e.g., Anthropic's computer use tools).
87103
*/
88-
tools?: ToolSet;
104+
tools?: DurableAgentToolSet;
89105

90106
/**
91107
* Optional system prompt to guide the agent's behavior.
@@ -194,7 +210,7 @@ export interface DurableAgentStreamOptions<TTools extends ToolSet = ToolSet> {
194210
*/
195211
export class DurableAgent {
196212
private model: string | (() => Promise<LanguageModelV2>);
197-
private tools: ToolSet;
213+
private tools: DurableAgentToolSet;
198214
private system?: string;
199215

200216
constructor(options: DurableAgentOptions) {
@@ -283,17 +299,39 @@ async function closeStream(
283299
}
284300
}
285301

302+
function isProviderDefinedTool(
303+
tool: DurableAgentToolSet[string]
304+
): tool is ProviderDefinedTool {
305+
return (
306+
typeof tool === 'object' &&
307+
tool !== null &&
308+
'type' in tool &&
309+
tool.type === 'provider-defined'
310+
);
311+
}
312+
286313
async function executeTool(
287314
toolCall: LanguageModelV2ToolCall,
288-
tools: ToolSet,
315+
tools: DurableAgentToolSet,
289316
messages: LanguageModelV2Prompt
290317
): Promise<LanguageModelV2ToolResultPart> {
291318
const tool = tools[toolCall.toolName];
292319
if (!tool) throw new Error(`Tool "${toolCall.toolName}" not found`);
293-
if (typeof tool.execute !== 'function')
320+
321+
// Provider-defined tools should not reach here as they are filtered out earlier
322+
// and executed by the provider. This is a safety check.
323+
if (isProviderDefinedTool(tool)) {
324+
throw new Error(
325+
`Tool "${toolCall.toolName}" is a provider-defined tool and should be executed by the provider, not the client`
326+
);
327+
}
328+
329+
if (typeof tool.execute !== 'function') {
294330
throw new Error(
295331
`Tool "${toolCall.toolName}" does not have an execute function`
296332
);
333+
}
334+
297335
const schema = asSchema(tool.inputSchema);
298336
const input = await schema.validate?.(JSON.parse(toolCall.input || '{}'));
299337
if (!input?.success) {

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

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import type {
77
import type {
88
StepResult,
99
StreamTextOnStepFinishCallback,
10-
ToolSet,
1110
UIMessageChunk,
1211
} from 'ai';
1312
import { doStreamStep, type ModelStopCondition } from './do-stream-step.js';
14-
import type { PrepareStepCallback } from './durable-agent.js';
13+
import type {
14+
DurableAgentToolSet,
15+
PrepareStepCallback,
16+
} from './durable-agent.js';
1517
import { toolsToModelTools } from './tools-to-model-tools.js';
1618

1719
/**
@@ -37,7 +39,7 @@ export async function* streamTextIterator({
3739
prepareStep,
3840
}: {
3941
prompt: LanguageModelV2Prompt;
40-
tools: ToolSet;
42+
tools: DurableAgentToolSet;
4143
writable: WritableStream<UIMessageChunk>;
4244
model: string | (() => Promise<LanguageModelV2>);
4345
stopConditions?: ModelStopCondition[] | ModelStopCondition;
@@ -76,7 +78,7 @@ export async function* streamTextIterator({
7678
}
7779
}
7880

79-
const { toolCalls, finish, step } = await doStreamStep(
81+
const { toolCalls, providerToolResults, finish, step } = await doStreamStep(
8082
conversationPrompt,
8183
currentModel,
8284
writable,
@@ -101,15 +103,29 @@ export async function* streamTextIterator({
101103
})),
102104
});
103105

104-
// Yield the tool calls along with the current conversation messages
105-
// This allows executeTool to pass the conversation context to tool execute functions
106-
const toolResults = yield { toolCalls, messages: conversationPrompt };
106+
// Filter to only client-executed tool calls (not provider-executed)
107+
const clientToolCalls = toolCalls.filter(
108+
(toolCall) => !toolCall.providerExecuted
109+
);
110+
111+
// Get client tool results by yielding only the client tool calls
112+
let clientToolResults: LanguageModelV2ToolResultPart[] = [];
113+
if (clientToolCalls.length > 0) {
114+
// Yield the tool calls along with the current conversation messages
115+
// This allows executeTool to pass the conversation context to tool execute functions
116+
clientToolResults = yield {
117+
toolCalls: clientToolCalls,
118+
messages: conversationPrompt,
119+
};
120+
await writeToolOutputToUI(writable, clientToolResults);
121+
}
107122

108-
await writeToolOutputToUI(writable, toolResults);
123+
// Merge provider tool results with client tool results
124+
const allToolResults = [...providerToolResults, ...clientToolResults];
109125

110126
conversationPrompt.push({
111127
role: 'tool',
112-
content: toolResults,
128+
content: allToolResults,
113129
});
114130

115131
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)