mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 22:10:11 -04:00
feat(plugin): expose model generation options to session hooks (#45268)
This commit is contained in:
@@ -297,17 +297,17 @@ export const layer = Layer.effect(
|
||||
)
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
|
||||
const context =
|
||||
input.contextHooks === false
|
||||
? { system: input.transcript.system, messages: input.transcript.messages, tools: definitions }
|
||||
: yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
})
|
||||
const context: PluginHooks.Domains["session"]["context"] = {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
}
|
||||
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
@@ -333,6 +333,8 @@ export const layer = Layer.effect(
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: input.toolChoice,
|
||||
generation: Object.keys(context.generation).length === 0 ? undefined : context.generation,
|
||||
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
|
||||
}),
|
||||
)
|
||||
const hasHttpHooks =
|
||||
|
||||
@@ -35,6 +35,8 @@ describe("PluginHooks", () => {
|
||||
system: [SystemPart.make("first")],
|
||||
messages: [Message.user("original")],
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
}
|
||||
|
||||
expect(yield* hooks.trigger("session", "context", event)).toBe(event)
|
||||
|
||||
@@ -124,6 +124,8 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
|
||||
system: [],
|
||||
messages,
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
})
|
||||
|
||||
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
|
||||
|
||||
@@ -381,6 +381,8 @@ describe("fromPromise", () => {
|
||||
await ctx.session.hook("context", (event) => {
|
||||
event.system.push(SystemPart.make("Promise hook"))
|
||||
delete event.tools.echo
|
||||
event.generation.temperature = 0.4
|
||||
event.providerOptions.reasoningEffort = "medium"
|
||||
})
|
||||
},
|
||||
}),
|
||||
@@ -392,12 +394,16 @@ describe("fromPromise", () => {
|
||||
system: [SystemPart.make("Initial")],
|
||||
messages: [Message.user("Hello")],
|
||||
tools: { echo: { description: "Echo", input: { type: "object" } } },
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
|
||||
expect(event.tools).toEqual({})
|
||||
expect(event.generation).toEqual({ temperature: 0.4 })
|
||||
expect(event.providerOptions).toEqual({ reasoningEffort: "medium" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
|
||||
system: [SystemPart.make(system)],
|
||||
messages: [],
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
})
|
||||
|
||||
describe("SystemPromptPlugin", () => {
|
||||
|
||||
@@ -1,9 +1,165 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
import { LanguageModel, Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { Gemini } from "@opencode-ai/ai/protocols/gemini"
|
||||
import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { SessionModelRequest, boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), [
|
||||
[SessionModelTransport.node, SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") })],
|
||||
]),
|
||||
)
|
||||
|
||||
const requestInput = (model: LanguageModel) => ({
|
||||
scope: {
|
||||
session: Session.Info.make({
|
||||
id: Session.ID.make("ses_request_options"),
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
}),
|
||||
agentID: Agent.ID.make("build"),
|
||||
model: SessionRunnerModel.resolved(model, {
|
||||
capabilities: { ...capabilities(["text"]), responsesWebsockets: model.provider === "openai" },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
}),
|
||||
},
|
||||
transcript: { system: [], messages: [Message.user("Hello")] },
|
||||
})
|
||||
|
||||
describe("SessionModelRequest.context options", () => {
|
||||
it.effect("compiles ordered generation and provider overrides without mutating defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const model = Gemini.route
|
||||
.with({
|
||||
generation: { maxTokens: 100, topP: 0.7 },
|
||||
providerOptions: { thinkingConfig: { includeThoughts: true, thinkingBudget: 256 } },
|
||||
})
|
||||
.model({
|
||||
id: "gemini-2.5-flash",
|
||||
defaults: {
|
||||
generation: { temperature: 0.8 },
|
||||
providerOptions: { thinkingConfig: { thinkingBudget: 512 } },
|
||||
},
|
||||
})
|
||||
const baseline = yield* requests.prepare(requestInput(model))
|
||||
expect(baseline.request.generation).toBeUndefined()
|
||||
expect(baseline.request.providerOptions).toBeUndefined()
|
||||
const first = yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.generation).toEqual({})
|
||||
expect(event.providerOptions).toEqual({})
|
||||
event.generation = {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.2,
|
||||
topK: 40,
|
||||
frequencyPenalty: 0.1,
|
||||
presencePenalty: 0.3,
|
||||
seed: 42,
|
||||
stop: ["END"],
|
||||
}
|
||||
event.providerOptions = { thinkingConfig: { thinkingBudget: 1024 } }
|
||||
}),
|
||||
)
|
||||
const second = yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.generation.temperature).toBe(0.2)
|
||||
expect(event.providerOptions.thinkingConfig).toEqual({ thinkingBudget: 1024 })
|
||||
event.generation.temperature = 0
|
||||
event.generation.stop?.push("STOP")
|
||||
}),
|
||||
)
|
||||
const prepared = yield* requests.prepare(requestInput(model))
|
||||
expect((yield* compileRequest(prepared.request)).body).toMatchObject({
|
||||
generationConfig: {
|
||||
maxOutputTokens: 2048,
|
||||
temperature: 0,
|
||||
topP: 0.7,
|
||||
topK: 40,
|
||||
frequencyPenalty: 0.1,
|
||||
presencePenalty: 0.3,
|
||||
seed: 42,
|
||||
stopSequences: ["END", "STOP"],
|
||||
thinkingConfig: { includeThoughts: true, thinkingBudget: 1024 },
|
||||
},
|
||||
})
|
||||
// Each new request starts with fresh override objects, even while hooks remain registered.
|
||||
expect((yield* requests.prepare(requestInput(model))).request.generation).toEqual(prepared.request.generation)
|
||||
yield* first.dispose
|
||||
yield* second.dispose
|
||||
const unhooked = yield* requests.prepare(requestInput(model))
|
||||
expect(unhooked.request.generation).toBeUndefined()
|
||||
expect(unhooked.request.providerOptions).toBeUndefined()
|
||||
expect((yield* compileRequest(unhooked.request)).body).toEqual((yield* compileRequest(baseline.request)).body)
|
||||
expect(model.defaults?.generation).toEqual({ temperature: 0.8 })
|
||||
expect(model.route.defaults.generation).toEqual({ maxTokens: 100, topP: 0.7 })
|
||||
expect(model.defaults?.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 512 } })
|
||||
expect(model.route.defaults.providerOptions).toEqual({
|
||||
thinkingConfig: { includeThoughts: true, thinkingBudget: 256 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compiles OpenAI semantic reasoning options without revoking WebSocket transport", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", () => Effect.die("Other-provider hook must not run"), {
|
||||
providerID: "google",
|
||||
})
|
||||
yield* hooks.register(
|
||||
"session",
|
||||
"context",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
event.generation.maxTokens = 8000
|
||||
event.providerOptions.reasoningEffort = "high"
|
||||
}),
|
||||
{ providerID: "openai" },
|
||||
)
|
||||
const input = requestInput(OpenAIResponses.route.model({ id: "gpt-5.5" }))
|
||||
const prepared = yield* requests.prepare({ ...input, webSocket: "session" })
|
||||
expect(prepared.options.webSocket).toBeDefined()
|
||||
expect(prepared.options.http).toBeUndefined()
|
||||
expect((yield* compileRequest(prepared.request)).body).toMatchObject({
|
||||
max_output_tokens: 8000,
|
||||
reasoning: { effort: "high" },
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
const excluded = yield* requests.prepare({ ...input, contextHooks: false })
|
||||
expect(excluded.request.generation).toBeUndefined()
|
||||
expect(excluded.request.providerOptions).toBeUndefined()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionModelRequest.unsupportedParts", () => {
|
||||
test("replaces unsupported user media with a visible error", () => {
|
||||
const messages = unsupportedParts(
|
||||
|
||||
@@ -1039,6 +1039,11 @@ describe("SessionRunnerLLM", () => {
|
||||
event.messages = [Message.user("Hooked message")]
|
||||
delete event.tools.echo
|
||||
event.tools.unregistered = { description: "Unavailable", input: { type: "object" } }
|
||||
event.generation.temperature = 0.2
|
||||
event.generation.topP = 0.9
|
||||
event.generation.topK = 40
|
||||
event.generation.maxTokens = 2048
|
||||
event.providerOptions.reasoningEffort = "high"
|
||||
}),
|
||||
)
|
||||
yield* admit(session, "Original message")
|
||||
@@ -1052,6 +1057,8 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
|
||||
expect(requests[0]?.generation).toMatchObject({ temperature: 0.2, topP: 0.9, topK: 40, maxTokens: 2048 })
|
||||
expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(executions).toEqual([])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Original message" },
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { GenerationOptionsFields, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { JsonSchema, Types } from "effect"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -13,6 +13,9 @@ export interface SessionContext {
|
||||
system: Array<SystemPart>
|
||||
messages: Array<Message>
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
/** Request overrides; unset fields retain route and model defaults. */
|
||||
generation: Types.DeepMutable<GenerationOptionsFields>
|
||||
providerOptions: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SessionModelRequest {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { GenerationOptionsFields, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { JsonSchema, Types } from "effect"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -13,6 +13,9 @@ export interface SessionContext {
|
||||
system: Array<SystemPart>
|
||||
messages: Array<Message>
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
/** Request overrides; unset fields retain route and model defaults. */
|
||||
generation: Types.DeepMutable<GenerationOptionsFields>
|
||||
providerOptions: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SessionModelRequest {
|
||||
|
||||
@@ -1011,21 +1011,54 @@ await registration.dispose()
|
||||
|
||||
### Sessions
|
||||
|
||||
Modify assembled system instructions, messages, or tools immediately before model dispatch.
|
||||
Modify assembled system instructions, messages, tools, generation settings, or provider options immediately before model
|
||||
dispatch.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("context", (event) => {
|
||||
event.system.push("Keep the review focused on correctness.")
|
||||
event.system.push({ text: "Keep the review focused on correctness." })
|
||||
delete event.tools.write
|
||||
event.generation.temperature = 0.2
|
||||
event.generation.maxTokens = 8_000
|
||||
})
|
||||
```
|
||||
|
||||
`generation` and `providerOptions` start as empty request-override objects for each context hook invocation, not resolved
|
||||
model settings. Hooks run in registration order and see earlier hooks' overrides. During compilation, request overrides
|
||||
take precedence over model defaults, which take precedence over route defaults. Provider option records merge recursively;
|
||||
arrays and scalar values replace earlier values. Deleting an override or setting it to `undefined` falls back to defaults;
|
||||
it does not remove a configured default. This precedence applies to semantic generation and provider options; explicit raw
|
||||
HTTP body overlays are applied after protocol lowering and can override the resulting fields.
|
||||
|
||||
These changes affect only the outgoing model call, not persisted session history or configuration. Context hooks also run
|
||||
for subsequent calls such as tool-driven continuations, but do not run for title or compaction requests.
|
||||
|
||||
Provider options use the selected protocol's semantic option names, not raw HTTP body fields. Scope provider-specific
|
||||
settings to the matching provider. For example, OpenAI Responses uses `reasoningEffort`:
|
||||
|
||||
```ts
|
||||
await ctx.session.hook(
|
||||
"context",
|
||||
(event) => {
|
||||
event.providerOptions.reasoningEffort = "high"
|
||||
},
|
||||
{ providerID: "openai" },
|
||||
)
|
||||
```
|
||||
|
||||
`maxTokens` is the semantic output-token limit. Generation settings are supported only where the selected protocol and
|
||||
model support them; for example, Gemini supports `topK`, while OpenAI Responses does not expose it.
|
||||
|
||||
Modify model request settings and optionally scope the hook to one provider.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("model.request", (event) => {
|
||||
event.headers["x-plugin"] = "review"
|
||||
}, { providerID: "anthropic" })
|
||||
await ctx.session.hook(
|
||||
"model.request",
|
||||
(event) => {
|
||||
event.headers["x-plugin"] = "review"
|
||||
},
|
||||
{ providerID: "anthropic" },
|
||||
)
|
||||
```
|
||||
|
||||
Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading
|
||||
@@ -1054,6 +1087,26 @@ interface SessionHooks {
|
||||
"http.response": SessionHttpResponseHook
|
||||
}
|
||||
|
||||
interface SessionContextHook {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
system: SystemPart[]
|
||||
messages: Message[]
|
||||
tools: Record<string, { description: string; input: JsonSchema }>
|
||||
generation: {
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
frequencyPenalty?: number
|
||||
presencePenalty?: number
|
||||
seed?: number
|
||||
stop?: string[]
|
||||
}
|
||||
providerOptions: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SessionHookContext {
|
||||
hook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
|
||||
Reference in New Issue
Block a user