feat(core): add session warming

This commit is contained in:
Dax Raad 2026-07-21 20:43:10 -04:00
commit f683eef5f9
5 changed files with 127 additions and 0 deletions

View file

@ -30,6 +30,7 @@ import { ConfigReference } from "./config/reference"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
import { ConfigWarming } from "./config/warming"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
import { WellKnown } from "./wellknown"
@ -110,6 +111,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {}

View file

@ -0,0 +1,17 @@
export * as ConfigWarming from "./warming"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigV2.Warming")({
prompt: Schema.String.pipe(Schema.optional).annotate({
description: "Prompt sent for keep-alive requests",
}),
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Idle time between keep-alive requests (default: "4 minutes")',
}),
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
}),
}) {}
export const Warming = Schema.Union([Schema.Boolean, Info])

View file

@ -54,6 +54,7 @@ import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { SystemPromptPlugin } from "./system-prompt"
import { VariantPlugin } from "./variant"
import { WarmingPlugin } from "./warming"
import { WellKnownPlugin } from "../wellknown/plugin"
const services = Effect.fn("PluginInternal.services")(function* () {
@ -143,6 +144,7 @@ const pre = [
WebFetchTool.Plugin,
WebSearchTool.Plugin,
WriteTool.Plugin,
WarmingPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
const post = [

View file

@ -0,0 +1,80 @@
export * as WarmingPlugin from "./warming"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Clock, Duration, Effect, Scope } from "effect"
import { Config } from "../config"
import { SessionSchema } from "../session/schema"
const defaults = {
prompt: "This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK",
interval: Duration.minutes(4),
duration: Duration.minutes(30),
}
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) {
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 now = yield* Clock.currentTimeMillis
const next = Math.min(current.last + 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* 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* () {
// 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
)
return
const now = yield* Clock.currentTimeMillis
const active = sessions.get(event.sessionID)
if (active) {
active.last = now
active.expires = now + duration
return
}
sessions.set(event.sessionID, { last: now, expires: now + duration })
yield* loop(event.sessionID).pipe(Effect.forkIn(scope))
}),
)
}),
})

View file

@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { Duration, Schema } from "effect"
import { Config } from "../../src/config"
const decode = Schema.decodeUnknownSync(Config.Info)
describe("config warming", () => {
test("accepts boolean enablement", () => {
expect(decode({}).warming).toBeUndefined()
expect(decode({ warming: false }).warming).toBe(false)
expect(decode({ warming: true }).warming).toBe(true)
})
test("decodes custom durations", () => {
const warming = decode({
warming: { prompt: "Reply pong", interval: "2 minutes", duration: "1 hour" },
}).warming
expect(typeof warming).toBe("object")
if (typeof warming !== "object") return
expect(warming.prompt).toBe("Reply pong")
expect(warming.interval).toEqual(Duration.minutes(2))
expect(warming.duration).toEqual(Duration.hours(1))
})
})