Compare commits

...
Author SHA1 Message Date
Aiden Cline 1c27331fe6 feat(plugin): let title hooks supply the title
Setting result on the session.title event skips the model request.
2026-09-07 00:13:39 -05:00
Aiden Cline 8bb97d85d5 refactor(core): drop the compaction agent identity from request hooks
Compaction runs with the session agent; request hooks distinguish it by
kind since #47214, so the fake agent had no consumers left.
2026-09-06 23:07:00 -05:00
Aiden Cline 74c088fc0d chore(core): plain-English comments in model-request 2026-09-06 17:44:24 -05:00
Aiden Cline 1de8d14ef2 refactor(core): trim model-request diff to the behavior change 2026-09-06 17:20:24 -05:00
Aiden Cline 46ce3cb2c9 test(core): cover tools renamed by session context hooks 2026-09-06 17:16:42 -05:00
Aiden Cline e82a4a1da4 refactor(core): one model-request entry per session flow
Callers no longer tell prepare which hook to run. SessionModelRequest
exposes primary, compaction, generate, and title; each runs the hook that
shapes its flow and the request kind follows from the entry. The
contextHooks flag, contextAgentID, and kind parameter are gone from the
input, which is flat now.

Single-use helpers are inlined into the shared lowering step.
2026-09-06 17:07:57 -05:00
Aiden Cline ee2e318ec7 feat(plugin): add session title hook and request options bag
Session request hooks now share a base shape: sessionID, model, system,
messages, and an options bag. The bag replaces the separate generation
and providerOptions fields; typed keys are the protocol-neutral
generation settings and any other key is passed to the protocol as a
provider option. Core partitions them when it builds the request.

