summaryrefslogtreecommitdiff
path: root/README.md
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-09-25 14:58:55 -0600
committert <t@tjp.lol>2026-09-25 15:11:20 -0600
commit8da8849e71a8dac7ed81d45d7674e6a3f38cbb7c (patch)
tree7c65f92bcf55eafca5b5c7ced18b66e1f5813518 /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.md160
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`.