feat(server): support structured output in session generate

This commit is contained in:
R44VC0RP committed 2026-10-07 13:42:34 +00:00
1 parent 119cb15d1d
commit 21b05a1337
11 files changed
+162 -28

No files matched your search

+6 -2
View File
@@ -413,8 +413,12 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: SessionInstructionsEntryRemoveInput,
) => Effect.Effect<SessionInstructionsEntryRemoveOutput, E>
export type SessionGenerateInput = { readonly sessionID: Session.ID; readonly prompt: string }
export type SessionGenerateOutput = { readonly text: string }
export type SessionGenerateInput = {
readonly sessionID: Session.ID
readonly prompt: string
readonly schema?: { readonly [x: string]: Schema.Json } | undefined
}
export type SessionGenerateOutput = { readonly text: string; readonly object?: Schema.Json | undefined }
export type SessionGenerateOperation<E = never> = (
input: SessionGenerateInput,
) => Effect.Effect<SessionGenerateOutput, E>
@@ -662,7 +662,10 @@ const EndpointSessionInstructionsEntryRemove =
const EndpointSessionGenerate = (raw: RawClient["server.session"]) => (input: SessionGenerateInput) =>
preserveEffect<SessionGenerateOutput>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
raw["session.generate"]({
params: { sessionID: input["sessionID"] },
payload: { prompt: input["prompt"], schema: input["schema"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -954,7 +954,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/generate`,
body: { prompt: input["prompt"] },
body: { prompt: input["prompt"], schema: input["schema"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
empty: false,
@@ -179,7 +179,7 @@ export type SessionInboxCompactionPayload = {}
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionGenerateResponse = { data: { text: string; object?: JsonValue | undefined } }
export type LocationRef = { directory: string; workspaceID?: string }
@@ -4610,7 +4610,14 @@ export type SessionInstructionsEntryRemoveOutput = void
export type SessionGenerateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly prompt: { readonly prompt: string }["prompt"]
readonly prompt: {
readonly prompt: string
readonly schema?: { readonly [x: string]: JsonValue } | undefined
}["prompt"]
readonly schema?: {
readonly prompt: string
readonly schema?: { readonly [x: string]: JsonValue } | undefined
}["schema"]
}
export type SessionGenerateOutput = SessionGenerateResponse["data"]
+1 -1
View File
@@ -547,7 +547,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
switchAgent: sessions.switchAgent,
switchModel: sessions.switchModel,
prompt: sessions.prompt,
generate: (input) => sessions.generate(input).pipe(Effect.map((text) => ({ text }))),
generate: sessions.generate,
command: (input) => sessions.command({ ...input, command: input.name }),
compact: sessions.compact,
update: Effect.fn(function* (input) {
+5 -4
View File
@@ -2,7 +2,7 @@ export * as Session from "./session.js"
export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { LLMClient } from "@opencode/ai"
import { type JsonSchema, LLMClient } from "@opencode/ai"
import { ListAnchor } from "@opencode/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
@@ -186,11 +186,12 @@ export interface Interface {
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["prompt"]>
/** Generates text from current Session context without admitting input or mutating history. */
/** Generates from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
schema?: JsonSchema
}) => Effect.Effect<SessionGenerate.Result, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
sessionID: SessionSchema.ID
command: string
@@ -416,7 +417,7 @@ const layer = Layer.effect(
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionGenerate.generate({ session, prompt: input.prompt }).pipe(
return yield* SessionGenerate.generate({ session, prompt: input.prompt, schema: input.schema }).pipe(
Effect.provideService(Instance.Service, instances),
Effect.provideService(Database.Service, database),
Effect.provideService(LLMClient.Service, llm),
+59 -5
View File
@@ -1,8 +1,17 @@
export * as SessionGenerate from "./generate.js"
import type { FileSystem } from "../filesystem.js"
import { LLMClient, Message, type AIError } from "@opencode/ai"
import { Effect } from "effect"
import {
AIError,
InvalidProviderOutputError,
type JsonSchema,
LLMClient,
LLMRequest,
Message,
ToolChoice,
ToolDefinition,
} from "@opencode/ai"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { Instance } from "../instance/service.js"
import { Plugin } from "../plugin/service.js"
@@ -22,10 +31,22 @@ export type Error =
| AIError
| FileSystem.DirectoryNotFoundError
/** Generates text from current Session context without mutating the Session. */
const STRUCTURED_OUTPUT_TOOL = "structured_output"
export interface Result {
readonly text: string
/** The forced tool call's input when a `schema` was requested. It is not validated against that schema. */
readonly object?: Schema.Json
}
/**
* Generates from current Session context without mutating the Session. With a `schema`, the model is forced to call a
* synthetic tool whose input is that JSON Schema, and the call's input is returned as `object`.
*/
export const generate = Effect.fn("SessionGenerate.generate")(function* (input: {
session: SessionSchema.Info
prompt: string
schema?: JsonSchema
}) {
const instances = yield* Instance.Service
const database = yield* Database.Service
@@ -66,8 +87,41 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
providerID: model.ref.providerID,
modelID: model.ref.id,
})
const response = yield* llm.generate(prepared.request, prepared.options)
// Session tools stay advertised because some providers reject tool history without tool definitions.
const request = input.schema
? LLMRequest.update(prepared.request, {
tools: [
...prepared.request.tools,
ToolDefinition.make({
name: STRUCTURED_OUTPUT_TOOL,
description: "Return the structured result by calling this tool.",
inputSchema: input.schema,
}),
],
toolChoice: ToolChoice.named(STRUCTURED_OUTPUT_TOOL),
})
: prepared.request
const response = yield* llm.generate(request, prepared.options)
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
if (!input.schema) return { text: response.text } satisfies Result
const call = response.toolCalls.find((event) => event.name === STRUCTURED_OUTPUT_TOOL)
if (!call)
return yield* new AIError({
reason: new InvalidProviderOutputError({
message: `Model did not call the forced \`${STRUCTURED_OUTPUT_TOOL}\` tool`,
}),
})
const object = yield* Schema.decodeUnknownEffect(Schema.Json)(call.input).pipe(
Effect.mapError(
(error) =>
new AIError({
reason: new InvalidProviderOutputError({
message: `\`${STRUCTURED_OUTPUT_TOOL}\` input is not JSON: ${error.message}`,
cause: error,
}),
}),
),
)
return { text: response.text, object } satisfies Result
}).pipe(instances.provide(input.session))
})
+60 -4
View File
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import {
AIError,
LLMClient,
LLMEvent,
LLMResponse,
@@ -57,6 +58,8 @@ import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
const options: Array<StreamOptions | undefined> = []
let instruction: string | Instructions.Unavailable = "Initial context"
// Events the mock model returns after its step starts; defaults to a plain text answer.
let reply: LLMEvent[] | undefined
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
@@ -68,9 +71,11 @@ const client = Layer.mock(LLMClient.Service)({
options.push(requestOptions)
const response = LLMResponse.fromEvents([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
LLMEvent.textEnd({ id: "generate" }),
...(reply ?? [
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
LLMEvent.textEnd({ id: "generate" }),
]),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
@@ -327,7 +332,7 @@ it.effect(
Effect.provideService(Instance.Service, instances),
)
expect(result).toBe("Transient answer")
expect(result).toEqual({ text: "Transient answer" })
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
@@ -414,3 +419,54 @@ it.effect(
}),
{ timeout: 15_000 },
)
it.effect(
"forces a structured output tool when a schema is requested",
() =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
const schema = { type: "object", properties: { answer: { type: "string" } }, required: ["answer"] }
reply = [LLMEvent.toolCall({ id: "call_structured", name: "structured_output", input: { answer: "42" } })]
const { db, bus, instructions, session, instances } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const before = yield* durableState(db, sessionID)
const result = yield* SessionGenerate.generate({ session, prompt: "Answer as JSON", schema }).pipe(
Effect.provideService(Instance.Service, instances),
Effect.ensuring(Effect.sync(() => (reply = undefined))),
)
expect(result).toEqual({ text: "", object: { answer: "42" } })
expect(requests).toHaveLength(1)
expect(requests[0]?.tools).toMatchObject([
{ name: "lookup" },
{ name: "structured_output", inputSchema: schema },
])
expect(requests[0]?.toolChoice).toMatchObject({ type: "tool", name: "structured_output" })
expect(userTexts(requests[0])).toEqual(["Answer as JSON"])
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
{ timeout: 15_000 },
)
it.effect(
"fails when the model does not call the structured output tool",
() =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
const { db, bus, instructions, session, instances } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const error = yield* SessionGenerate.generate({ session, prompt: "Answer as JSON", schema: { type: "object" } }).pipe(
Effect.provideService(Instance.Service, instances),
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error).toMatchObject({ reason: { _tag: "InvalidProviderOutput" } })
expect(requests).toHaveLength(1)
}),
{ timeout: 15_000 },
)
+13 -4
View File
@@ -705,9 +705,17 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
.add(
HttpApiEndpoint.post("session.generate", "/api/session/:sessionID/generate", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ prompt: Schema.String }),
payload: Schema.Struct({
prompt: Schema.String,
schema: Schema.optional(
Schema.Record(Schema.String, Schema.Json).annotate({
description:
"JSON Schema for a structured result. The model is forced to return it as `object`; it is not validated against the schema.",
}),
),
}),
success: Schema.Struct({
data: Schema.Struct({ text: Schema.String }),
data: Schema.Struct({ text: Schema.String, object: Schema.optional(Schema.Json) }),
}).annotate({ identifier: "SessionGenerateResponse" }),
error: [SessionNotFoundError, ServiceUnavailableError],
})
@@ -715,8 +723,9 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
.annotateMerge(
OpenApi.annotations({
identifier: "session.generate",
summary: "Generate text from session context",
description: "Generate transient text from the current session context without mutating session history.",
summary: "Generate from session context",
description:
"Generate transient text, or a structured result when `schema` is provided, from the current session context without mutating session history.",
}),
),
)
+3 -3
View File
@@ -595,8 +595,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.generate",
Effect.fn(function* (ctx) {
const text = yield* session
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })
const result = yield* session
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt, schema: ctx.payload.schema })
.pipe(
Effect.mapError((error) =>
error._tag === "Session.NotFoundError"
@@ -604,7 +604,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
: new ServiceUnavailableError({ message: error.message, service: "session generation" }),
),
)
return { data: { text } }
return { data: result }
}),
)
.handle(
@@ -216,7 +216,7 @@ it.live(
for (const config of configs) {
yield* llm.push(TestLLM.text(`generated ${config.id}`, config.tool))
expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toBe(`generated ${config.id}`)
expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toEqual({ text: `generated ${config.id}` })
yield* sessions.command({ sessionID: config.id, command: "instance-check", text: "" })
expect(yield* sessions.inbox(config.id)).toMatchObject([
{ type: "user", payload: { text: `command ${config.tool} [${config.tool}]` } },