diff --git a/packages/core/src/plugin/warming.ts b/packages/core/src/plugin/warming.ts index 3ecc1171442..d7bb9d0fd84 100644 --- a/packages/core/src/plugin/warming.ts +++ b/packages/core/src/plugin/warming.ts @@ -15,65 +15,86 @@ export const Plugin = define({ id: "opencode.warming", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const warming = Config.latest(yield* config.entries(), "warming") - if (!warming) return - const settings = warming === true ? defaults : { ...defaults, ...warming } - const interval = Duration.toMillis(settings.interval) - const duration = Duration.toMillis(settings.duration) - if (!Number.isFinite(interval) || interval <= 0 || !Number.isFinite(duration) || duration <= 0) { + const loadSettings = Effect.fn("WarmingPlugin.loadSettings")(function* () { + const warming = Config.latest(yield* config.entries(), "warming") + if (!warming) return + const settings = warming === true ? defaults : { ...defaults, ...warming } + const interval = Duration.toMillis(settings.interval) + const duration = Duration.toMillis(settings.duration) + if (Number.isFinite(interval) && interval > 0 && Number.isFinite(duration) && duration > 0) return settings yield* Effect.logWarning("warming interval and duration must be finite positive durations") - return - } + }) const scope = yield* Scope.Scope - const sessions = new Map() - const loop: (sessionID: SessionSchema.ID) => Effect.Effect = Effect.fn("WarmingPlugin.loop")(function* ( - sessionID, - ) { - const current = sessions.get(sessionID) - if (!current) return + const sessions = new Map() + const loop: (sessionID: SessionSchema.ID) => Effect.Effect = Effect.fn("WarmingPlugin.loop")( + function* (sessionID) { + const current = sessions.get(sessionID) + if (!current) return - const now = yield* Clock.currentTimeMillis - const next = Math.min(current.last + interval, current.expires) - if (now < next) { - yield* Effect.sleep(Duration.millis(next - now)) + const now = yield* Clock.currentTimeMillis + const next = Math.min(current.last + Duration.toMillis(current.settings.interval), current.expires) + if (now < next) { + yield* Effect.sleep(Duration.millis(next - now)) + return yield* loop(sessionID) + } + if (now >= current.expires) { + sessions.delete(sessionID) + return + } + + const last = current.last + yield* Effect.logInfo("warming session", { sessionID, last }) + yield* ctx.session + .generate({ sessionID, prompt: current.settings.prompt }) + .pipe(Effect.catchCause((cause) => Effect.logWarning("failed to warm session", { sessionID, cause }))) + const latest = sessions.get(sessionID) + if (latest === current && latest.last === last) latest.last = yield* Clock.currentTimeMillis return yield* loop(sessionID) - } - if (now >= current.expires) { - sessions.delete(sessionID) - return - } - - const last = current.last - yield* ctx.session.generate({ sessionID, prompt: settings.prompt }).pipe( - Effect.catchCause((cause) => Effect.logWarning("failed to warm session", { sessionID, cause })), - ) - const latest = sessions.get(sessionID) - if (latest === current && latest.last === last) latest.last = yield* Clock.currentTimeMillis - return yield* loop(sessionID) - }) + }, + ) yield* ctx.session.hook("context", (event) => Effect.gen(function* () { + const active = sessions.get(event.sessionID) + const settings = yield* loadSettings() + if (!settings) { + sessions.delete(event.sessionID) + return + } + // Once generate exposes request metadata to context hooks, tag warm requests instead of matching the prompt. const message = event.messages.at(-1) if ( message?.role === "user" && message.content.length === 1 && message.content[0]?.type === "text" && - message.content[0].text === settings.prompt - ) + (message.content[0].text === active?.settings.prompt || message.content[0].text === settings.prompt) + ) { + if (active) active.settings = settings return + } const now = yield* Clock.currentTimeMillis - const active = sessions.get(event.sessionID) + const duration = Duration.toMillis(settings.duration) if (active) { active.last = now active.expires = now + duration + active.settings = settings return } - sessions.set(event.sessionID, { last: now, expires: now + duration }) - yield* loop(event.sessionID).pipe(Effect.forkIn(scope)) + sessions.set(event.sessionID, { last: now, expires: now + duration, settings }) + yield* Effect.logInfo("scheduled session warming", { + sessionID: event.sessionID, + interval: settings.interval, + expires: now + duration, + }) + yield* loop(event.sessionID).pipe( + Effect.catchCause((cause) => + Effect.logError("session warming loop failed", { sessionID: event.sessionID, cause }), + ), + Effect.forkIn(scope), + ) }), ) }), diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index 1ca5fa1ac60..2214b179c9d 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -48,6 +48,11 @@ export const layer = Layer.effect( ], tools: {}, }) + yield* Effect.logInfo("sending session generation request", { + sessionID: selection.session.id, + providerID: model.ref.providerID, + modelID: model.ref.id, + }) return (yield* llm.generate( LLM.request({ model: model.model, diff --git a/packages/docs/config.mdx b/packages/docs/config.mdx index 25d9cb0989c..bf53d35b452 100644 --- a/packages/docs/config.mdx +++ b/packages/docs/config.mdx @@ -338,6 +338,25 @@ Control automatic context compaction and how much recent context it preserves. See the [compaction guide](/compaction) for automatic context management. +### Session warming + +Keep recently active model sessions warm with periodic transient requests. +Warming is disabled by default; set it to `true` to use the four-minute idle +interval and 30-minute active window. + +```jsonc +{ + "warming": { + "prompt": "Do not perform any work. Reply with exactly: OK", + "interval": "4 minutes", + "duration": "30 minutes" + } +} +``` + +See the [session warming guide](/warming) for request behavior, customization, +and cost considerations. + ### Skills Add directories or URLs that OpenCode should search for agent skills. diff --git a/packages/docs/docs.json b/packages/docs/docs.json index fbbf84d3fa8..ff41ab4b92e 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -37,6 +37,7 @@ "mcp-servers", "attachments", "compaction", + "warming", "formatters", "lsp", "references" diff --git a/packages/docs/warming.mdx b/packages/docs/warming.mdx new file mode 100644 index 00000000000..8b7bd06a59c --- /dev/null +++ b/packages/docs/warming.mdx @@ -0,0 +1,76 @@ +--- +title: "Session warming" +description: "Keep recently active model sessions warm with periodic transient requests." +--- + +Session warming sends periodic model requests for recently active sessions. +This can preserve provider-side prompt caches or other short-lived session +state while you pause between prompts. + +Warming is disabled by default. Enable it with the default settings in any +[OpenCode configuration file](/config): + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "warming": true +} +``` + +With the defaults, OpenCode sends a warming request after a session has made no +model request for four minutes. It repeats this while the session remains idle, +but stops 30 minutes after the last non-warming request. New model activity +starts a new 30-minute window. + +## Configuration + +Use the object form to customize the prompt, idle interval, or active duration: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "warming": { + "prompt": "Do not perform any work. Reply with exactly: OK", + "interval": "4 minutes", + "duration": "30 minutes" + } +} +``` + +| Field | Default | Description | +| --- | --- | --- | +| `prompt` | Keep-alive instruction | Prompt sent in each warming request. The default instructs the model to do no work and reply with `OK`. | +| `interval` | `"4 minutes"` | Idle time between warming requests. | +| `duration` | `"30 minutes"` | Maximum warming window after the latest non-warming model request. | + +`interval` and `duration` accept duration strings such as `"30 seconds"`, +`"4 minutes"`, or `"1 hour"`. Both must be finite and greater than zero. + +To disable warming explicitly: + +```jsonc +{ + "warming": false +} +``` + +## Request behavior + +A warming request uses the session's current model, agent, instructions, and +conversation context. Tools are disabled. The configured prompt is appended as +a transient user message, and the response is discarded. + +Warming does not admit input, add messages to session history, or otherwise +mutate durable session state. A warming request resets the idle interval but +does not extend the active duration; without new model activity, warming still +ends when the configured duration expires. + +## Costs and limits + +Warming requests are real provider requests. They can consume tokens, incur +costs, count against rate limits, and fail for the same reasons as other model +requests. OpenCode logs warming failures without failing or changing the +session, then waits for the next interval before trying again. + +Enable warming only when the provider-side benefit is worth the additional +requests. A shorter interval or longer duration increases request volume. diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 553e91281ee..edffdbd4f80 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -123,6 +123,12 @@ function makeRoutes( }), ) : AppNodeBuilder.build(applicationServices, replacements) + const observability = Observability.layer({ + ...options.observability, + client: options.app?.name, + version: options.app?.version, + channel: options.app?.channel, + }) return serviceLayer.pipe( Layer.flatMap((context) => { @@ -139,18 +145,11 @@ function makeRoutes( Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), - Layer.provide( - Observability.layer({ - ...options.observability, - client: options.app?.name, - version: options.app?.version, - channel: options.app?.channel, - }), - ), HttpRouter.provideRequest(requestServices), Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer), ) }), + Layer.provide(observability), ) }