mirror of
https://github.com/Heretek-AI/opencode.git
synced 2026-11-07 10:56:09 -10:00
feat(server): support structured output in session generate
This commit is contained in:
1 parent
119cb15d1d
commit
21b05a1337
11 files changed
+162
-28
No files matched your search
@@ -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"]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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}]` } },
|
||||
|
||||
Reference in new issue
Block a user