-
Notifications
You must be signed in to change notification settings - Fork 52
feat: add structured output support with Zod schema validation #402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Implement structured output functionality that enables type-safe, validated responses from LLMs using Zod schemas. Includes automatic validation retry logic and seamless agent integration. Key changes: - Add StructuredOutputContext for per-invocation lifecycle management - Implement StructuredOutputTool with Zod validation and error formatting - Enhance ToolRegistry with dynamic tool registration - Add structuredOutput field to AgentResult with type inference - Add structuredOutputSchema to AgentConfig - Update documentation with usage examples - Create structured-output example Resolves #111
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
…output Address PR review feedback: - Rename structuredOutputSchema to structuredOutputModel (Python SDK naming convention) - Rename schema_converter.ts to structured_output_utils.ts - Rename publicTool variable to registeredTool - Remove verbose JSDoc comment Implement two-phase storage pattern matching Python SDK: - Phase 1 (Store): Results stored in temporary storage during tool execution - Phase 2 (Extract): Results extracted after all tools execute - Add hasResult(), extractResult(), getToolName() methods to context Add forced tool execution for guaranteed structured output: - Add _forcedToolChoice field to Agent - Force structured output tool when LLM returns without calling it - Pass toolChoice to model via StreamOptions - Clear forced tool choice after successful execution This ensures structuredOutput is always returned when a schema is provided, matching the Python SDK's guaranteed result behavior.
This comment was marked as resolved.
This comment was marked as resolved.
- Remove validationErrors, toolName, toolUseId fields - Exception now only has message (like Python SDK) - Raised only when LLM refuses to call tool after being forced - Add forceAttempted tracking in agent loop - Throw exception when forced execution fails - Update tests for simplified exception class
src/agent/agent.ts
Outdated
| const toolResultMessage = yield* this.executeTools(modelResult.message, this._toolRegistry) | ||
|
|
||
| // Extract structured output result AFTER all tools execute (two-phase pattern) | ||
| if (context) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of possibly having undefined, let's use the null object pattern - a context that does nothing if we didn't have a schema; that will clean up the code and make it more clear I think
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done! Implemented null object pattern with NullStructuredOutputContext and factory function createStructuredOutputContext(). The agent code no longer has if (context) checks.
| * The validated structured output from the LLM, if a schema was provided. | ||
| * Type is inferred from the Zod schema using z.infer. | ||
| */ | ||
| readonly structuredOutput?: T | undefined |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The | undefined seems odd given that it's optional on ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The | undefined is actually required due to exactOptionalPropertyTypes: true in tsconfig. Without it, TypeScript throws an error when assigning undefined to the field.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When would we ever assign undefined to this field? Shouldn't that be prevented?
| expect(tool.name).toBe('PersonSchema') | ||
| }) | ||
|
|
||
| it('includes structured output description', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should be able to assert on the entire description string exactly
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done! Updated tests to assert on entire objects with exact values including inputSchema, error messages, etc.
| }) | ||
|
|
||
| it('includes input schema from Zod schema', () => { | ||
| expect(tool.toolSpec.inputSchema).toBeDefined() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Expect that the entire inputSpec is exactly as we expect it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done! Tests now assert the complete toolSpec object including additionalProperties: false in the inputSchema.
| expect(result.value.content).toHaveLength(1) | ||
|
|
||
| const errorContent = result.value.content[0] as any | ||
| expect(errorContent.text).toContain('Validation failed') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, assert on the entire object; everything is deterministic so let's not just check contains
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done! Updated to use exact string matching for error messages.
| * 1. Phase 1 (Store): During tool execution, results are stored in temporary storage | ||
| * 2. Phase 2 (Extract): After all tools execute, the result is extracted from temporary storage | ||
| */ | ||
| export class StructuredOutputContext { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of curiosity, could we just merge StructuredOutputTool and this class? E.g. can we just use the tool?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered this but kept them separate because: 1) The context manages the two-phase storage and tool lifecycle, 2) The tool handles validation and result storage per-invocation, 3) The null object pattern requires the interface/context separation. Could merge them but the responsibilities feel cleanly separated. Happy to refactor if you prefer.
|
/strands implement |
Address zastrowm's review feedback: Agent changes: - Pass toolChoice as parameter to invokeModel instead of class field - Move BeforeInvocationEvent before tool registration - Clear forcedToolChoice after use, not as class field cleanup StructuredOutputContext changes: - Make schema required (not optional) - Add extractResultFromMessage() to encapsulate toolUseIds extraction - Remove unnecessary temporaryStorage.clear() in cleanup ToolRegistry changes: - Remove dynamic tools functionality - Use regular add()/removeByName() for structured output tool - Simplify getToolsForModel() to just return values() Tests: - Update context tests to use extractResultFromMessage - Update tool tests to use instanceof checks instead of exact object matching - Remove tests for removed functionality
|
/strands implement |
…act test assertions Address remaining review feedback: Null object pattern: - Add IStructuredOutputContext interface - Add NullStructuredOutputContext (no-op implementation) - Add createStructuredOutputContext() factory function - Agent now uses context without undefined checks (cleaner code) Cleanup: - Remove examples/structured-output directory (defer to maintainer on location) - Remove structured_output/index.ts (export directly from files) - Update src/index.ts with direct exports Tests: - Update tool tests to assert on exact objects (inputSchema, error messages) - Add NullStructuredOutputContext tests - Add createStructuredOutputContext tests
| yield new BeforeInvocationEvent({ agent: this }) | ||
|
|
||
| // Register structured output tool (no-op if null context) | ||
| context.registerTool(this._toolRegistry) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is it removed from the registry and do we have a test verifying that?
| // Continue loop | ||
| } | ||
| } finally { | ||
| // Always cleanup structured output context (no-op for null context) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove comments about "no-op for null contexts"; that's an implementation detail that we don't need here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same for above
| currentArgs = undefined // Only pass args on first invocation | ||
| forcedToolChoice = undefined // Clear after use | ||
|
|
||
| if (modelResult.stopReason !== 'toolUse') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is modelResult.stopReason !== 'toolUse' but forceAttempted something we should be handling here?
| const modelResult = yield* this.invokeModel(currentArgs) | ||
| const modelResult = yield* this.invokeModel(currentArgs, forcedToolChoice) | ||
| currentArgs = undefined // Only pass args on first invocation | ||
| forcedToolChoice = undefined // Clear after use |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there ever a case where forceAttempted is true but forcedToolChoice is undefined?
If not, can forceAttempted just be substituted by forcedToolChoise != undefined
| const toolName = context.getToolName() | ||
| forcedToolChoice = { tool: { name: toolName } } | ||
| forceAttempted = true | ||
| // Continue loop without adding messages (don't re-add user message) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does "don't re-add user message" mean here?
| } | ||
|
|
||
| const toolSpecs = this._toolRegistry.values().map((tool) => tool.toolSpec) | ||
| const toolSpecs = this._toolRegistry.getToolsForModel().map((tool) => tool.toolSpec) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getToolsForModel isn't something that needs to exist I think?
| * @returns JSON Schema representation of the Zod schema | ||
| * @throws StructuredOutputException if the schema contains unsupported features | ||
| */ | ||
| export function convertSchemaToJsonSchema(schema: z.ZodSchema): JSONSchema { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re-use the same logic as ZodTool or re-use ZodTool; I'm mostly concerned about having different logic for ZodTool and structured_output
| } | ||
|
|
||
| // Convert to JSON Schema using Zod v4's built-in toJSONSchema | ||
| const result = z4mini.toJSONSchema(schema, { target: 'draft-7' }) as JSONSchema & { $schema?: string } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes consolidate; we should not have separate logic for this
| * The validated structured output from the LLM, if a schema was provided. | ||
| * Type is inferred from the Zod schema using z.infer. | ||
| */ | ||
| readonly structuredOutput?: T | undefined |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When would we ever assign undefined to this field? Shouldn't that be prevented?
Motivation
Language model outputs are inherently unstructured text, which creates challenges when building applications that need reliable, program-friendly data. Developers must write custom parsing logic, handle validation errors, and manage retry logic when the LLM produces malformed output. This implementation brings structured output functionality from the Python SDK to TypeScript, enabling type-safe, validated responses from LLMs using Zod schemas.
Resolves #111
Public API Changes
AgentConfig - New Option
AgentResult - New Field
The
AgentResultclass now includes an optionalstructuredOutputfield with automatic type inference from the provided Zod schema:New Exports
Implementation Overview
Structured output works through a multi-step process integrated into the agent loop:
structuredOutputModel, the agent creates a per-invocation context that registers a hidden validation tool with the modeltoolChoice, ensuring structured output is always returnedKey Design Decisions
Dynamic Tool Registration: Structured output tools are registered as "dynamic tools" in a separate namespace within ToolRegistry. They're included in model tool specifications but hidden from the public
agent.toolsaccessor.Two-Phase Storage Pattern: Matches the Python SDK implementation - results are stored during tool execution (Phase 1) and extracted after all tools complete (Phase 2). This enables proper result handling when multiple tools are used in a single turn.
Forced Tool Execution: When the LLM returns
end_turnwithout calling the structured output tool, the agent forces the model to call it usingtoolChoice: { tool: { name: 'StructuredOutput' } }. This guarantees structured output is always returned when a schema is provided.Per-Invocation Lifecycle: Each agent invocation creates its own StructuredOutputContext instance, enabling concurrent invocations with different schemas.
Use Cases