mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 09:56:24 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c27331fe6 | ||
|
|
8bb97d85d5 | ||
|
|
74c088fc0d | ||
|
|
1de8d14ef2 | ||
|
|
46ce3cb2c9 | ||
|
|
e82a4a1da4 | ||
|
|
ee2e318ec7 |
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
|
||||
@@ -37,8 +37,7 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
|
||||
{ description: name, input: { type: "object" } },
|
||||
]),
|
||||
),
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
options: {},
|
||||
})
|
||||
|
||||
describe("OptimizePlugin", () => {
|
||||
|
||||
@@ -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 }))),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user