diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4c63972c39..2f338b6b35 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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("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), }) {} diff --git a/packages/core/src/config/warming.ts b/packages/core/src/config/warming.ts new file mode 100644 index 0000000000..5e3b7d4a91 --- /dev/null +++ b/packages/core/src/config/warming.ts @@ -0,0 +1,17 @@ +export * as ConfigWarming from "./warming" + +import { Schema } from "effect" + +export class Info extends Schema.Class("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]) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2488c1c6fd..5457a13d3d 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -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 = [ diff --git a/packages/core/src/plugin/warming.ts b/packages/core/src/plugin/warming.ts new file mode 100644 index 0000000000..3ecc117144 --- /dev/null +++ b/packages/core/src/plugin/warming.ts @@ -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() + 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)) + 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)) + }), + ) + }), +}) diff --git a/packages/core/test/config/warming.test.ts b/packages/core/test/config/warming.test.ts new file mode 100644 index 0000000000..d0ecbf3a91 --- /dev/null +++ b/packages/core/test/config/warming.test.ts @@ -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)) + }) +})