Skip to content

Conversation

@mehtarac
Copy link
Member

@mehtarac mehtarac commented Jan 13, 2026

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

import { Agent } from '@strands-agents/sdk'
import { z } from 'zod'

const PersonSchema = z.object({
  name: z.string().describe('Full name'),
  age: z.number().describe('Age in years'),
  occupation: z.string().describe('Job title')
})

// Agent with default schema for all invocations
const agent = new Agent({
  structuredOutputModel: PersonSchema
})

const result = await agent.invoke('John Smith is a 30 year-old engineer')
console.log(result.structuredOutput) // { name: "John Smith", age: 30, occupation: "engineer" }

AgentResult - New Field

The AgentResult class now includes an optional structuredOutput field with automatic type inference from the provided Zod schema:

// Type is automatically inferred from schema
const result = await agent.invoke(prompt, {
  structuredOutputModel: PersonSchema
})
// result.structuredOutput has type: z.infer<typeof PersonSchema> | undefined

New Exports

// Exception type for structured output errors
export { StructuredOutputException } from '@strands-agents/sdk'

// Internal types (exported as types only for advanced usage)
export type { StructuredOutputContext, StructuredOutputTool } from '@strands-agents/sdk'

Implementation Overview

Structured output works through a multi-step process integrated into the agent loop:

  1. Schema Registration: When an agent invocation includes a structuredOutputModel, the agent creates a per-invocation context that registers a hidden validation tool with the model
  2. Tool Generation: The Zod schema is converted to a JSON Schema tool specification that guides the LLM to produce correctly structured output
  3. Validation: When the LLM uses the structured output tool, the response is validated against the Zod schema
  4. Two-Phase Storage: Results are stored in temporary storage during tool execution, then extracted after all tools complete (matching Python SDK pattern)
  5. Forced Execution: If the LLM returns without calling the structured output tool, it is forced to call it via toolChoice, ensuring structured output is always returned
  6. Cleanup: The validation tool is automatically removed from the registry after invocation completes (success or failure)

Key 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.tools accessor.

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_turn without calling the structured output tool, the agent forces the model to call it using toolChoice: { 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

  • API Response Parsing: Extract structured data from API documentation, error messages, or unstructured API responses
  • Data Extraction: Pull specific fields from documents, emails, or natural language descriptions
  • Form Validation: Convert natural language form inputs into validated, typed data structures
  • Multi-Step Workflows: Ensure each step produces validated output before proceeding to the next step

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
@zastrowm zastrowm marked this pull request as draft January 13, 2026 18:29
@mehtarac

This comment was marked as resolved.

@mehtarac

This comment was marked as resolved.

@github-actions github-actions bot added the strands-running <strands-managed> Whether or not an agent is currently running label Jan 14, 2026
…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.
@github-actions github-actions bot removed the strands-running <strands-managed> Whether or not an agent is currently running label Jan 14, 2026
@mehtarac

This comment was marked as resolved.

@github-actions github-actions bot added the strands-running <strands-managed> Whether or not an agent is currently running label Jan 14, 2026
- 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
@github-actions github-actions bot removed the strands-running <strands-managed> Whether or not an agent is currently running label Jan 14, 2026
@mehtarac mehtarac marked this pull request as ready for review January 14, 2026 17:39
const toolResultMessage = yield* this.executeTools(modelResult.message, this._toolRegistry)

// Extract structured output result AFTER all tools execute (two-phase pattern)
if (context) {
Copy link
Member

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

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
Copy link
Member

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 ?

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.

Copy link
Member

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', () => {
Copy link
Member

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

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()
Copy link
Member

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

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')
Copy link
Member

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

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 {
Copy link
Member

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?

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.

@mehtarac
Copy link
Member Author

/strands implement

@github-actions github-actions bot added the strands-running <strands-managed> Whether or not an agent is currently running label Jan 14, 2026
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
@github-actions github-actions bot removed the strands-running <strands-managed> Whether or not an agent is currently running label Jan 14, 2026
@mehtarac
Copy link
Member Author

/strands implement
Ensure all the comments have replies, the suggestions are addressed and that the code is functional.

@github-actions github-actions bot added the strands-running <strands-managed> Whether or not an agent is currently running label Jan 14, 2026
…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
@github-actions github-actions bot removed the strands-running <strands-managed> Whether or not an agent is currently running label Jan 14, 2026
yield new BeforeInvocationEvent({ agent: this })

// Register structured output tool (no-op if null context)
context.registerTool(this._toolRegistry)
Copy link
Member

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)
Copy link
Member

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

Copy link
Member

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') {
Copy link
Member

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
Copy link
Member

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)
Copy link
Member

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)
Copy link
Member

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 {
Copy link
Member

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 }
Copy link
Member

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
Copy link
Member

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[V1] Agent - Structured Output

3 participants