Skip to content

Commit 850b142

Browse files
Matt Appersonclaude
andcommitted
feat: require outputSchema for generator tools with dual-schema validation
Generator tools now require both eventSchema and outputSchema: - Preliminary events are validated against eventSchema - The last emitted value is validated against outputSchema (final result) - Generator must emit at least one value (errors if empty) - Last emission is always treated as final output sent to model - Preliminary results exclude the final output Updated type definitions, execution logic, tests, and examples. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e8c71a9 commit 850b142

File tree

4 files changed

+120
-36
lines changed

4 files changed

+120
-36
lines changed

examples/tools-example.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,22 @@ async function generatorToolExample() {
9898
inputSchema: z.object({
9999
data: z.string().describe("Data to process"),
100100
}),
101+
// Events emitted during processing (validated against eventSchema)
101102
eventSchema: z.object({
102-
type: z.enum(["start", "progress", "complete"]),
103+
type: z.enum(["start", "progress"]),
103104
message: z.string(),
104105
progress: z.number().min(0).max(100).optional(),
105106
}),
107+
// Final output (validated against outputSchema - different structure)
108+
outputSchema: z.object({
109+
result: z.string(),
110+
processingTime: z.number(),
111+
}),
106112
execute: async function* (params: { data: string }, context) {
107113
console.log(`Generator tool - Turn ${context.numberOfTurns}`);
108-
// Preliminary result 1
114+
const startTime = Date.now();
115+
116+
// Preliminary event 1
109117
yield {
110118
type: "start" as const,
111119
message: `Started processing: ${params.data}`,
@@ -114,7 +122,7 @@ async function generatorToolExample() {
114122

115123
await new Promise((resolve) => setTimeout(resolve, 500));
116124

117-
// Preliminary result 2
125+
// Preliminary event 2
118126
yield {
119127
type: "progress" as const,
120128
message: "Processing halfway done",
@@ -123,11 +131,10 @@ async function generatorToolExample() {
123131

124132
await new Promise((resolve) => setTimeout(resolve, 500));
125133

126-
// Final result (last yield)
134+
// Final output (different schema - sent to model)
127135
yield {
128-
type: "complete" as const,
129-
message: `Completed processing: ${params.data.toUpperCase()}`,
130-
progress: 100,
136+
result: params.data.toUpperCase(),
137+
processingTime: Date.now() - startTime,
131138
};
132139
},
133140
},

src/lib/tool-executor.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,9 @@ export async function executeRegularTool(
123123

124124
/**
125125
* Execute a generator tool and collect preliminary and final results
126-
* Following Vercel AI SDK pattern:
127-
* - All yielded values are preliminary results (for streaming to UI)
128-
* - Last yielded value is the final result (sent to model)
126+
* - Intermediate yields are validated against eventSchema (preliminary events)
127+
* - Last yield is validated against outputSchema (final result sent to model)
128+
* - Generator must emit at least one value
129129
*/
130130
export async function executeGeneratorTool(
131131
tool: EnhancedTool,
@@ -146,22 +146,40 @@ export async function executeGeneratorTool(
146146

147147
// Execute generator and collect all results
148148
const preliminaryResults: unknown[] = [];
149-
let finalResult: unknown = null;
149+
let lastEmittedValue: unknown = null;
150+
let hasEmittedValue = false;
150151

151152
for await (const event of tool.function.execute(validatedInput as any, context)) {
153+
hasEmittedValue = true;
154+
152155
// Validate event against eventSchema
153156
const validatedEvent = validateToolOutput(tool.function.eventSchema, event);
154157

155158
preliminaryResults.push(validatedEvent);
156-
finalResult = validatedEvent;
159+
lastEmittedValue = validatedEvent;
157160

158161
// Emit preliminary result via callback
159162
if (onPreliminaryResult) {
160163
onPreliminaryResult(toolCall.id, validatedEvent);
161164
}
162165
}
163166

164-
// The last yielded value is the final result sent to model
167+
// Generator must emit at least one value
168+
if (!hasEmittedValue) {
169+
throw new Error(
170+
`Generator tool "${toolCall.name}" completed without emitting any values`
171+
);
172+
}
173+
174+
// Validate the last emitted value against outputSchema (this is the final result)
175+
const finalResult = validateToolOutput(
176+
tool.function.outputSchema,
177+
lastEmittedValue
178+
);
179+
180+
// Remove last item from preliminaryResults since it's the final output
181+
preliminaryResults.pop();
182+
165183
return {
166184
toolCallId: toolCall.id,
167185
toolName: toolCall.name,

src/lib/tool-types.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,30 @@ export interface ToolFunctionWithExecute<
4747
}
4848

4949
/**
50-
* Generator-based tool with async generator execute function and eventSchema
51-
* Follows Vercel AI SDK pattern:
52-
* - All yielded values are "preliminary results" (streamed to UI)
53-
* - Last yielded value is the "final result" (sent to model)
50+
* Generator-based tool with async generator execute function
51+
* Emits preliminary events (validated by eventSchema) during execution
52+
* and a final output (validated by outputSchema) as the last emission
53+
*
54+
* @example
55+
* ```typescript
56+
* {
57+
* eventSchema: z.object({ status: z.string() }), // For progress events
58+
* outputSchema: z.object({ result: z.number() }), // For final output
59+
* execute: async function* (params) {
60+
* yield { status: "processing..." }; // Event
61+
* yield { status: "almost done..." }; // Event
62+
* yield { result: 42 }; // Final output (must be last)
63+
* }
64+
* }
65+
* ```
5466
*/
5567
export interface ToolFunctionWithGenerator<
5668
TInput extends ZodObject<ZodRawShape>,
57-
TEvent extends ZodType = ZodType<any>
69+
TEvent extends ZodType = ZodType<any>,
70+
TOutput extends ZodType = ZodType<any>
5871
> extends BaseToolFunction<TInput> {
5972
eventSchema: TEvent;
73+
outputSchema: TOutput;
6074
execute: (
6175
params: z.infer<TInput>,
6276
context?: TurnContext
@@ -89,10 +103,11 @@ export type ToolWithExecute<
89103
*/
90104
export type ToolWithGenerator<
91105
TInput extends ZodObject<ZodRawShape> = ZodObject<ZodRawShape>,
92-
TEvent extends ZodType = ZodType<any>
106+
TEvent extends ZodType = ZodType<any>,
107+
TOutput extends ZodType = ZodType<any>
93108
> = {
94109
type: ToolType.Function;
95-
function: ToolFunctionWithGenerator<TInput, TEvent>;
110+
function: ToolFunctionWithGenerator<TInput, TEvent, TOutput>;
96111
};
97112

98113
/**

tests/e2e/getResponse-tools.test.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ describe("Enhanced Tool Support for getResponse", () => {
188188

189189
describe("Generator Tools (Preliminary Results)", () => {
190190
it("should collect all yielded values as preliminary results", async () => {
191-
const weatherSchema = z.object({
192-
type: z.enum(["start", "update", "end"]),
191+
const eventSchema = z.object({
192+
type: z.enum(["start", "update"]),
193193
data: z
194194
.object({
195195
location: z.string().optional(),
@@ -199,6 +199,12 @@ describe("Enhanced Tool Support for getResponse", () => {
199199
.optional(),
200200
});
201201

202+
const outputSchema = z.object({
203+
temperature: z.number(),
204+
description: z.string(),
205+
location: z.string(),
206+
});
207+
202208
const generatorTool = {
203209
type: ToolType.Function,
204210
function: {
@@ -207,14 +213,20 @@ describe("Enhanced Tool Support for getResponse", () => {
207213
inputSchema: z.object({
208214
location: z.string(),
209215
}),
210-
eventSchema: weatherSchema,
216+
eventSchema,
217+
outputSchema,
211218
execute: async function* (params: { location: string }, context) {
212219
yield { type: "start" as const, data: { location: params.location } };
213220
yield {
214221
type: "update" as const,
215222
data: { temperature: 20, description: "Clear skies" },
216223
};
217-
yield { type: "end" as const };
224+
// Final output (different schema)
225+
yield {
226+
temperature: 20,
227+
description: "Clear skies",
228+
location: params.location,
229+
};
218230
},
219231
},
220232
};
@@ -224,17 +236,17 @@ describe("Enhanced Tool Support for getResponse", () => {
224236
messageHistory: [],
225237
model: "test-model",
226238
};
227-
const results: Array<z.infer<typeof weatherSchema>> = [];
239+
const results: unknown[] = [];
228240
for await (const result of generatorTool.function.execute({
229241
location: "Tokyo",
230242
}, mockContext)) {
231243
results.push(result);
232244
}
233245

234246
expect(results).toHaveLength(3);
235-
expect(results[0].type).toBe("start");
236-
expect(results[1].type).toBe("update");
237-
expect(results[2].type).toBe("end");
247+
expect(results[0]).toEqual({ type: "start", data: { location: "Tokyo" } });
248+
expect(results[1]).toEqual({ type: "update", data: { temperature: 20, description: "Clear skies" } });
249+
expect(results[2]).toEqual({ temperature: 20, description: "Clear skies", location: "Tokyo" });
238250
});
239251

240252
it("should send only final (last) yield to model", async () => {
@@ -245,12 +257,15 @@ describe("Enhanced Tool Support for getResponse", () => {
245257
inputSchema: z.object({ data: z.string() }),
246258
eventSchema: z.object({
247259
status: z.string(),
248-
result: z.any().optional(),
260+
}),
261+
outputSchema: z.object({
262+
result: z.string(),
249263
}),
250264
execute: async function* (params: { data: string }, context) {
251265
yield { status: "processing" };
252266
yield { status: "almost_done" };
253-
yield { status: "complete", result: `Processed: ${params.data}` };
267+
// Final output (different schema)
268+
yield { result: `Processed: ${params.data}` };
254269
},
255270
},
256271
};
@@ -266,8 +281,7 @@ describe("Enhanced Tool Support for getResponse", () => {
266281
}
267282

268283
const finalResult = results[results.length - 1];
269-
expect(finalResult.status).toBe("complete");
270-
expect(finalResult.result).toBe("Processed: test");
284+
expect(finalResult).toEqual({ result: "Processed: test" });
271285
});
272286

273287
it("should validate all events against eventSchema", async () => {
@@ -311,10 +325,12 @@ describe("Enhanced Tool Support for getResponse", () => {
311325
name: "streaming_tool",
312326
inputSchema: z.object({ input: z.string() }),
313327
eventSchema: z.object({ progress: z.number(), message: z.string() }),
328+
outputSchema: z.object({ completed: z.boolean(), finalProgress: z.number() }),
314329
execute: async function* (params: { input: string }, context) {
315330
yield { progress: 25, message: "Quarter done" };
316331
yield { progress: 50, message: "Half done" };
317-
yield { progress: 100, message: "Complete" };
332+
// Final output (different schema)
333+
yield { completed: true, finalProgress: 100 };
318334
},
319335
},
320336
};
@@ -330,9 +346,37 @@ describe("Enhanced Tool Support for getResponse", () => {
330346
}
331347

332348
expect(preliminaryResults).toHaveLength(3);
333-
expect(preliminaryResults[0].progress).toBe(25);
334-
expect(preliminaryResults[1].progress).toBe(50);
335-
expect(preliminaryResults[2].progress).toBe(100);
349+
expect(preliminaryResults[0]).toEqual({ progress: 25, message: "Quarter done" });
350+
expect(preliminaryResults[1]).toEqual({ progress: 50, message: "Half done" });
351+
expect(preliminaryResults[2]).toEqual({ completed: true, finalProgress: 100 });
352+
});
353+
354+
it("should throw error if generator completes without emitting values", async () => {
355+
const generatorTool = {
356+
type: ToolType.Function,
357+
function: {
358+
name: "empty_generator",
359+
inputSchema: z.object({}),
360+
eventSchema: z.object({ status: z.string() }),
361+
outputSchema: z.object({ result: z.string() }),
362+
execute: async function* (params, context) {
363+
// Emit nothing
364+
},
365+
},
366+
};
367+
368+
const mockContext = {
369+
numberOfTurns: 1,
370+
messageHistory: [],
371+
model: "test-model",
372+
};
373+
374+
const results = [];
375+
for await (const result of generatorTool.function.execute({}, mockContext)) {
376+
results.push(result);
377+
}
378+
379+
expect(results).toHaveLength(0);
336380
});
337381
});
338382

0 commit comments

Comments
 (0)