feat(plugin): expose model generation options to session hooks (#45268)

This commit is contained in:
Shoubhit Dash
2026-08-27 17:30:37 +05:30
committed by GitHub
parent 803ead32e7
commit 46aa25f236
10 changed files with 258 additions and 22 deletions
+13 -11
View File
@@ -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 =
+2
View File
@@ -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)
+2
View File
@@ -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" },
+5 -2
View File
@@ -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 {
+5 -2
View File
@@ -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,