mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 00:13:35 +00:00
fix(core): make session warming observable
This commit is contained in:
parent
c821d49386
commit
e84938b309
6 changed files with 166 additions and 45 deletions
|
|
@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"mcp-servers",
|
||||
"attachments",
|
||||
"compaction",
|
||||
"warming",
|
||||
"formatters",
|
||||
"lsp",
|
||||
"references"
|
||||
|
|
|
|||
76
packages/docs/warming.mdx
Normal file
76
packages/docs/warming.mdx
Normal 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.
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue