fix(core): make session warming observable

This commit is contained in:
Dax Raad 2026-07-21 21:30:05 -04:00
parent c821d49386
commit e84938b309
6 changed files with 166 additions and 45 deletions

View file

@ -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<SessionSchema.ID, { last: number; expires: number }>()
const loop: (sessionID: SessionSchema.ID) => Effect.Effect<void> = Effect.fn("WarmingPlugin.loop")(function* (
sessionID,
) {
const current = sessions.get(sessionID)
if (!current) return
const sessions = new Map<SessionSchema.ID, { last: number; expires: number; settings: typeof defaults }>()
const loop: (sessionID: SessionSchema.ID) => Effect.Effect<void> = 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),
)
}),
)
}),

View file

@ -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,

View file

@ -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.

View file

@ -37,6 +37,7 @@
"mcp-servers",
"attachments",
"compaction",
"warming",
"formatters",
"lsp",
"references"

76
packages/docs/warming.mdx Normal file
View file

@ -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.

View file

@ -123,6 +123,12 @@ function makeRoutes<AuthError, AuthServices>(
}),
)
: 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<AuthError, AuthServices>(
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),
)
}