feat(plugin): add session title hook and request options bag (#47663)

This commit is contained in:
Aiden Cline
2026-09-09 14:14:23 -05:00
committed by GitHub
parent c7dd0c8278
commit 9ae6b21f6a
22 changed files with 377 additions and 324 deletions
+14 -21
View File
@@ -11,7 +11,6 @@ import {
Message,
type ContentPart,
} from "@opencode/ai"
import { Agent } from "@opencode/schema/agent"
import { SessionError } from "@opencode/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
@@ -97,7 +96,7 @@ export type Editor = {
export type AutoInput = {
readonly context: SessionContext.Loaded
readonly prepare: SessionModelRequest.Interface["prepare"]
readonly prepare: SessionModelRequest.Interface["compaction"]
/** Known overflow must recover from durable history, not submit the overflowing native window again. */
readonly overflow?: boolean
}
@@ -120,7 +119,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 & {
@@ -428,22 +427,16 @@ export const layer = Layer.effect(
messages,
})
return 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)] : []),
...prompt,
],
},
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)] : []),
...prompt,
],
webSocket,
})
}
@@ -478,7 +471,7 @@ export const layer = Layer.effect(
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
)
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
agent: context.agent.id,
model: context.model.ref,
hook: prepared.retry,
})
@@ -580,7 +573,7 @@ export const layer = Layer.effect(
])
// Both requests share the retry allowance; rejected output never enters the reminder request.
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
agent: context.agent.id,
model: context.model.ref,
hook: prepared.retry,
})
+3 -3
View File
@@ -65,7 +65,7 @@ export interface Interface {
}
| undefined
>
readonly prepare: SessionModelRequest.Interface["prepare"]
readonly request: SessionModelRequest.Interface
}
/** Location-scoped model-context loader for durable Session Steps. */
@@ -84,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
@@ -173,7 +173,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
@@ -43,17 +43,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,
+158 -200
View File
@@ -1,8 +1,17 @@
export * as SessionModelRequest from "./model-request.js"
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode/ai"
import {
GenerationOptions,
type GenerationOptionsFields,
HttpOptions,
LanguageModel,
LLM,
LLMRequest,
Message,
SystemPart,
} from "@opencode/ai"
import type { StreamOptions } from "@opencode/ai/route"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
import type { SessionContext, SessionRequest, SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Content } from "@opencode/schema/tool"
@@ -26,60 +35,31 @@ 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))
/** 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 {
export interface Prepared<Event = SessionRequest> {
readonly event: Event
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"
}
@@ -193,90 +173,16 @@ 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<SessionContext>>
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly title: (input: Input) => Effect.Effect<Prepared<SessionTitle>>
}
/** Location-scoped outbound model-request preparation. */
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelRequest") {}
export const layer = Layer.effect(
@@ -285,114 +191,166 @@ 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 ?? {
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,
}),
)
})
// History selects native windows against the catalog route before hooks run. A newly installed
// routing hook must not send an existing opaque window to another deployment; `prepare` has no
// error channel, so like hook failures this surfaces as a defect.
const selected = SessionProviderContext.provenance(resolved)
const selected = SessionProviderContext.provenance(model)
if (
selected &&
!SessionProviderContext.compatible(
selected,
SessionProviderContext.provenance({ model: request.model, ref: resolved.ref }),
SessionProviderContext.provenance({ model: request.model, ref: model.ref }),
) &&
request.messages.some((message) => message.content.some((part) => part.type === "compaction"))
)
return yield* Effect.die(
new Error("Provider context is incompatible with the route selected by model request hooks"),
)
const hasHttpHooks =
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
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
// HTTP hooks must observe every request, so they keep the provider on HTTP.
const options: StreamOptions = {
...(http ? { http } : {}),
...(input.webSocket === "session" &&
const webSocket =
input.webSocket === "session" &&
!hasHttpHooks &&
resolved.capabilities.responsesWebsockets === true &&
resolved.websocket
? { 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)
model.capabilities.responsesWebsockets === true &&
model.websocket
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)),
})
}),
)
+11 -11
View File
@@ -130,7 +130,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,
@@ -209,7 +209,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 result = yield* compaction.compact(compactionInput)
@@ -226,15 +226,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",
+1 -1
View File
@@ -46,7 +46,7 @@ interface Input {
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly prepared: SessionModelRequest.Prepared
readonly prepared: Omit<SessionModelRequest.Prepared, "event">
readonly retry: (
cause: AIError,
error: SessionError.Error,
+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 (prepared.event.result !== undefined) return prepared.event.result
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
@@ -41,8 +41,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" }>
@@ -173,7 +173,9 @@ describe("OpenAIPlugin", () => {
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false)
}),
)
@@ -244,22 +246,20 @@ describe("OpenAIPlugin", () => {
websocket,
})
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(
@@ -77,10 +77,12 @@ const fixture = Effect.fn(function* () {
const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolveModel(model)
const service = yield* SessionModelRequest.Service
const prepared = yield* service.prepare({
kind: "primary",
scope: { session, agentID: scope.agent, model: resolved },
transcript: { system: [], messages: [Message.user("Hello")] },
const prepared = yield* service.primary({
session,
agent: scope.agent,
model: resolved,
system: [],
messages: [Message.user("Hello")],
})
return yield* LLMClient.stream(prepared.request, prepared.options).pipe(
Stream.runCollect,
@@ -173,7 +175,7 @@ it.live("manual PAT uses its account and native compatibility rather than an SDK
"context",
(event) =>
Effect.sync(() => {
event.generation.maxTokens = 1024
event.options.maxTokens = 1024
}),
{ providerID },
)
@@ -384,7 +384,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"),
}),
@@ -460,7 +460,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(),
@@ -511,7 +511,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,10 +57,12 @@ 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
if (!http) throw new Error(`Expected HTTP middleware for ${kind}`)
@@ -230,7 +230,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
messages: yield* store.context(sessionID),
inputID: SessionMessage.ID.create(),
resolveContext: () => load,
prepare: requests.prepare,
prepare: requests.compaction,
})
})
const checkpoint = Effect.gen(function* () {
@@ -247,7 +247,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
return {
compact,
automatic: Effect.gen(function* () {
return yield* compaction.compact({ context: yield* load, prepare: requests.prepare })
return yield* compaction.compact({ context: yield* load, prepare: requests.compaction })
}),
checkpoint,
prompt,
@@ -284,10 +284,12 @@ it.live(
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
yield* fixture.prompt("Second real user request")
const context = yield* fixture.load
const prepared = yield* fixture.requests.prepare({
kind: "primary",
scope: { session: context.session, model: context.model, agentID: context.agent.id, tools: context.tools },
transcript: SessionModelRequest.baseTranscript({ ...context, agent: context.agent.info }),
const prepared = yield* fixture.requests.primary({
session: context.session,
agent: context.agent.id,
model: context.model,
tools: context.tools,
...SessionModelRequest.baseTranscript({ ...context, agent: context.agent.info }),
})
const client = yield* LLMClient.Service
yield* client.generate(prepared.request, prepared.options)
@@ -331,7 +333,7 @@ it.live(
expect(fixture.state.calls).toBe(7)
expect(retries).toMatchObject([
{
agent: "compaction",
agent: "build",
attempt: 2,
error: { type: "provider.rate-limit" },
decision: { retry: true, delay: 0 },
+21 -3
View File
@@ -1078,6 +1078,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()
@@ -2399,7 +2417,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) =>
@@ -2450,7 +2468,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 },
@@ -2565,7 +2583,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"
import {
AIError,
LLMClient,
LLMEvent,
LanguageModel,
Message,
SystemPart,
TransportError,
type LLMRequest,
} from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Agent } from "@opencode/core/agent"
import { Catalog } from "@opencode/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
+15 -5
View File
@@ -18,16 +18,25 @@ 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>
}
export interface SessionTitle extends SessionRequest {
/** Set to use this title and skip the model request. */
result?: string
}
/**
@@ -76,6 +85,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
+15 -5
View File
@@ -18,16 +18,25 @@ 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>
}
export interface SessionTitle extends SessionRequest {
/** Set to use this title and skip the model request. */
result?: string
}
/**
@@ -76,6 +85,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) =>
@@ -1199,15 +1199,14 @@ 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
})
```
@@ -1215,12 +1214,10 @@ 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.
Compaction context hooks receive the selected session agent. Its model-request
and HTTP hooks retain the `compaction` agent identity for provider-specific handling.
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.
@@ -1236,7 +1233,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" },
)
@@ -1348,7 +1345,7 @@ interface SessionContextHook {
system: SystemPart[]
messages: Message[]
tools: Record<string, { description: string; input: JsonSchema }>
generation: {
options: {
maxTokens?: number
temperature?: number
topP?: number
@@ -1357,8 +1354,7 @@ interface SessionContextHook {
presencePenalty?: number
seed?: number
stop?: string[]
}
providerOptions: Record<string, unknown>
} & Record<string, unknown>
}
interface SessionHookContext {