feat(core): add session warming
This commit is contained in:
parent
a0a6963beb
commit
f683eef5f9
5 changed files with 127 additions and 0 deletions
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
|
|||
17
packages/core/src/config/warming.ts
Normal file
17
packages/core/src/config/warming.ts
Normal 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])
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
80
packages/core/src/plugin/warming.ts
Normal file
80
packages/core/src/plugin/warming.ts
Normal 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))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
24
packages/core/test/config/warming.test.ts
Normal file
24
packages/core/test/config/warming.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue