Skip to content

Commit 0dc2935

Browse files
authored
feat(core): add prepareStep to AgentOptions for per-step tool control (#1192)
Surfaces the AI SDK's prepareStep callback as a top-level AgentOptions property. Users can now set a default step preparation callback at agent creation time to control tool availability per step. Per-call prepareStep in method options overrides the agent-level default. The agent-level default is applied before applyForcedToolChoice so both features compose correctly. Fixes #1187
1 parent a21275f commit 0dc2935

File tree

7 files changed

+180
-3
lines changed

7 files changed

+180
-3
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@voltagent/core": minor
3+
---
4+
5+
feat(core): add `prepareStep` to AgentOptions for per-step tool control
6+
7+
Surfaces the AI SDK's `prepareStep` callback as a top-level `AgentOptions` property so users can set a default step preparation callback at agent creation time. Per-call `prepareStep` in method options overrides the agent-level default.
8+
9+
This enables controlling tool availability, tool choice, and other step settings on a per-step basis without passing `prepareStep` on every call.

packages/core/src/agent/agent.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export type {
128128
SemanticMemoryOptions,
129129
} from "./types";
130130
import { P, match } from "ts-pattern";
131-
import type { StopWhen } from "../ai-types";
131+
import type { PrepareStep, StopWhen } from "../ai-types";
132132
import type { SamplingPolicy } from "../eval/runtime";
133133
import type { ConversationStepRecord } from "../memory/types";
134134
import { applySummarization } from "./apply-summarization";
@@ -923,6 +923,13 @@ export interface BaseGenerationOptions<TProviderOptions extends ProviderOptions
923923
* Tool choice strategy for AI SDK calls.
924924
*/
925925
toolChoice?: ToolChoice<Record<string, unknown>>;
926+
927+
/**
928+
* Step preparation callback (ai-sdk `prepareStep`).
929+
* Called before each step to control tool availability, tool choice, etc.
930+
* Overrides the agent-level `prepareStep` if provided.
931+
*/
932+
prepareStep?: PrepareStep;
926933
}
927934

928935
export type GenerateTextOptions<
@@ -964,6 +971,7 @@ export class Agent {
964971
readonly maxSteps: number;
965972
readonly maxRetries: number;
966973
readonly stopWhen?: StopWhen;
974+
readonly prepareStep?: PrepareStep;
967975
readonly markdown: boolean;
968976
readonly inheritParentSpan: boolean;
969977
readonly voice?: Voice;
@@ -1022,6 +1030,7 @@ export class Agent {
10221030
this.maxSteps = options.maxSteps ?? defaultMaxSteps;
10231031
this.maxRetries = options.maxRetries ?? DEFAULT_LLM_MAX_RETRIES;
10241032
this.stopWhen = options.stopWhen;
1033+
this.prepareStep = options.prepareStep;
10251034
this.markdown = options.markdown ?? false;
10261035
this.inheritParentSpan = options.inheritParentSpan ?? true;
10271036
this.voice = options.voice;
@@ -1265,6 +1274,11 @@ export class Agent {
12651274
...aiSDKOptions
12661275
} = options || {};
12671276

1277+
// Apply agent-level prepareStep as default (per-call overrides)
1278+
if (this.prepareStep && !aiSDKOptions.prepareStep) {
1279+
aiSDKOptions.prepareStep = this.prepareStep as AITextCallOptions["prepareStep"];
1280+
}
1281+
12681282
const forcedToolChoice = oc.systemContext.get(FORCED_TOOL_CHOICE_CONTEXT_KEY) as
12691283
| ToolChoice<Record<string, unknown>>
12701284
| undefined;
@@ -1879,6 +1893,11 @@ export class Agent {
18791893
...aiSDKOptions
18801894
} = options || {};
18811895

1896+
// Apply agent-level prepareStep as default (per-call overrides)
1897+
if (this.prepareStep && !aiSDKOptions.prepareStep) {
1898+
aiSDKOptions.prepareStep = this.prepareStep as AITextCallOptions["prepareStep"];
1899+
}
1900+
18821901
const forcedToolChoice = oc.systemContext.get(FORCED_TOOL_CHOICE_CONTEXT_KEY) as
18831902
| ToolChoice<Record<string, unknown>>
18841903
| undefined;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { Agent } from "./agent";
3+
import { createMockLanguageModel, defaultMockResponse } from "./test-utils";
4+
5+
describe("prepareStep", () => {
6+
it("should accept prepareStep in AgentOptions", () => {
7+
const prepareStep = vi.fn(() => ({}));
8+
const model = createMockLanguageModel();
9+
10+
const agent = new Agent({
11+
name: "test-agent",
12+
instructions: "test",
13+
model,
14+
prepareStep,
15+
});
16+
17+
expect(agent.prepareStep).toBe(prepareStep);
18+
});
19+
20+
it("should default to undefined when prepareStep is not provided", () => {
21+
const model = createMockLanguageModel();
22+
23+
const agent = new Agent({
24+
name: "test-agent",
25+
instructions: "test",
26+
model,
27+
});
28+
29+
expect(agent.prepareStep).toBeUndefined();
30+
});
31+
32+
it("should pass agent-level prepareStep to generateText", async () => {
33+
const prepareStep = vi.fn(() => ({}));
34+
const model = createMockLanguageModel({
35+
doGenerate: {
36+
...defaultMockResponse,
37+
content: [{ type: "text", text: "done" }],
38+
},
39+
});
40+
41+
const agent = new Agent({
42+
name: "test-agent",
43+
instructions: "test",
44+
model,
45+
prepareStep,
46+
});
47+
48+
await agent.generateText("hello");
49+
50+
// prepareStep is called by the AI SDK on each step
51+
expect(prepareStep).toHaveBeenCalled();
52+
});
53+
54+
it("should pass agent-level prepareStep to streamText", async () => {
55+
const prepareStep = vi.fn(() => ({}));
56+
const model = createMockLanguageModel();
57+
58+
const agent = new Agent({
59+
name: "test-agent",
60+
instructions: "test",
61+
model,
62+
prepareStep,
63+
});
64+
65+
const result = await agent.streamText("hello");
66+
// consume the stream to completion
67+
for await (const _part of result.textStream) {
68+
// drain
69+
}
70+
71+
expect(prepareStep).toHaveBeenCalled();
72+
});
73+
74+
it("should allow per-call prepareStep to override agent-level", async () => {
75+
const agentPrepareStep = vi.fn(() => ({}));
76+
const callPrepareStep = vi.fn(() => ({}));
77+
const model = createMockLanguageModel({
78+
doGenerate: {
79+
...defaultMockResponse,
80+
content: [{ type: "text", text: "done" }],
81+
},
82+
});
83+
84+
const agent = new Agent({
85+
name: "test-agent",
86+
instructions: "test",
87+
model,
88+
prepareStep: agentPrepareStep,
89+
});
90+
91+
await agent.generateText("hello", {
92+
prepareStep: callPrepareStep,
93+
});
94+
95+
// per-call should be used, not agent-level
96+
expect(callPrepareStep).toHaveBeenCalled();
97+
expect(agentPrepareStep).not.toHaveBeenCalled();
98+
});
99+
100+
it("should allow per-call prepareStep to override agent-level in streamText", async () => {
101+
const agentPrepareStep = vi.fn(() => ({}));
102+
const callPrepareStep = vi.fn(() => ({}));
103+
const model = createMockLanguageModel();
104+
105+
const agent = new Agent({
106+
name: "test-agent",
107+
instructions: "test",
108+
model,
109+
prepareStep: agentPrepareStep,
110+
});
111+
112+
const result = await agent.streamText("hello", {
113+
prepareStep: callPrepareStep,
114+
});
115+
for await (const _part of result.textStream) {
116+
// drain
117+
}
118+
119+
expect(callPrepareStep).toHaveBeenCalled();
120+
expect(agentPrepareStep).not.toHaveBeenCalled();
121+
});
122+
});

packages/core/src/agent/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
ProviderTextResponse,
1212
ProviderTextStreamResponse,
1313
} from "../agent/providers/base/types";
14-
import type { StopWhen } from "../ai-types";
14+
import type { PrepareStep, StopWhen } from "../ai-types";
1515

1616
import type { LanguageModel, TextStreamPart, UIMessage } from "ai";
1717
import type { Memory } from "../memory";
@@ -723,6 +723,17 @@ export type AgentOptions = {
723723
* Per-call `stopWhen` in method options overrides this.
724724
*/
725725
stopWhen?: StopWhen;
726+
/**
727+
* Default step preparation callback (ai-sdk `prepareStep`).
728+
* Called before each step to control tool availability, tool choice, etc.
729+
* Per-call `prepareStep` in method options overrides this.
730+
*
731+
* @example
732+
* ```ts
733+
* prepareStep: ({ steps }) => (steps.length > 0 ? { toolChoice: 'none' } : {}),
734+
* ```
735+
*/
736+
prepareStep?: PrepareStep;
726737
markdown?: boolean;
727738
/**
728739
* When true, use the active VoltAgent span as the parent if parentSpan is not provided.

packages/core/src/ai-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ import type { generateText } from "ai";
33

44
// StopWhen predicate type used by ai-sdk generate/stream functions
55
export type StopWhen = Parameters<typeof generateText>[0]["stopWhen"];
6+
7+
// PrepareStep callback type used by ai-sdk generate/stream functions
8+
export type PrepareStep = Parameters<typeof generateText>[0]["prepareStep"];

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ export { createAsyncIterableStream, type AsyncIterableStream } from "@voltagent/
302302
// Convenience re-exports from ai-sdk so apps need only @voltagent/core
303303
export { stepCountIs, hasToolCall } from "ai";
304304
export type { LanguageModel } from "ai";
305-
export type { StopWhen } from "./ai-types";
305+
export type { PrepareStep, StopWhen } from "./ai-types";
306306

307307
export type {
308308
ManagedMemoryStatus,

website/docs/getting-started/migration-guide.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,19 @@ console.log(out.context); // VoltAgent Map
610610
- This overrides VoltAgent's default `stepCountIs(maxSteps)` guard.
611611
- Be cautious: permissive predicates can lead to long-running or looping generations; overly strict ones may stop before tools complete.
612612

613+
### prepareStep callback (advanced)
614+
615+
- You can pass an ai-sdk `prepareStep` callback in `AgentOptions` or in per-call method options to control tool availability, tool choice, and other settings before each step.
616+
- Per-call `prepareStep` overrides the agent-level default.
617+
- Example: force text-only output after the first step:
618+
```ts
619+
const agent = new Agent({
620+
name: "my-agent",
621+
model,
622+
prepareStep: ({ steps }) => (steps.length > 0 ? { toolChoice: "none" } : {}),
623+
});
624+
```
625+
613626
### Built-in server removed; use `@voltagent/server-hono`
614627

615628
VoltAgent 1.x decouples the HTTP server from `@voltagent/core`. The built-in server is removed in favor of pluggable server providers. The recommended provider is `@voltagent/server-hono` (powered by Hono). Default port remains `3141`.

0 commit comments

Comments
 (0)