diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index b45292d9b5a..98488776aa6 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -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, }) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index f2afd986dde..62df1219a70 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -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 }) }), ) diff --git a/packages/core/src/session/generate.ts b/packages/core/src/session/generate.ts index 47b9dd1f6e5..d8561d016e3 100644 --- a/packages/core/src/session/generate.ts +++ b/packages/core/src/session/generate.ts @@ -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, diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 690abe7dfc1..a55f0b62a7c 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -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) => { - 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 { + readonly event: Event readonly request: LLMRequest readonly options: StreamOptions readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect - /** - * 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[0], ) => Effect.Effect } -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 - readonly messages: Array - } +export interface Input { + readonly session: SessionSchema.Info + readonly agent: Agent.ID + readonly model: SessionRunnerModel.Resolved + readonly tools?: Tool.Snapshot + readonly system: Array + readonly messages: Array 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, 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 => - (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 + readonly primary: (input: Input) => Effect.Effect> + readonly compaction: (input: Input) => Effect.Effect> + readonly generate: (input: Input) => Effect.Effect> + readonly title: (input: Input) => Effect.Effect> } -/** Location-scoped outbound model-request preparation. */ export class Service extends Context.Service()("@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) { + 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[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[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)), + }) }), ) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d6c6992fd6a..1c8103e73e6 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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", diff --git a/packages/core/src/session/runner/step.ts b/packages/core/src/session/runner/step.ts index 919ea8676fa..b43def2c35a 100644 --- a/packages/core/src/session/runner/step.ts +++ b/packages/core/src/session/runner/step.ts @@ -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 readonly retry: ( cause: AIError, error: SessionError.Error, diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 83066d1e790..4ba54f2ede4 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -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 diff --git a/packages/core/test/config/compaction.test.ts b/packages/core/test/config/compaction.test.ts index 5db8a10d97d..e292f942ae9 100644 --- a/packages/core/test/config/compaction.test.ts +++ b/packages/core/test/config/compaction.test.ts @@ -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"), }), diff --git a/packages/core/test/plugin/optimize.test.ts b/packages/core/test/plugin/optimize.test.ts index a9b26a8c5f2..fa4708c3882 100644 --- a/packages/core/test/plugin/optimize.test.ts +++ b/packages/core/test/plugin/optimize.test.ts @@ -41,8 +41,7 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({ { description: name, input: { type: "object" } }, ]), ), - generation: {}, - providerOptions: {}, + options: {}, }) describe("OptimizePlugin", () => { diff --git a/packages/core/test/plugin/plan.test.ts b/packages/core/test/plugin/plan.test.ts index bb2f948bdcf..088cb5d1111 100644 --- a/packages/core/test/plugin/plan.test.ts +++ b/packages/core/test/plugin/plan.test.ts @@ -124,8 +124,7 @@ const request = (agent: Agent.ID, messages: Array): SessionContext => ( system: [], messages, tools: {}, - generation: {}, - providerOptions: {}, + options: {}, }) type ToolErrorEvent = Extract diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index e9f67a87fe2..35b04435e9b 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -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( diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 4bc355e9d68..063d38884c9 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -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 }, ) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 4ed46bca0d9..45d09756c43 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -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"), }), diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index 16fb9e593ce..a5193dca738 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -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}`) diff --git a/packages/core/test/session-native-compaction.test.ts b/packages/core/test/session-native-compaction.test.ts index 872a1212e55..18cd21a0c84 100644 --- a/packages/core/test/session-native-compaction.test.ts +++ b/packages/core/test/session-native-compaction.test.ts @@ -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 }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1928cd318dd..ded6033757b 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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", diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index ccd2fb86dd0..dcea637ea0d 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -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 diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index 89cb42bc7c4..a9cee6c76ca 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -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 & Record + +export interface SessionRequest { readonly sessionID: Session.ID - readonly agent: Agent.ID readonly model: Model.Ref system: Array messages: Array + options: SessionRequestOptions +} + +export interface SessionContext extends SessionRequest { + readonly agent: Agent.ID tools: Record - /** Request overrides; unset fields retain route and model defaults. */ - generation: Types.DeepMutable - providerOptions: Record +} + +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 diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index 2a89c007d58..fc2a35f459c 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -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 & Record + +export interface SessionRequest { readonly sessionID: Session.ID - readonly agent: Agent.ID readonly model: Model.Ref system: Array messages: Array + options: SessionRequestOptions +} + +export interface SessionContext extends SessionRequest { + readonly agent: Agent.ID tools: Record - /** Request overrides; unset fields retain route and model defaults. */ - generation: Types.DeepMutable - providerOptions: Record +} + +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 diff --git a/packages/sdk/test/instances-effect.test.ts b/packages/sdk/test/instances-effect.test.ts index ca4d7d2294b..842e78adc87 100644 --- a/packages/sdk/test/instances-effect.test.ts +++ b/packages/sdk/test/instances-effect.test.ts @@ -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) => diff --git a/packages/server/test/session-instances.test.ts b/packages/server/test/session-instances.test.ts index 5da4e646097..9bdd1360707 100644 --- a/packages/server/test/session-instances.test.ts +++ b/packages/server/test/session-instances.test.ts @@ -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) => diff --git a/services/www/src/docs/content/build/plugins/index.mdx b/services/www/src/docs/content/build/plugins/index.mdx index 773b172b4f1..e5beaef18c4 100644 --- a/services/www/src/docs/content/build/plugins/index.mdx +++ b/services/www/src/docs/content/build/plugins/index.mdx @@ -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 - generation: { + options: { maxTokens?: number temperature?: number topP?: number @@ -1357,8 +1354,7 @@ interface SessionContextHook { presencePenalty?: number seed?: number stop?: string[] - } - providerOptions: Record + } & Record } interface SessionHookContext {