diff options
Diffstat (limited to 'warming')
| -rw-r--r-- | warming/config.lua | 156 | ||||
| -rw-r--r-- | warming/policy.lua | 75 | ||||
| -rw-r--r-- | warming/warm.lua | 160 |
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 |
