Skip to content
Open
15 changes: 0 additions & 15 deletions logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json

This file was deleted.

48 changes: 0 additions & 48 deletions logs/mcp-puppeteer-2025-10-07.log

This file was deleted.

2 changes: 2 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export namespace LLM {
small?: boolean
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
}

export type StreamOutput = StreamTextResult<ToolSet, unknown>
Expand Down Expand Up @@ -195,6 +196,7 @@ export namespace LLM {
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid" && x !== "_noop"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens,
abortSignal: input.abort,
headers: {
Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import { type SystemError } from "bun"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
export const StructuredOutputError = NamedError.create(
"StructuredOutputError",
z.object({
message: z.string(),
retries: z.number(),
}),
)
export const AuthError = NamedError.create(
"ProviderAuthError",
z.object({
Expand All @@ -35,6 +42,28 @@ export namespace MessageV2 {
)
export type APIError = z.infer<typeof APIError.Schema>

export const OutputFormatText = z.object({
type: z.literal("text"),
}).meta({
ref: "OutputFormatText",
})

export const OutputFormatJsonSchema = z.object({
type: z.literal("json_schema"),
schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }),
retryCount: z.number().int().min(0).default(2),
}).meta({
ref: "OutputFormatJsonSchema",
})

export const OutputFormat = z.discriminatedUnion("type", [
OutputFormatText,
OutputFormatJsonSchema,
]).meta({
ref: "OutputFormat",
})
export type OutputFormat = z.infer<typeof OutputFormat>

const PartBase = z.object({
id: z.string(),
sessionID: z.string(),
Expand Down Expand Up @@ -306,6 +335,7 @@ export namespace MessageV2 {
time: z.object({
created: z.number(),
}),
outputFormat: OutputFormat.optional(),
summary: z
.object({
title: z.string().optional(),
Expand Down Expand Up @@ -358,6 +388,7 @@ export namespace MessageV2 {
NamedError.Unknown.Schema,
OutputLengthError.Schema,
AbortedError.Schema,
StructuredOutputError.Schema,
APIError.Schema,
])
.optional(),
Expand All @@ -384,6 +415,7 @@ export namespace MessageV2 {
write: z.number(),
}),
}),
structured_output: z.any().optional(),
finish: z.string().optional(),
}).meta({
ref: "AssistantMessage",
Expand Down
102 changes: 99 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ import { Shell } from "@/shell/shell"
// @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" })
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
Expand Down Expand Up @@ -88,7 +98,7 @@ export namespace SessionPrompt {
.object({
providerID: z.string(),
modelID: z.string(),
})
})
.optional(),
agent: z.string().optional(),
noReply: z.boolean().optional(),
Expand All @@ -98,6 +108,7 @@ export namespace SessionPrompt {
.describe(
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
),
outputFormat: MessageV2.OutputFormat.optional(),
system: z.string().optional(),
variant: z.string().optional(),
parts: z.array(
Expand Down Expand Up @@ -265,6 +276,11 @@ 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 from lastUser below
let structuredOutput: unknown | undefined

let step = 0
const session = await Session.get(sessionID)
while (true) {
Expand Down Expand Up @@ -557,6 +573,10 @@ export namespace SessionPrompt {
model,
tools: lastUser.tools,
processor,
outputFormat: lastUser.outputFormat ?? { type: "text" },
onStructuredOutputSuccess: (output) => {
structuredOutput = output
},
bypassAgentCheck,
})

Expand Down Expand Up @@ -590,12 +610,19 @@ export namespace SessionPrompt {

await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages })

// Build system prompt, adding structured output instruction if needed
const systemPrompts = [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())]
const outputFormat = lastUser.outputFormat ?? { type: "text" }
if (outputFormat.type === "json_schema") {
systemPrompts.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
}

const result = await processor.process({
user: lastUser,
agent,
abort,
sessionID,
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
system: systemPrompts,
messages: [
...MessageV2.toModelMessage(sessionMessages),
...(isLastStep
Expand All @@ -609,7 +636,34 @@ export namespace SessionPrompt {
],
tools,
model,
toolChoice: outputFormat.type === "json_schema" ? "required" : undefined,
})

// If structured output was captured, save it and exit immediately
// This takes priority because the StructuredOutput tool was called successfully
if (structuredOutput !== undefined) {
processor.message.structured_output = structuredOutput
processor.message.finish = processor.message.finish ?? "stop"
await Session.updateMessage(processor.message)
break
}

// Check if model finished (finish reason is not "tool-calls" or "unknown")
const modelFinished =
processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)

if (modelFinished && !processor.message.error) {
if (outputFormat.type === "json_schema") {
// Model stopped without calling StructuredOutput tool
processor.message.error = new MessageV2.StructuredOutputError({
message: "Model did not produce structured output",
retries: 0,
}).toObject()
await Session.updateMessage(processor.message)
break
}
}

if (result === "stop") break
if (result === "compact") {
await SessionCompaction.create({
Expand Down Expand Up @@ -640,12 +694,15 @@ export namespace SessionPrompt {
return Provider.defaultModel()
}

async function resolveTools(input: {
/** @internal Exported for testing */
export async function resolveTools(input: {
agent: Agent.Info
model: Provider.Model
session: Session.Info
tools?: Record<string, boolean>
processor: SessionProcessor.Info
outputFormat: MessageV2.OutputFormat
onStructuredOutputSuccess?: (output: unknown) => void
bypassAgentCheck: boolean
}) {
using _ = log.time("resolveTools")
Expand Down Expand Up @@ -818,9 +875,47 @@ export namespace SessionPrompt {
tools[key] = item
}

// Inject StructuredOutput tool if JSON schema mode enabled
if (input.outputFormat.type === "json_schema" && input.onStructuredOutputSuccess) {
tools["StructuredOutput"] = createStructuredOutputTool({
schema: input.outputFormat.schema,
onSuccess: input.onStructuredOutputSuccess,
})
}

return tools
}

/** @internal Exported for testing */
export function createStructuredOutputTool(input: {
schema: Record<string, any>
onSuccess: (output: unknown) => void
}): AITool {
// Remove $schema property if present (not needed for tool input)
const { $schema, ...toolSchema } = input.schema

return tool({
id: "StructuredOutput" as any,
description: STRUCTURED_OUTPUT_DESCRIPTION,
inputSchema: jsonSchema(toolSchema as any),
async execute(args) {
// AI SDK validates args against inputSchema before calling execute()
input.onSuccess(args)
return {
output: "Structured output captured successfully.",
title: "Structured Output",
metadata: { valid: true },
}
},
toModelOutput(result) {
return {
type: "text",
value: result.output,
}
},
})
}

async function createUserMessage(input: PromptInput) {
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
const info: MessageV2.Info = {
Expand All @@ -834,6 +929,7 @@ export namespace SessionPrompt {
agent: agent.name,
model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
system: input.system,
outputFormat: input.outputFormat,
variant: input.variant,
}

Expand Down
Loading