summaryrefslogtreecommitdiff
path: root/warming
diff options
context:
space:
mode:
Diffstat (limited to 'warming')
-rw-r--r--warming/config.lua156
-rw-r--r--warming/policy.lua75
-rw-r--r--warming/warm.lua160
3 files changed, 391 insertions, 0 deletions
diff --git a/warming/config.lua b/warming/config.lua
new file mode 100644
index 0000000..27cafd8
--- /dev/null
+++ b/warming/config.lua
@@ -0,0 +1,156 @@
+-- The layered `[warming]` settings from config.toml.
+--
+-- Warming is off unless configured, like opencode's. Both spellings work:
+--
+-- warming = true -- on with every default
+-- warming = false -- explicitly off
+--
+-- [warming] -- on, with overrides
+-- prompt = "..." -- transient user message
+-- interval = "4 minutes" -- idle time between model requests
+-- duration = "30 minutes" -- window from the latest real request
+-- enabled = false -- opt out here instead of `warming = false`
+--
+-- Settings merge across the host's config layers (base, user, project,
+-- local -- lowest precedence first): later layers win per key, and a layer
+-- that omits a key keeps whatever earlier layers set. Anything unrecognized
+-- is an activation error: a typo in a feature that silently does nothing is
+-- worse than a loud failure.
+
+local M = {}
+
+M.DEFAULT_PROMPT = "Cache keep-alive. Do not perform any work and do not call any tools. Reply with exactly: OK"
+M.DEFAULT_INTERVAL_MS = 4 * 60 * 1000
+M.DEFAULT_DURATION_MS = 30 * 60 * 1000
+
+local UNITS = {
+ s = 1000, sec = 1000, secs = 1000, second = 1000, seconds = 1000,
+ m = 60000, min = 60000, mins = 60000, minute = 60000, minutes = 60000,
+ h = 3600000, hr = 3600000, hrs = 3600000, hour = 3600000, hours = 3600000,
+}
+
+function M.defaults()
+ return {
+ enabled = false,
+ prompt = M.DEFAULT_PROMPT,
+ interval_ms = M.DEFAULT_INTERVAL_MS,
+ duration_ms = M.DEFAULT_DURATION_MS,
+ }
+end
+
+-- parse_duration("4 minutes") -> 240000 | nil, err
+function M.parse_duration(text)
+ if type(text) ~= "string" then
+ return nil, "must be a duration string like \"4 minutes\""
+ end
+ local amount, unit = text:match("^%s*(%d+)%s*(%a+)%s*$")
+ local ms = amount and UNITS[unit:lower()] or nil
+ if not ms then
+ return nil, string.format(
+ "cannot read %q as a duration (want e.g. \"30 seconds\", \"4 minutes\", \"1 hour\")", text)
+ end
+ if tonumber(amount) < 1 then
+ return nil, "must be greater than zero"
+ end
+ return tonumber(amount) * ms
+end
+
+-- apply_layer(settings, value, path) -> true | nil, err
+--
+-- `value` is one config.toml layer's `warming` entry: a boolean or a table
+-- of overrides. Mutates and returns `settings`.
+function M.apply_layer(settings, value, path)
+ if type(value) == "boolean" then
+ settings.enabled = value
+ return true
+ end
+ if type(value) ~= "table" then
+ return nil, string.format("[warming] must be true, false, or a table in %s", path)
+ end
+ -- The table form is opencode's object form: its presence enables warming.
+ settings.enabled = true
+ for key, raw in pairs(value) do
+ if key == "enabled" then
+ if type(raw) ~= "boolean" then
+ return nil, string.format("warming.enabled must be a boolean in %s", path)
+ end
+ settings.enabled = raw
+ elseif key == "prompt" then
+ if type(raw) ~= "string" or raw == "" then
+ return nil, string.format("warming.prompt must be a non-empty string in %s", path)
+ end
+ settings.prompt = raw
+ elseif key == "interval" or key == "duration" then
+ local ms, err = M.parse_duration(raw)
+ if not ms then
+ return nil, string.format("warming.%s %s in %s", key, err, path)
+ end
+ settings[key .. "_ms"] = ms
+ else
+ return nil, string.format("unknown setting [warming] %s in %s", tostring(key), path)
+ end
+ end
+ return true
+end
+
+-- read(ext, io) -> settings | nil, err
+--
+-- Walks `ext.dirs.layers` and merges each layer's `warming` entry.
+-- `io = { read_file(path) -> text|nil, parse(text) -> table }` is injected
+-- so the layer walk runs under the selfcheck without the toml rock or a
+-- filesystem.
+function M.read(ext, io)
+ local settings = M.defaults()
+ local layers = ext.dirs and ext.dirs.layers
+ if type(layers) ~= "table" then
+ return settings
+ end
+ for _, layer in ipairs(layers) do
+ if type(layer) == "table" and type(layer.dir) == "string" then
+ local path = layer.dir .. "/config.toml"
+ local text = io.read_file(path)
+ if text then
+ local ok, parsed = pcall(io.parse, text)
+ if not ok then
+ return nil, string.format("could not parse %s: %s", path, tostring(parsed))
+ end
+ if type(parsed) ~= "table" then
+ return nil, string.format("could not parse %s", path)
+ end
+ if parsed.warming ~= nil then
+ local applied, err = M.apply_layer(settings, parsed.warming, path)
+ if not applied then
+ return nil, err
+ end
+ end
+ end
+ end
+ end
+ return settings
+end
+
+-- The production io: whole-file reads plus the toml2lua parser (`toml`
+-- module). `toml.parse` is strict, so a malformed config.toml fails
+-- activation instead of half-configuring warming.
+function M.default_io()
+ return {
+ read_file = function(path)
+ local file = io.open(path, "r")
+ if not file then
+ return nil
+ end
+ local text = file:read("*a")
+ file:close()
+ return text
+ end,
+ parse = function(text)
+ local ok, toml = pcall(require, "toml")
+ if not ok or type(toml) ~= "table" or type(toml.parse) ~= "function" then
+ error("the 'toml2lua' rock is required to read configuration")
+ end
+ return toml.parse(text, { strict = true })
+ end,
+ }
+end
+
+return M
diff --git a/warming/policy.lua b/warming/policy.lua
new file mode 100644
index 0000000..e129fb0
--- /dev/null
+++ b/warming/policy.lua
@@ -0,0 +1,75 @@
+-- Timing policy for session warming: WHEN a transient request may fire.
+--
+-- The semantics mirror opencode's warming feature. `interval` is the idle
+-- time before a warm request fires after real activity (and again after each
+-- warm attempt); `duration` is the warming window measured from the latest
+-- non-warming activity. Warm attempts never extend the window:
+--
+-- 00:00 real activity window = [00:00, 00:30]
+-- 00:04 warm next due 00:08
+-- 00:08 warm ...
+-- 00:30 window expired, warming stops
+--
+-- A turn in flight (`turn_started` without `turn_ended`) blocks warm
+-- requests: the live conversation is changing under us. With
+-- `interval >= duration` the window always expires before the first warm
+-- request is due, so warming never fires -- a documented consequence of the
+-- opencode semantics, not a special case.
+--
+-- Every entry point takes `now` (monotonic milliseconds) so the policy is
+-- pure and selfcheck-able; scheduling and IO live elsewhere.
+
+local M = {}
+
+local Policy = {}
+Policy.__index = Policy
+
+-- interval_ms and duration_ms are validated positive by config parsing.
+function M.new(interval_ms, duration_ms)
+ return setmetatable({
+ interval = interval_ms,
+ duration = duration_ms,
+ last_real = nil, -- ms of the latest non-warming activity
+ next_warm = nil, -- ms before which no warm request may fire
+ deadline = nil, -- ms at which the warming window closes
+ busy = false, -- a real turn is in flight
+ }, Policy)
+end
+
+function Policy:activity(now)
+ self.last_real = now
+ self.next_warm = now + self.interval
+ self.deadline = now + self.duration
+end
+
+function Policy:turn_started(now)
+ self.busy = true
+ self:activity(now)
+end
+
+function Policy:turn_ended(now)
+ self.busy = false
+ self:activity(now)
+end
+
+-- May a warm request start right now?
+function Policy:due(now)
+ return (not self.busy)
+ and self.next_warm ~= nil
+ and now >= self.next_warm
+ and now < self.deadline
+end
+
+-- A warm attempt settled (success or failure): schedule the next one. A
+-- failure waits out the same interval and the original window keeps running.
+function Policy:attempted(now)
+ self.next_warm = now + self.interval
+end
+
+-- Nothing left to do until real activity reopens the window. Lets the
+-- caller stop its timer instead of ticking forever.
+function Policy:expired(now)
+ return self.deadline == nil or now >= self.deadline
+end
+
+return M
diff --git a/warming/warm.lua b/warming/warm.lua
new file mode 100644
index 0000000..e5e41fc
--- /dev/null
+++ b/warming/warm.lua
@@ -0,0 +1,160 @@
+-- One transient warming request: the session's current prompt, a minimal
+-- extra user message, one model response -- and none of it kept.
+--
+-- The request runs on a throwaway agent so the live session is never
+-- touched. The pieces:
+--
+-- transcript `panto.file_system_jsonl_store` over the session's own
+-- store dir, then `load` -- the same reconstruction `/resume`
+-- uses, so system blocks, tool inputs, and thinking
+-- signatures come back verbatim and the request shares the
+-- session's prompt prefix byte for byte.
+-- config `panto.ext.resolve_model` for the session's current
+-- model/reasoning label, which inherits the live credentialed
+-- transport when it resolves the session's own model.
+-- tools the session agent's declarations, copied with `set_tools`
+-- so the tools array matches the session's own requests (see
+-- the README's fidelity notes on schema re-encoding).
+-- store `panto.null_store()` -- the warm turn's user message and
+-- response are appended to the throwaway conversation and
+-- persisted nowhere.
+-- turn `agent:run_async { prompt, dispatch_tools = false }` on its
+-- own pump thread, so the UI thread never blocks. Tools are
+-- never dispatched: a misbehaving model cannot run real
+-- tools from a keep-alive prompt. The response is drained
+-- and dropped.
+--
+-- Nothing here raises: the binding reports failures by raising, and every
+-- call goes through `try` so callers get one `nil, message` shape.
+
+local M = {}
+
+local function try(fn, ...)
+ local ok, value = pcall(fn, ...)
+ if not ok then
+ return false, tostring(value)
+ end
+ return true, value
+end
+
+-- start(prompt) -> handle | nil, err
+--
+-- Everything that can fail before the turn starts fails here: a caller
+-- either gets a handle to poll or a message.
+function M.start(prompt)
+ local panto = require("panto")
+ local ext = panto.ext
+
+ local ok, info = try(ext.session_info)
+ if not ok then
+ return nil, "session_info: " .. tostring(info)
+ end
+ if type(info) ~= "table" or type(info.session_dir) ~= "string" or type(info.session_id) ~= "string" then
+ return nil, "no session information available"
+ end
+
+ local opened, store = try(panto.file_system_jsonl_store, { dir = info.session_dir })
+ if not opened then
+ return nil, "session store: " .. tostring(store)
+ end
+ if store == nil then
+ return nil, "the session store could not be opened"
+ end
+
+ local loaded, conv = try(store.load, store, info.session_id)
+ if not loaded then
+ return nil, "session store: " .. tostring(conv)
+ end
+ if conv == nil then
+ return nil, "the session transcript could not be loaded"
+ end
+
+ local asked, cfg, resolve_err = pcall(ext.resolve_model, {
+ model = info.model,
+ reasoning = info.reasoning,
+ })
+ if not asked then
+ return nil, tostring(cfg)
+ end
+ if cfg == nil then
+ return nil, resolve_err and tostring(resolve_err) or "the session model could not be resolved"
+ end
+
+ local made, no_store = try(panto.null_store)
+ if not made then
+ return nil, tostring(no_store)
+ end
+
+ local built, agent = try(panto.agent, {
+ config = cfg,
+ store = no_store,
+ conversation = conv,
+ })
+ if not built then
+ return nil, "warm agent: " .. tostring(agent)
+ end
+ if agent == nil then
+ return nil, "the warm agent could not be created"
+ end
+
+ local primary = ext.agent
+ if primary ~= nil then
+ local got, decls = try(primary.tools, primary)
+ if not got then
+ return nil, "tools: " .. tostring(decls)
+ end
+ if type(decls) == "table" and #decls > 0 then
+ local armed, tools_err = try(agent.set_tools, agent, decls)
+ if not armed then
+ return nil, "warm tools: " .. tostring(tools_err)
+ end
+ end
+ end
+
+ local started, job = try(agent.run_async, agent, {
+ prompt = prompt,
+ dispatch_tools = false,
+ })
+ if not started then
+ return nil, "warm turn: " .. tostring(job)
+ end
+ if job == nil then
+ return nil, "the warm turn could not be started"
+ end
+
+ return { agent = agent, job = job, store = store, conv = conv }
+end
+
+-- poll(handle) -> nil while the pump runs, then a settled
+-- { status = "completed"|"failed"|"cancelled", err = ... }. Drains buffered
+-- events as it goes so a chatty response cannot grow the queue. The result
+-- content is deliberately discarded; `close` joins the (settled) pump.
+function M.poll(handle)
+ local job = handle.job
+ while true do
+ local ok, event = pcall(job.next_event, job)
+ if not ok or event == nil then
+ break
+ end
+ end
+ local ok, result = pcall(job.result, job)
+ if not ok then
+ pcall(job.close, job)
+ return { status = "failed", err = tostring(result) }
+ end
+ if type(result) ~= "table" then
+ return nil
+ end
+ pcall(job.close, job)
+ return { status = result.status or "failed", err = result.err }
+end
+
+-- abandon(handle): cancel and join the pump, on session teardown. The
+-- cancel half shuts the response socket down, so the join returns promptly
+-- even mid-request.
+function M.abandon(handle)
+ pcall(handle.job.request_cancel, handle.job)
+ pcall(handle.job.close, handle.job)
+end
+
+return M