#18450 · @potlee · opened Mar 20, 2026 at 11:37 PM UTC · last updated Mar 21, 2026 at 12:52 AM UTC

feat: use native Output.object() for structured output

appfeat
62
+212964 files

Score breakdown

Impact

8.0

Clarity

9.0

Urgency

4.0

Ease Of Review

8.0

Guidelines

8.0

Readiness

8.0

Size

9.0

Trust

5.0

Traction

0.0

Summary

This PR refactors the structured output mechanism to leverage the AI SDK's native Output.object(), replacing ~300 lines of custom and fragile code. It enhances system reliability and better integrates with provider-native JSON modes.

Open in GitHub

Description

Issue for this PR

Closes #

Type of change

  • [ ] Bug fix
  • [ ] New feature
  • [x] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Switches structured output from a custom StructuredOutput tool to the AI SDK's native Output.object() via experimental_output.

Previously, when format.type === "json_schema" was set, the code would:

  1. Inject a synthetic StructuredOutput tool
  2. Add a system prompt telling the model to call that tool
  3. Force toolChoice: "required" so the model had to call it
  4. Capture the tool args as the structured output

This was fragile — it consumed a tool slot, required prompt engineering to make the model cooperate, and bypassed the provider's native JSON mode support.

Now it passes experimental_output: Output.object({ schema }) to streamText(), which sets responseFormat: { type: "json", schema } on the underlying provider call. Providers that support native JSON schema will use their built-in mechanisms, AI SDK handles the fallback to using a tool when structured output is not supported. The model outputs JSON text directly, the processor parses it, and tools still work alongside structured output for multi-step workflows.

Net removal of ~300 lines of tool scaffolding, system prompt injection, and manual plumbing.

How did you verify your code works?

  • bun typecheck passes with 0 errors (from packages/opencode)
  • bun test test/session/structured-output.test.ts — 13 pass, 0 fail
  • bun test test/session/structured-output-integration.test.ts — 1 pass, 4 skipped (require API key), 0 fail

Screenshots / recordings

N/A — no UI changes.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

None.

Comments

No comments.

Changed Files

packages/opencode/src/session/llm.ts

+60
@@ -10,6 +10,7 @@ import {
type ToolSet,
tool,
jsonSchema,
Output,
} from "ai"
import { mergeDeep, pipe } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
@@ -41,6 +42,7 @@ export namespace LLM {
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
format?: MessageV2.OutputFormat
}
export type StreamOutput = StreamTextResult<ToolSet, unknown>
@@ -214,6 +216,10 @@ export namespace LLM {
}
return streamText({
experimental_output:
input.format?.type === "json_schema"
? Output.object({ schema: jsonSchema(input.format.schema as any) })
: undefined,
onError(error) {
l.error("stream error", {
error,

packages/opencode/src/session/processor.ts

+100
@@ -35,6 +35,7 @@ export namespace SessionProcessor {
let blocked = false
let attempt = 0
let needsCompaction = false
let lastText: string | undefined
const result = {
get message() {
@@ -330,6 +331,7 @@ export namespace SessionProcessor {
{ text: currentText.text },
)
currentText.text = textOutput.text
lastText = currentText.text
currentText.time = {
start: Date.now(),
end: Date.now(),
@@ -351,6 +353,14 @@ export namespace SessionProcessor {
}
if (needsCompaction) break
}
// Extract structured output from text when using native json_schema
if (streamInput.format?.type === "json_schema" && lastText) {
try {
input.assistantMessage.structured = JSON.parse(lastText)
} catch {
// JSON parse failed - will be handled as StructuredOutputError in prompt.ts
}
}
} catch (e: any) {
log.error("process", {
error: e

packages/opencode/src/session/prompt.ts

+566
@@ -53,16 +53,6 @@ import { Process } from "@/util/process"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
IMPORTANT:
- You MUST call this tool exactly once at the end of your response
- The input must be valid JSON matching the required schema
- Complete all necessary research and tool calls BEFORE calling this tool
- This tool provides your final answer - no further actions are taken after calling it`
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
@@ -288,11 +278,6 @@ export namespace SessionPrompt {
using _ = defer(() => cancel(sessionID))
// Structured output state
// Note: On session resumption, state is reset but outputFormat is preserved
// on the user message and will be retrieved

packages/opencode/test/session/structured-output.test.ts

+0230
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionID, MessageID } from "../../src/session/schema"
describe("structured-output.OutputFormat", () => {
@@ -155,232 +154,3 @@ describe("structured-output.AssistantMessage", () => {
expect(result.success).toBe(true)
})
})
describe("structured-output.createStructuredOutputTool", () => {
test("creates tool with correct id", () => {
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object", properties: { name: { type: "string" } } },
onSuccess: () => {},
})
// AI SDK tool type doesn't expose id, but we set it internally
expect((tool as any).id).toBe("StructuredOutput")
})
test("creates tool with description", () => {
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object" },
onSuccess: () => {},
})
expect(tool.description).toContain("structured format")
})
test("creates tool with schema as inputSchema", () => {
const schema = {
type: "o