|
12 | 12 |
|
13 | 13 | </div> |
14 | 14 |
|
15 | | -This library provides a type-safe API to manage [`activeTools`](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text#active-tools) for [`generateText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text) and [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) in the AI SDK. |
| 15 | +This library provides a type-safe API to manage [`activeTools`](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text#active-tools) and [`toolChoice`](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#tool-choice) for [`generateText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text) and [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) in the AI SDK. |
16 | 16 |
|
17 | 17 | ### Why? |
18 | 18 |
|
19 | | -The AI SDK provides an `activeTools` parameter to control which tools the model can use at any given time. However, managing tool activation becomes complex when you need to: |
| 19 | +The AI SDK provides an `activeTools` parameter to control which tools the model can use, and a `toolChoice` parameter to force which tool is called. However, managing this becomes complex when you need to: |
20 | 20 |
|
21 | 21 | - **Statically activate/deactivate tools**: Some tools should be inactive by default and only available after being explicitly activated |
22 | 22 | - **Dynamically infer tool activation**: Some tools should be activated based on runtime context like the conversation history |
| 23 | +- **Force tool choice**: Sometimes the model should be required to call a specific tool, or any tool, based on context |
23 | 24 |
|
24 | | -This library wraps standard AI SDK `tool()` definitions with chainable activation methods and resolves `tools` and `activeTools` for any AI SDK function. |
| 25 | +This library wraps standard AI SDK `tool()` definitions with chainable activation and choice methods and resolves `tools`, `activeTools`, and `toolChoice` for any AI SDK function. |
25 | 26 |
|
26 | 27 | ### Installation |
27 | 28 |
|
@@ -220,6 +221,50 @@ const toolSet = createToolSet({ tools }) |
220 | 221 | .activateWhen('cancel_order', ({ messages }) => hasUnfulfilledOrders(messages)); |
221 | 222 | ``` |
222 | 223 |
|
| 224 | +### Tool Choice |
| 225 | + |
| 226 | +The AI SDK can steer tool calls with [`toolChoice`](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#tool-choice), which controls whether and which tool the model must call. `.inferTools()` resolves a `toolChoice` value alongside `tools` and `activeTools`, so a single tool set drives activation and choice together. |
| 227 | + |
| 228 | +Use `.choice()` with a constant for static choices or use a resolver to decide dynamically: |
| 229 | + |
| 230 | +```typescript |
| 231 | +const toolSet = createToolSet({ tools }) |
| 232 | + .deactivate(['cancel_order']) |
| 233 | + // Force the model to call one of the active tools this turn |
| 234 | + .choice('required') |
| 235 | + // Or resolve dynamically, e.g. force unauthenticated users into search |
| 236 | + .choice(({ context }) => (context?.isAuthenticated ? 'auto' : { type: 'tool', toolName: 'search' })); |
| 237 | + |
| 238 | +const { tools, activeTools, toolChoice } = toolSet.inferTools(); |
| 239 | + |
| 240 | +const result = await generateText({ model, tools, activeTools, toolChoice, prompt: 'Show me my orders' }); |
| 241 | +``` |
| 242 | + |
| 243 | +A `.choice()` entry is either a constant [`ToolChoice`](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#tool-choice) (`'auto'`, `'required'`, `'none'`, or `{ type: 'tool', toolName }` with `toolName` typed to your tools) or a **resolver**. The resolver runs inside `.inferTools()` with your toolset values (`{ messages, steps, context }`) and returns a `ToolChoice`, or `undefined` to leave the choice unconstrained (the AI SDK then defaults to `'auto'`). Since it sets a single value, it follows the same **last-call wins** rule as activation: the last entry decides, and a resolver returning `undefined` does not fall back to an earlier one, so express any fallback inside the resolver itself. |
| 244 | + |
| 245 | +> [!NOTE] |
| 246 | +> `toolChoice` is resolved independently of `activeTools` and passed through as-is. Because the AI SDK filters tools by `activeTools` first, forcing `{ type: 'tool', toolName }` for a tool you have deactivated leaves the model unable to see it and surfaces as a provider error, so keep a forced tool active. |
| 247 | +
|
| 248 | +Resolving from `steps` makes it easy to guide a multi-step run. Call `.inferTools()` inside `prepareStep()` to recompute `toolChoice` (and `activeTools`) after each step, for example to force a tool on the first step and then hand control back to the model: |
| 249 | + |
| 250 | +```typescript |
| 251 | +import { generateText, stepCountIs } from 'ai'; |
| 252 | + |
| 253 | +const toolSet = createToolSet({ tools }).choice(({ steps }) => |
| 254 | + steps?.length === 0 ? { type: 'tool', toolName: 'search' } : 'auto', |
| 255 | +); |
| 256 | + |
| 257 | +const { tools } = toolSet; |
| 258 | + |
| 259 | +const result = await generateText({ |
| 260 | + model, |
| 261 | + tools, |
| 262 | + stopWhen: stepCountIs(10), |
| 263 | + prompt: 'Find my orders and cancel the unfulfilled one', |
| 264 | + prepareStep: ({ steps }) => toolSet.inferTools({ steps }), |
| 265 | +}); |
| 266 | +``` |
| 267 | + |
223 | 268 | ### Immutable vs Mutable |
224 | 269 |
|
225 | 270 | By default, `createToolSet()` returns an **immutable** tool set, that means every method returns a new instance and the original is never modified. This is ideal when the tool set is created once in the global scope and shared across requests: |
@@ -420,32 +465,47 @@ Conditionally deactivate tools. The predicate receives `{ messages, steps, conte |
420 | 465 | toolSet.deactivateWhen('search', ({ messages }) => messages && messages.length > 10); |
421 | 466 | ``` |
422 | 467 |
|
| 468 | +#### `.choice(entry)` |
| 469 | + |
| 470 | +Set the toolset's `toolChoice` to a constant `ToolChoice` (`'auto'`, `'none'`, `'required'`, or `{ type: 'tool', toolName }`) or a resolver. A resolver receives `{ messages, steps, context }` and returns a `ToolChoice`, or `undefined` to leave it unconstrained (omitted). Last-call wins. The resolved `toolChoice` is passed through as-is; keep a forced tool active so the AI SDK does not filter it out. |
| 471 | + |
| 472 | +```ts |
| 473 | +// Constant |
| 474 | +toolSet.choice('required'); |
| 475 | +toolSet.choice({ type: 'tool', toolName: 'search' }); |
| 476 | + |
| 477 | +// Resolver — force a tool on the first step, then let the model decide |
| 478 | +toolSet.choice(({ steps }) => (steps?.length === 0 ? { type: 'tool', toolName: 'search' } : 'auto')); |
| 479 | +``` |
| 480 | + |
423 | 481 | #### `.inferTools(input?)` |
424 | 482 |
|
425 | | -Evaluate all predicates and return `{ tools, activeTools }`, directly spreadable into `generateText()` or `streamText()`. The input is optional; all fields are optional. Predicates receive `undefined` for fields not provided. |
| 483 | +Evaluate all predicates and the tool-choice entry and return `{ tools, activeTools, toolChoice }`, directly spreadable into `generateText()` or `streamText()`. The input is optional; all fields are optional. Predicates and resolvers receive `undefined` for fields not provided. |
426 | 484 |
|
427 | 485 | - `input` (optional): |
428 | 486 | - `messages` (optional), the current conversation messages |
429 | 487 | - `steps` (optional), the completed steps of the current run (the `StepResult` array from `prepareStep`) |
430 | | - - `context` (optional), arbitrary values passed to predicates |
| 488 | + - `context` (optional), arbitrary values passed to predicates and the tool-choice resolver |
| 489 | + |
| 490 | +The result also includes `toolChoice` when set via `.choice()` (otherwise `undefined`). |
431 | 491 |
|
432 | 492 | ```ts |
433 | 493 | // Static-only (no predicates) |
434 | | -const { tools, activeTools } = toolSet.inferTools(); |
| 494 | +const { tools, activeTools, toolChoice } = toolSet.inferTools(); |
435 | 495 |
|
436 | 496 | // With messages |
437 | | -const { tools, activeTools } = toolSet.inferTools({ messages }); |
| 497 | +const { tools, activeTools, toolChoice } = toolSet.inferTools({ messages }); |
438 | 498 |
|
439 | 499 | // With context |
440 | | -const { tools, activeTools } = toolSet.inferTools({ context: { isAdmin: true } }); |
| 500 | +const { tools, activeTools, toolChoice } = toolSet.inferTools({ context: { isAdmin: true } }); |
441 | 501 |
|
442 | 502 | // With steps (e.g. inside prepareStep) |
443 | | -const { tools, activeTools } = toolSet.inferTools({ steps }); |
| 503 | +const { tools, activeTools, toolChoice } = toolSet.inferTools({ steps }); |
444 | 504 |
|
445 | 505 | // With both |
446 | | -const { tools, activeTools } = toolSet.inferTools({ messages, context }); |
| 506 | +const { tools, activeTools, toolChoice } = toolSet.inferTools({ messages, context }); |
447 | 507 |
|
448 | | -const result = await generateText({ model, tools, activeTools, messages }); |
| 508 | +const result = await generateText({ model, tools, activeTools, toolChoice, messages }); |
449 | 509 | ``` |
450 | 510 |
|
451 | 511 | #### `.clone(options?)` |
|
0 commit comments