diff options
| author | t <t@tjp.lol> | 2026-09-25 14:58:55 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-09-25 15:11:20 -0600 |
| commit | 8da8849e71a8dac7ed81d45d7674e6a3f38cbb7c (patch) | |
| tree | 7c65f92bcf55eafca5b5c7ced18b66e1f5813518 /README.md | |
Add panto-warming session cache warming extension
Send a minimal transient prompt on idle intervals to keep the
provider-side prompt cache warm, then discard prompt and response so
the context window and transcript stay untouched.
Disabled by default; enable via warming table with layered interval,
duration, and prompt settings.
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..d57d9f4 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# panto-warming + +Session warming for [Pantograph](https://github.com/travisp/pantograph), +modeled on [opencode's warming +feature](https://opencode.ai/v2/docs/warming/). While you pause between +prompts, the extension periodically sends a **minimal transient prompt** on +top of the session's current context so the provider-side prompt cache stays +warm — then throws the prompt and the response away. Nothing is added to the +context window or the session transcript. + +Disabled by default. Warming requests are real provider requests: they +consume tokens, cost money, and count against rate limits. + +## Install + +```toml +[extensions] +rocks = ["panto-warming"] +``` + +For a local checkout, use `paths = ["/path/to/panto-warming"]` instead. A +`paths` entry loads the code but installs nothing, so put the dependencies +in panto's own rocks tree first (`mise run deps`, or the `luarocks` line it +contains). + +## Configuration + +Enable with defaults in any layered `config.toml` (the built-in base layer, +`${XDG_CONFIG_HOME:-$HOME/.config}/panto/`, `.panto/`, and `.panto/local/`): + +```toml +warming = true +``` + +Or configure it as a table — its presence enables warming: + +```toml +[warming] +prompt = "Do not perform any work. Reply with exactly: OK" +interval = "5 minutes" +duration = "1 hour" +``` + +| Field | Default | Purpose | +| ---------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `prompt` | keep-alive instruction (do no work, no tools, reply `OK`) | appended as a transient user message for each warming request | +| `interval` | `"4 minutes"` | idle time before the next warming request | +| `duration` | `"30 minutes"` | warming window measured from the latest non-warming model request | +| `enabled` | `true` when the table is present | opt out in one layer (`enabled = false`, or the scalar `warming = false`) | + +Omitted fields keep their defaults. `interval` and `duration` take duration +strings (`"30 seconds"`, `"4 minutes"`, `"1 hour"`; singular and a few +abbreviations work too) and must be greater than zero — anything else fails +activation with a message naming the file, because a silently misread setting +in a feature that spends money is worse than a loud error. + +Settings merge across the config layers, lowest precedence first; later +layers win per key. A higher layer can therefore shorten the interval, extend +the window, or turn warming off entirely. + +### Timing + +Warming starts after session start or any non-warming model request, and any +new real activity resets both timers. A warming request never extends the +window; after each attempt (success or failure) the next one waits out a full +interval: + +``` +00:00 real activity window = [00:00, 00:30] +00:04 warming request +00:08 warming request +... +00:30 warming stops +``` + +If `interval >= duration`, the window always expires before a warming +request is due and none is ever sent. Failures are silent (extensions have +no logging channel): the session is unchanged and the next interval retries. +Nothing warms while a real turn is in flight. + +## How a warming request works + +```mermaid +sequenceDiagram + participant Timer + participant Warm as warm agent (throwaway) + participant Store as session store + participant Provider + Timer->>Store: replay transcript (same as /resume) + Timer->>Warm: panto.agent { conversation, model, tools, null_store } + Timer->>Warm: run_async { transient prompt, dispatch_tools = false } + Warm->>Provider: [session prefix] + [transient user message] + Provider-->>Warm: one response + Warm--)Timer: settled; response discarded +``` + +The warm request shares the session's prompt prefix and differs only by the +transient user message, so a provider that caches prefixes (Anthropic with +its `cache_control` breakpoints, OpenAI's prompt caching) sees the shared +prefix again and keeps it hot. Then everything is dropped: + +- **Transcript** — reloaded from the session's own JSONL store with the same + machinery `/resume` uses. System blocks, tool inputs, and thinking + signatures replay verbatim, so the messages half of the prefix is + byte-identical to the session's own requests. +- **Model** — `panto.ext.resolve_model` for the session's current + model/reasoning label, inheriting the live credentialed transport. +- **Tools** — the session agent's declarations, copied onto the warm agent. +- **Turn** — one `run_async` on its own pump thread (the UI never blocks), + with `dispatch_tools = false`: a misbehaving model cannot run real tools + from a keep-alive prompt, and tool calls are handed back unresolved and + discarded with the rest. + +The warm turn's user message and response live only in the throwaway +agent's conversation and a `null_store` — the live session, its transcript, +and its persistence are never touched. + +### Fidelity caveat: tool schemas + +Tool declarations cross a decode/re-encode seam when copied to the warm +agent (`agent:tools()` decodes the session's schema JSON, `set_tools` +re-serializes it), and Lua table iteration can reorder JSON object keys +along the way. Both Anthropic and OpenAI key their caches on the **exact** +rendered prefix — tools included, at position 0 — so when the re-encoded +schema bytes differ from the session's own, that warming request cannot +write a cache entry the session's next request will hit, and buys nothing. + +This is unfixable from an extension: no binding surface passes raw schema +bytes to a second agent. A small `schema_json` passthrough in +`libpanto-lua` (`agent:tools()` exposing the raw bytes, `set_tools` +accepting them) would make the copy exact. Until that lands, the self-check +measures how often re-encoding drifts on the local codec: + +``` +$ panto lua _selfcheck.lua +probe: schema re-encode drift 3/4 (tool prefix fidelity degraded; see README) +``` + +The drift is a property of the shared JSON codec's table iteration, not of +this rock; the messages half of the prefix is unaffected. + +## Costs + +Warming requests are billed like any other. Shorter intervals increase +request volume; longer durations allow more of them. Enable warming only +when the provider-side benefit is worth the added usage — with the defaults, +one uninterrupted 30-minute window sends at most seven warming requests. + +## Development + +```sh +mise trust # once +mise run deps # install toml2lua into panto's rocks tree (for `paths` use) +mise run check # panto lua _selfcheck.lua +``` + +`warming/policy.lua` and `warming/config.lua` are pure (durations, layer +merging, the timing state machine) and fully covered by the self-check; +`warming/warm.lua` is the binding glue. Release plumbing: `mise run version +<x.y.z>`, commit, `mise run release`. |