Title generation gets its own hook. It is not an agent conversation, so
the event carries no agent or tools, and it no longer opts out of context
hooks through a flag: prepare dispatches on the request kind.
2026-09-06 15:54:51 -05:00
20 changed files with 440 additions and 331 deletions
+16 -23
View File
@@ -11,7 +11,6 @@ import {
Message,
type ContentPart,
} from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
@@ -92,7 +91,7 @@ export type Editor = {
export type AutoInput = {
readonly context: SessionContext.Loaded
readonly prepare: SessionModelRequest.Interface["prepare"]
readonly prepare: SessionModelRequest.Interface["compaction"]
}
type RequiredInput = {
@@ -113,7 +112,7 @@ export type ManualInput = {
SessionContext.Loaded & { readonly instructionUpdate: string },
SessionRunnerModel.Error | AgentNotFoundError | Instructions.InitializationBlocked
>
readonly prepare: SessionModelRequest.Interface["prepare"]
readonly prepare: SessionModelRequest.Interface["compaction"]
}
type ExecuteInput = AutoInput & {
@@ -396,26 +395,20 @@ export const layer = Layer.effect(
messages: history.messages,
})
const prepared = yield* input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
session: context.session,
agent: context.agent.id,
model: context.model,
tools: context.tools,
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
],
},
),
],
})
const retry = yield* SessionRunnerRetry.policy(context.session.id)
// Both requests share the retry allowance; rejected output never enters the reminder request.
@@ -486,7 +479,7 @@ export const layer = Layer.effect(
const decision = yield* retry({
cause,
error: toSessionError(cause),
agent: Agent.ID.make("compaction"),
agent: context.agent.id,
model: context.model.ref,
hook: prepared.retry,
retry: SessionRunnerRetry.isRetryable(cause),
+4 -3
View File
@@ -64,7 +64,8 @@ export interface Interface {
}
| undefined
>
readonly prepare: SessionModelRequest.Interface["prepare"]
/** Outbound request preparation, one entry per Session flow. */
readonly request: SessionModelRequest.Interface
}
/** Location-scoped model-context loader for durable Session Steps. */
@@ -83,7 +84,7 @@ const layer = Layer.effect(
const mcpInstructions = yield* McpInstructions.Service
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const request = yield* SessionModelRequest.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
@@ -167,7 +168,7 @@ const layer = Layer.effect(
}
})
return Service.of({ select, load, resolveModel, selectTitle, prepare: modelRequests.prepare })
return Service.of({ select, load, resolveModel, selectTitle, request })
}),
)
+11 -11
View File
@@ -37,17 +37,17 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
initial: history.initial,
messages: history.messages,
})
const prepared = yield* context.prepare({
kind: "generate",
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
},
const prepared = yield* context.request.generate({
session: selection.session,
agent: selection.agent.id,
model,
tools: selection.tools,
system: transcript.system,
messages: [
...transcript.messages,
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
})
yield* Effect.logInfo("sending session generation request", {
sessionID: selection.session.id,
+165 -205
View File
@@ -1,8 +1,17 @@
export * as SessionModelRequest from "./model-request.js"
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode-ai/ai"
import {
GenerationOptions,
type GenerationOptionsFields,
HttpOptions,
LanguageModel,
LLM,
LLMRequest,
Message,
SystemPart,
} from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
import type { SessionRequest, SessionRequestKind } from "@opencode-ai/plugin/effect/session"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Content } from "@opencode-ai/schema/tool"
@@ -25,63 +34,30 @@ const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
/** Tool errors, plus the user declining a permission or dismissing a question. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
}
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
/**
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
*/
/** Runs a tool call against the tools this request advertised. */
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
}
interface PrepareInput {
/** Which Session flow issues this request; request hooks receive it alongside the Session identity. */
readonly kind: SessionRequestKind
readonly scope: {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
readonly contextAgentID?: Agent.ID
readonly model: SessionRunnerModel.Resolved
/** Omitted for requests that carry no tool definitions, such as titles. */
readonly tools?: Tool.Snapshot
}
readonly transcript: {
readonly system: Array<SystemPart>
readonly messages: Array<Message>
}
export interface Input {
readonly session: SessionSchema.Info
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly tools?: Tool.Snapshot
readonly system: Array<SystemPart>
readonly messages: Array<Message>
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/**
* Session context hooks shape the agent conversation. Standalone requests
* such as titles opt out; compaction uses the selected Session context.
*/
readonly contextHooks?: false
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
/** Only the durable runner may use a stateful WebSocket. */
readonly webSocket?: "session"
}
@@ -195,90 +171,17 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
)
}
/** The identity a plugin hook sees for one outbound request. */
interface HookScope {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
}
type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
const sessionHeaders = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
})
const promptCacheKey = (sessionID: SessionSchema.ID) =>
/^ses_[0-9a-f]{64}$/.test(sessionID) ? sessionID.slice(4) : sessionID
// Lets session.model.request hooks rewrite the base URL and headers before dispatch.
const applyModelHooks = (hooks: PluginHooks.Interface, scope: HookScope, request: LLMRequest) =>
Effect.gen(function* () {
const currentBaseURL = request.model.route.endpoint.baseURL
const event = yield* hooks.trigger("session", "model.request", {
...scope,
baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined,
headers: { ...request.http?.headers },
})
const route =
event.baseURL !== undefined && event.baseURL !== currentBaseURL
? request.model.route.with({ endpoint: { baseURL: event.baseURL } })
: request.model.route
return LLMRequest.update(request, {
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
http: new HttpOptions({
body: request.http?.body,
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
query: request.http?.query,
}),
})
})
// Exposes each outbound HTTP exchange to session.http.request/response hooks
// through web-standard Request/Response values.
const httpMiddleware =
(hooks: PluginHooks.Interface, scope: HookScope): NonNullable<StreamOptions["http"]> =>
(request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...scope,
request: yield* HttpClientRequest.toWeb(request),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
...scope,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
* Session state.
*/
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */
export interface Interface {
/** Builds one outbound model request and its matching tool-call capability. */
readonly prepare: (input: PrepareInput) => Effect.Effect<Prepared>
readonly primary: (input: Input) => Effect.Effect<Prepared>
readonly compaction: (input: Input) => Effect.Effect<Prepared>
readonly generate: (input: Input) => Effect.Effect<Prepared>
/** Runs `session.title` instead of `session.context`; no agent or tools. A hook may supply the title outright. */
readonly title: (input: Input) => Effect.Effect<Prepared | { readonly title: string }>
}
/** Location-scoped outbound model-request preparation. */
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelRequest") {}
export const layer = Layer.effect(
@@ -287,102 +190,159 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service
const transport = yield* SessionModelTransport.Service
const app = yield* App.Metadata
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.scope.session
const resolved = input.scope.model
const model = resolved.model
const tools = input.scope.tools ?? {
// `shape` runs the flow's plugin hook. Hooks mutate `tools` in place, so it is passed separately.
const prepare = Effect.fn("SessionModelRequest.prepare")(function* <
S extends SessionRequest & { tools?: Definitions },
>(kind: SessionRequestKind, input: Input, shape: (draft: SessionRequest, tools: Definitions) => Effect.Effect<S>) {
const session = input.session
const model = input.model
const scope = { sessionID: session.id, agent: input.agent, model: model.ref, kind }
const tools = input.tools ?? {
definitions: [],
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
}
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
// tool by moving its definition to a new key; recognizing the object recovers the tool.
// Remember which tool each definition object came from. Hooks rename a tool by moving
// its definition to a new key, so after the hook we find the tool by object identity.
const given = new Map(
tools.definitions.map(
(tool) => [{ description: tool.description, input: { ...tool.inputSchema } }, tool] as const,
),
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
)
// 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: PluginHooks.Domains["session"]["context"] = {
sessionID: session.id,
agent: input.scope.contextAgentID ?? 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.
// `tool.name` stays canonical so execution can translate renamed calls back.
const shaped = yield* shape(
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
)
// Match by identity first, then by key. Entries matching neither were invented by a
// hook and are dropped. `t.name` stays the real name so execution can map renames back.
const byName = new Map(tools.definitions.map((t) => [t.name, t]))
const hooked = new Map(
Object.entries(context.tools).flatMap(([name, definition]) => {
const tool = given.get(definition) ?? registry.get(name)
if (!tool) return []
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
Object.entries(shaped.tools ?? {}).flatMap(([name, d]) => {
const t = given.get(d) ?? byName.get(name)
return t ? [[name, { ...t, description: d.description, inputSchema: d.input }] as const] : []
}),
)
const request = yield* applyModelHooks(
hooks,
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref, kind: input.kind },
LLM.request({
model,
http: {
headers: sessionHeaders(session, app),
const entries = Object.entries(shaped.options)
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
const root = session.fork?.sessionID ?? session.id
const base = LLM.request({
model: model.model,
http: {
headers: {
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: promptCacheKey(session.fork?.sessionID ?? session.id),
system: context.system,
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,
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: /^ses_[0-9a-f]{64}$/.test(root) ? root.slice(4) : root,
system: shaped.system,
messages: boundImages(unsupportedParts(shaped.messages, model.capabilities)),
tools: Array.from(hooked, ([name, t]) => ({ ...t, name })),
toolChoice: input.toolChoice,
generation: Object.keys(generation).length === 0 ? undefined : generation,
providerOptions: Object.keys(providerOptions).length === 0 ? undefined : providerOptions,
})
const baseURL = base.model.route.endpoint.baseURL
const modelHook = yield* hooks.trigger("session", "model.request", {
...scope,
baseURL: typeof baseURL === "string" ? baseURL : undefined,
headers: { ...base.http?.headers },
})
const route =
modelHook.baseURL !== undefined && modelHook.baseURL !== baseURL
? base.model.route.with({ endpoint: { baseURL: modelHook.baseURL } })
: base.model.route
const request = LLMRequest.update(base, {
model: route === base.model.route ? base.model : LanguageModel.update(base.model, { route }),
http: new HttpOptions({
body: base.http?.body,
headers: Object.keys(modelHook.headers).length === 0 ? undefined : modelHook.headers,
query: base.http?.query,
}),
)
})
// Hooks see each HTTP exchange as web Request/Response values. WebSockets bypass this,
// so registering an HTTP hook forces HTTP.
const hasHttpHooks =
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const webSocket =
resolved.capabilities.responsesWebsockets === true
? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe(
Config.withDefault(false),
Effect.orDie,
)
: false
const http = hasHttpHooks
? httpMiddleware(hooks, {
sessionID: session.id,
agent: input.scope.agentID,
model: resolved.ref,
kind: input.kind,
})
(yield* hooks.has("session", "http.request", model.ref.providerID)) ||
(yield* hooks.has("session", "http.response", model.ref.providerID))
const http: StreamOptions["http"] = hasHttpHooks
? (req, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...scope,
request: yield* HttpClientRequest.toWeb(req),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const res = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
...scope,
request: before.request,
response: new Response(
[204, 205, 304].includes(res.status) ? null : yield* Stream.toReadableStreamEffect(res.stream),
{ status: res.status, headers: res.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
: undefined
const options: StreamOptions = {
...(http ? { http } : {}),
...(input.webSocket === "session" && webSocket && !hasHttpHooks
? { webSocket: transport.bind(session.id) }
: {}),
}
const executeTool: Prepared["executeTool"] = (input) =>
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
const webSocket =
input.webSocket === "session" &&
!hasHttpHooks &&
model.capabilities.responsesWebsockets === true &&
(yield* Config.boolean(
`OPENCODE_EXPERIMENTAL_${model.ref.providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`,
).pipe(Config.withDefault(false), Effect.orDie))
return {
event: shaped,
request,
options,
retry,
executeTool,
options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket: transport.bind(session.id) } : {}) },
retry: (event: Parameters<Prepared["retry"]>[0]) =>
hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
// Permission.assert and the question tool throw declines as defects so tools cannot
// catch them and turn a "no" into model-visible output. Recover them here as failures.
executeTool: (call: Parameters<Prepared["executeTool"]>[0]) =>
tools.execute({ ...call, definitions: hooked }).pipe(
Effect.catchCauseFilter(
(cause) => {
const decline = cause.reasons.flatMap((r) =>
Cause.isDieReason(r) &&
(r.defect instanceof Permission.DeclinedError || r.defect instanceof QuestionTool.CancelledError)
? [r.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
},
(decline) => Effect.fail(decline),
),
),
}
})
return Service.of({ prepare })
const context = (agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
hooks.trigger("session", "context", { ...draft, agent, tools })
return Service.of({
primary: (input) => prepare("primary", input, context(input.agent)),
generate: (input) => prepare("generate", input, context(input.agent)),
compaction: (input) => prepare("compaction", input, context(input.agent)),
title: (input) =>
prepare("title", input, (draft) => hooks.trigger("session", "title", draft)).pipe(
Effect.map((p) => (p.event.result === undefined ? p : { title: p.event.result })),
),
})
}),
)
+11 -11
View File
@@ -124,7 +124,7 @@ const layer = Layer.effect(
instructionUpdate: history.instructionUpdate,
}
}),
prepare: context.prepare,
prepare: context.request.compaction,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
@@ -203,7 +203,7 @@ const layer = Layer.effect(
initial = undefined
const compactionInput = {
context: loaded,
prepare: context.prepare,
prepare: context.request.compaction,
}
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const compacted = yield* compaction.compact(compactionInput)
@@ -219,15 +219,15 @@ const layer = Layer.effect(
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* context.prepare({
kind: "primary",
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
const prepared = yield* context.request.primary({
session: loaded.session,
agent: loaded.agent.id,
model: loaded.model,
tools: loaded.tools,
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
+7 -8
View File
@@ -63,15 +63,14 @@ export const layer = Layer.effect(
})
: Effect.void,
)
const prepared = yield* context.prepare({
kind: "title",
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
},
contextHooks: false,
const prepared = yield* context.request.title({
session: input.session,
agent: input.agent.id,
model: input.model,
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
})
if ("title" in prepared) return prepared.title
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
+1 -1
View File
@@ -95,7 +95,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
prepare: modelRequests.prepare,
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
+1 -2
View File
@@ -37,8 +37,7 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
{ description: name, input: { type: "object" } },
]),
),
generation: {},
providerOptions: {},
options: {},
})
describe("OptimizePlugin", () => {
+1 -2
View File
@@ -124,8 +124,7 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
system: [],
messages,
tools: {},
generation: {},
providerOptions: {},
options: {},
})
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
@@ -240,22 +240,20 @@ describe("OpenAIPlugin", () => {
})
const program = Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
kind: "primary",
scope: {
session: Session.Info.make({
id: sessionID,
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,
model,
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
},
transcript: { system: [], messages: [] },
return yield* requests.primary({
session: Session.Info.make({
id: sessionID,
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") }),
}),
agent: agentID,
model,
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
system: [],
messages: [],
webSocket: "session",
})
}).pipe(
@@ -362,7 +362,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.prepare,
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
@@ -430,7 +430,7 @@ it.effect("manual compaction records model resolution failures without calling t
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.prepare,
prepare: modelRequests.compaction,
messages: [
{
id: SessionMessage.ID.create(),
@@ -481,7 +481,7 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.prepare,
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_fork_compaction"),
}),
@@ -57,12 +57,14 @@ describe("SessionModelRequest HTTP hooks", () => {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
for (const kind of KINDS) {
const prepared = yield* requests.prepare({
kind,
scope: { session, agentID: Agent.ID.make("build"), model },
transcript: { system: [], messages: [] },
const prepared = yield* requests[kind]({
session,
agent: Agent.ID.make("build"),
model,
system: [],
messages: [],
})
const http = prepared.options.http
const http = "options" in prepared ? prepared.options.http : undefined
if (!http) throw new Error(`Expected HTTP middleware for ${kind}`)
yield* http(HttpClientRequest.post("https://example.test/v1/chat/completions"), (request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 200 }))),
+21 -3
View File
@@ -1073,6 +1073,24 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("executes a tool renamed by a session context hook", function* (s) {
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.tools.renamed_echo = event.tools.echo!
delete event.tools.echo
}),
)
yield* s.admit("Use the renamed tool")
yield* s.llm.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
yield* s.resume
expect(s.requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
expect(s.requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(s.executions).toEqual(["renamed"])
})
scenario("executes the tool advertised before a registry reload", function* (s) {
const registry = yield* Tool.Service
const scope = yield* Scope.make()
@@ -2308,7 +2326,7 @@ describe("SessionRunnerLLM", () => {
expect(event.model.variant).toBe(variant)
event.system.push(SystemPart.make("Hook-provided instructions"))
event.tools.echo.description = "Hook-provided tool description"
event.generation.maxTokens = 4_000
event.options.maxTokens = 4_000
}),
)
yield* hooks.register("session", "model.request", (event) =>
@@ -2359,7 +2377,7 @@ describe("SessionRunnerLLM", () => {
expect(compact[field]).toEqual(normal[field])
expect(compact.toolChoice).toBeUndefined()
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
expect(requestAgents[2]).toBe(agentID)
expect(s.executions).toEqual(["x".repeat(4_000)])
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
@@ -2474,7 +2492,7 @@ describe("SessionRunnerLLM", () => {
expect(s.requests).toHaveLength(5)
for (const request of s.requests) expect(request).toEqual(s.requests[0])
expect(retries.map((event) => event.attempt)).toEqual([2, 3, 4, 5])
expect(retries.every((event) => event.sessionID === sessionID && event.agent === "compaction")).toBe(true)
expect(retries.every((event) => event.sessionID === sessionID && event.agent === "build")).toBe(true)
expect(retries[3].decision).toEqual({ retry: true, delay: 60_000 })
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
status: "completed",
+66 -1
View File
@@ -1,5 +1,14 @@
import { beforeEach, expect } from "bun:test"
import { AIError, LLMClient, LLMEvent, LanguageModel, TransportError, type LLMRequest } from "@opencode-ai/ai"
import {
AIError,
LLMClient,
LLMEvent,
LanguageModel,
Message,
SystemPart,
TransportError,
type LLMRequest,
} from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -232,6 +241,62 @@ it.effect("generates a title from the sole user message and renames the session"
}),
)
it.effect("runs title hooks instead of context hooks", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_hook")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Redact this message")
const hooks = yield* PluginHooks.Service
let contexts = 0
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
yield* hooks.register("session", "title", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(sessionID)
expect(event.system.map((part) => part.text)).toEqual(["You are a title generator."])
event.system.push(SystemPart.make("Prefer short titles."))
event.messages = [Message.user("[redacted]")]
event.options.maxTokens = 32
event.options.reasoningEffort = "low"
}),
)
const title = yield* SessionTitle.Service
yield* title.generate(sessionID)
expect(contexts).toBe(0)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", "Prefer short titles."])
expect(JSON.stringify(requests[0]?.messages)).not.toContain("Redact this message")
expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 }))
expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" })
}),
)
it.effect("uses a hook-provided title without a model request", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_result")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Hello")
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "title", (event) =>
Effect.sync(() => {
event.result = "Plugin Title"
}),
)
const title = yield* SessionTitle.Service
yield* title.generate(sessionID)
expect(requests).toHaveLength(0)
const store = yield* SessionStore.Service
expect((yield* store.get(sessionID))?.title).toBe("Plugin Title")
}),
)
it.effect("uses a small model from the primary provider", () =>
Effect.gen(function* () {
selectedSmall = small
+16 -5
View File
@@ -18,16 +18,26 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
export interface SessionContext {
/** Request overrides. Typed keys are generation settings; any other key is a provider option. */
export type SessionRequestOptions = Types.DeepMutable<GenerationOptionsFields> & Record<string, unknown>
export interface SessionRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
options: SessionRequestOptions
}
export interface SessionContext extends SessionRequest {
readonly agent: Agent.ID
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>
}
/** Title generation is not an agent conversation and exposes no agent or tools. */
export interface SessionTitle extends SessionRequest {
/** Set to use this title and skip the model request. */
result?: string
}
/**
@@ -76,6 +86,7 @@ export interface SessionRetry {
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
+16 -5
View File
@@ -18,16 +18,26 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
export interface SessionContext {
/** Request overrides. Typed keys are generation settings; any other key is a provider option. */
export type SessionRequestOptions = Types.DeepMutable<GenerationOptionsFields> & Record<string, unknown>
export interface SessionRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
options: SessionRequestOptions
}
export interface SessionContext extends SessionRequest {
readonly agent: Agent.ID
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>
}
/** Title generation is not an agent conversation and exposes no agent or tools. */
export interface SessionTitle extends SessionRequest {
/** Set to use this title and skip the model request. */
result?: string
}
/**
@@ -76,6 +86,7 @@ export interface SessionRetry {
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
+1 -1
View File
@@ -69,7 +69,7 @@ it.live(
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.generation.temperature = 0.25
event.options.temperature = 0.25
}),
)
yield* ctx.tool.transform((editor) =>
@@ -94,7 +94,7 @@ it.live(
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.generation.temperature = config.temperature
event.options.temperature = config.temperature
}),
)
yield* ctx.permission.hook("evaluate", (event) =>
@@ -1089,7 +1089,7 @@ effect: (ctx) =>
### Sessions
Modify assembled system instructions, messages, or tools immediately before model dispatch.
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
```ts
effect: (ctx) =>
@@ -1099,11 +1099,15 @@ effect: (ctx) =>
Effect.sync(() => {
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
event.options.maxTokens = 8_000
}),
)
}),
```
Title generation runs `title` instead, with the same shape minus `agent` and `tools`. Setting `result` skips the
model request and uses that title.
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind` as
the HTTP hooks below.
@@ -1178,6 +1182,7 @@ Context-overflow recovery remains separate because it compacts the conversation
```ts
interface SessionHooks {
readonly context: SessionContext
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
@@ -1195,6 +1200,34 @@ interface SessionRetry {
decision: RetryDecision
}
type SessionRequestOptions = {
maxTokens?: number
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
seed?: number
stop?: string[]
} & Record<string, unknown>
interface SessionRequest {
readonly sessionID: string
readonly model: { providerID: string; id: string; variant?: string }
system: SystemPart[]
messages: Message[]
options: SessionRequestOptions
}
interface SessionContext extends SessionRequest {
readonly agent: string
tools: Record<string, { description: string; input: JsonSchema }>
}
interface SessionTitle extends SessionRequest {
result?: string
}
interface SessionHookDomain {
readonly hook: ModelHooks<SessionHooks>
}
@@ -1195,28 +1195,26 @@ Keep prompt hooks retry-safe. They are not an exactly-once side-effect boundary:
#### Model context
Modify assembled system instructions, messages, tools, generation settings, or provider options immediately before model
dispatch.
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
```ts
await ctx.session.hook("context", (event) => {
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
event.generation.temperature = 0.2
event.generation.maxTokens = 8_000
event.options.temperature = 0.2
event.options.maxTokens = 8_000
})
```
Context changes affect only the outgoing model call, not persisted history or
configuration. The hook runs again for subsequent calls such as tool-driven
continuations, transient session generation, and compaction, but not for title requests.
continuations, transient session generation, and compaction. Title generation
has its own hook.
Compaction context hooks receive the selected session agent. Its model-request
and HTTP hooks retain the `compaction` agent identity for provider-specific handling.
Request options follow these rules:
Request overrides follow these rules:
- `generation` and `providerOptions` start empty for each model call; they do not contain resolved model settings.
- `options` starts empty for each model call; it does not contain resolved model settings.
- Typed keys are generation settings; any other key is passed to the selected protocol as a provider option.
- Hooks run in registration order and see overrides made by earlier hooks.
- Request overrides take precedence over model defaults, which take precedence over route defaults.
- Provider option objects merge recursively; arrays and scalar values replace earlier values.
@@ -1232,7 +1230,7 @@ settings to the matching provider. For example, OpenAI Responses uses `reasoning
await ctx.session.hook(
"context",
(event) => {
event.providerOptions.reasoningEffort = "high"
event.options.reasoningEffort = "high"
},
{ providerID: "openai" },
)
@@ -1243,6 +1241,19 @@ Generation options depend on the selected protocol and model:
- `maxTokens` is the semantic output-token limit.
- Gemini supports `topK`; OpenAI Responses does not expose it.
#### Title generation
Title generation is not an agent conversation, so its hook carries no `agent` or `tools`.
Set `result` to supply the title yourself and skip the model request.
```ts
await ctx.session.hook("title", (event) => {
event.system.push({ text: "Titles are at most five words." })
event.options.maxTokens = 32
})
```
#### Model request
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind`
@@ -1320,6 +1331,7 @@ import type { SessionPrompt } from "@opencode-ai/plugin/promise/session"
interface SessionHooks {
prompt: SessionPrompt
context: SessionContextHook
title: SessionTitleHook
"model.request": SessionModelRequestHook
"http.request": SessionHttpRequestHook
"http.response": SessionHttpResponseHook
@@ -1337,24 +1349,32 @@ interface SessionRetryHook {
decision: RetryDecision
}
interface SessionContextHook {
type SessionRequestOptions = {
maxTokens?: number
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
seed?: number
stop?: string[]
} & Record<string, unknown>
interface SessionRequestHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
system: SystemPart[]
messages: Message[]
options: SessionRequestOptions
}
interface SessionContextHook extends SessionRequestHook {
readonly agent: string
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 SessionTitleHook extends SessionRequestHook {
result?: string
}
interface SessionHookContext {