ytdb-slate 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,50 @@
2
2
 
3
3
  Slate is a thread-weaving orchestration extension for the [pi coding agent](https://github.com/earendil-works/pi-coding-agent).
4
4
 
5
- The orchestrator (your main pi session) dispatches **bounded actions** to persistent **worker threads**. Each completed action is compressed by an LLM into an **episode** — a durable, structured record (intent, actions, findings, artifacts, open issues, handoff notes) that the orchestrator composes into further dispatches instead of re-reading raw transcripts. On top of that, Slate injects a mandatory workflow doctrine: research before design, design review, adversarial review, and track review — with optional umbrella **draft-PR publishing** for tracks.
5
+ The orchestrator (your main pi session) dispatches **bounded actions** to persistent **worker threads**. Each completed action is compressed by an LLM into an **episode** — a durable, structured record (intent, actions, findings, artifacts, open issues, handoff notes) that the orchestrator composes into further dispatches instead of re-reading raw transcripts. On top of that, Slate injects a mandatory workflow doctrine: research before design, design review, adversarial review, and track review — with optional umbrella **draft-PR publishing** for tracks. An opt-in **model-failover** map adds high availability: when a model API fails, the orchestrator, worker threads, and episode compression each retry once on a configured equal-quality alternative.
6
+
7
+ ## Why Slate?
8
+
9
+ Long-horizon agentic work fails on context management, not model capability. Existing architectures each solve a piece of the problem and trade away the rest. Compaction is unpredictably lossy; naive subagents isolate context but hand back only a single response string; markdown plans go stale and get under-executed. Rigid task trees can't adapt to information discovered mid-task, and planner/executor stacks synchronize through compress-and-return boundaries that risk dropping critical state.
10
+
11
+ Slate's answer is the **thread**: the orchestrator dispatches one bounded action at a time; the worker executes it and returns an **episode**. The orchestrator keeps the reactivity of a plain agent loop while gaining the context isolation, compaction, and parallelism that the single-context loop lacks.
12
+
13
+ | Aspect | ReAct | Markdown plan | Task trees | RLM (Recursive Language Models) | Devin / Manus / Altera | Claude Code / Codex subagents | Slate |
14
+ | --- | --- | --- | --- | --- | --- | --- | --- |
15
+ | Planning | implicit | file | explicit tree | REPL | planning agent | plan mode | implicit / adaptive — no upfront static plan |
16
+ | Decomposition | none | none | direct tree | REPL functions | task-based | subagent delegation | implicit |
17
+ | Synchronization | single thread | single thread | gated steps | REPL return | reduce & return | message passing | episodes |
18
+ | Intermediate feedback | per step | per step | on task failure | on execution end | after compress | message passing | per episode |
19
+ | Context isolation | none | none | per subtask | per subcall | subagent | subagent | per thread |
20
+ | Context compaction | none | none | task-based | REPL slicing | subagent compress | compaction | episode compress |
21
+ | Parallel execution | none | none | none | in REPL | Altera only | native | native |
22
+ | Expressivity | high | high | low | high | medium | medium | high |
23
+ | Adaptability | yes | if plan updated | no | limited — no mid-run course correction | yes | limited by message passing | yes |
24
+
25
+ Characterizations of third-party systems reflect their publicly described designs at the time of writing. The taxonomy is adapted from the Random Labs technical report introducing the thread-weaving "Slate" architecture (its agent ships as the npm package `@randomlabs/slate`); ytdb-slate is an independent implementation of that architecture for pi.
26
+
27
+ **Threads are not subagents:**
28
+
29
+ - **Per-episode feedback.** Each thread executes one bounded action and hands control back. The orchestrator adapts after every episode — reactive like a ReAct loop — instead of firing off a subagent and hoping the result comes back usable.
30
+ - **Compaction at a chosen boundary.** Compression happens at a predictable point — action completion — instead of mid-stream when context overflows. The compression itself is still LLM-performed and lossy, but the boundary is chosen, not forced.
31
+ - **Episodes compose.** One thread's episode can seed another thread's context, so conclusions travel across the system by reference. Subagents pass back a single response string; episodes are durable, structured, reusable records.
32
+
33
+ Full rationale: [`docs/design-principles.md`](docs/design-principles.md), shipped in the package — if this summary and that document disagree, the document wins.
34
+
35
+ ## Feature development workflow
36
+
37
+ In orchestrator mode, Slate injects a track-based development workflow as doctrine — it is not optional; project configuration can extend it (`doctrineExtraPath`) but never replace it. The flow:
38
+
39
+ 1. **Research** — interactive exploration; a research log opens lazily when defined triggers fire (non-trivial decisions, surprises, risky invariants, session boundaries).
40
+ 2. **User design review** — the user approves the design direction before any machine review.
41
+ 3. **Adversarial review** — a fresh-context reviewer attacks the design pre-implementation; findings are triaged with the user.
42
+ 4. **Track split approval** — the user approves how the change splits into independently reviewable tracks.
43
+ 5. **Per-track loop** — implement → agent code review → fixes → mandatory user review → marker commit (an empty commit marking the track's boundary in history).
44
+ 6. **Delivery** — with draft-PR publishing enabled, the squash-merge is performed by the user; the agent never merges. With it disabled (the default), the change lands as a squashed commit that carries the workflow log's key content in its message.
45
+
46
+ The workflow scales with change size: multi-track changes get the full flow, single-track changes skip the split and the marker commits, and trivial changes shrink the pre-implementation reviews to consent plus a micro-review (skippable with explicit user consent) — the agent code review and the mandatory user review still apply at every tier. Umbrella draft-PR publishing activates only when `workflow.draftPRs` is `true`.
47
+
48
+ This summary is orientation only; the shipped docs listed below are normative.
6
49
 
7
50
  ## Install
8
51
 
@@ -24,12 +67,13 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
24
67
  | `episodeModel` | string | newest available Anthropic Sonnet, else the worker's own model | Model (`provider/id`) used to compress a finished action into an episode. |
25
68
  | `workerTools` | string[] | `["read", "bash", "edit", "write", "grep", "find", "ls"]` | Tools available to worker threads (an empty list also falls back to the default). |
26
69
  | `maxConcurrent` | number | `4` | Maximum number of worker actions running concurrently (must be ≥ 1 — unenforced: a value of 0 or less silently hangs all dispatches). Excess dispatches wait in a queue; actions on the same thread always run in dispatch order. Default rationale: shipped `docs/design-principles.md` §5 (repo-local note). |
27
- | `pauseThresholdPercent` | number | `40` | Orchestrator context budget (percent, in `(0, 100]`) at which Slate auto-pauses and prepares a fresh-session handoff. |
70
+ | `contextBudget` | number \| object | `256000` (Anthropic models: `400000`) | Absolute orchestrator context budget (tokens) at which Slate auto-pauses and prepares a fresh-session handoff — semantics, defaults, per-model overrides, and rationale in [`docs/context-budget.md`](docs/context-budget.md). |
28
71
  | `orchestratorPromptDocs` | string[] | `[]` | Project markdown files (paths relative to the project root) whose **contents** are appended to the orchestrator system prompt. |
29
72
  | `workerPromptDocs` | string[] | `[]` | Project markdown files whose **contents** are appended to every worker-thread system prompt. |
30
73
  | `workflow.draftPRs` | boolean | `false` | Enable umbrella draft-PR publishing for tracks. |
31
74
  | `doctrineExtraPath` | string | — | Project markdown whose **content** is appended to the orchestrator doctrine (project-specific workflow additions). |
32
75
  | `reviewPerspectivesPath` | string | — | Project review charters, each declaring its own finding-ID prefix. The doctrine references this **path**; the orchestrator reads the file alongside the shipped review rules. |
76
+ | `modelFailover` | object (string → string) | — (empty, failover off) | Map of `provider/id` → equal-quality alternative model; on a model API failure the affected site retries once on the mapped model (worker/orchestrator sites only after pi's own retries are exhausted; episode compression has no pi retry loop). Read once at session start — see [`docs/model-failover.md`](docs/model-failover.md). |
33
77
 
34
78
  Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
35
79
 
@@ -38,12 +82,12 @@ Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point
38
82
  "orchestratorModeDefault": true,
39
83
  "episodeModel": "anthropic/claude-sonnet-5",
40
84
  "maxConcurrent": 4,
41
- "pauseThresholdPercent": 40,
42
85
  "orchestratorPromptDocs": ["docs/agents/orchestrator-guidelines.md"],
43
86
  "workerPromptDocs": ["docs/agents/thread-guidelines.md"],
44
87
  "workflow": { "draftPRs": true },
45
88
  "doctrineExtraPath": "docs/agents/workflow-additions.md",
46
- "reviewPerspectivesPath": "docs/agents/review-perspectives.md"
89
+ "reviewPerspectivesPath": "docs/agents/review-perspectives.md",
90
+ "modelFailover": { "anthropic/claude-sonnet-5": "openai/gpt-5.2" }
47
91
  }
48
92
  ```
49
93
 
@@ -61,6 +105,8 @@ In orchestrator mode, Slate appends a short **doctrine** (a block of numbered ru
61
105
  - `docs/pr-publishing.md` — umbrella draft-PR publishing (cited only when `workflow.draftPRs` is `true`)
62
106
  - `docs/review-rules.md` — review discipline and finding rules
63
107
  - `docs/design-principles.md` — Slate's own design rationale
108
+ - `docs/model-failover.md` — the opt-in `modelFailover` map (**reference documentation** — unlike the entries above it is not workflow doctrine and is not cited by the doctrine)
109
+ - `docs/context-budget.md` — the orchestrator `contextBudget`: defaults, per-model overrides, the window clamp, and the pricing rationale (also **reference documentation**, not cited by the doctrine)
64
110
 
65
111
  Project-specific additions layer on top — they extend, not replace, the shipped doctrine — via two distinct mechanisms:
66
112
 
@@ -0,0 +1,176 @@
1
+ # Orchestrator context budget
2
+
3
+ `contextBudget` in the project's `slate.json` sets the ABSOLUTE
4
+ token count at which Slate pauses the orchestrator and prepares the
5
+ fresh-session handoff. It replaces the deprecated percentage knob.
6
+ This document is reference documentation, not workflow doctrine.
7
+
8
+ ## Why absolute tokens, not a percentage
9
+
10
+ pi's registry context windows differ per model: current Anthropic
11
+ flagships are registered at 1,000,000 tokens; `claude-haiku-4-5`,
12
+ `claude-opus-4-1`, and `claude-opus-4-5` (and their dated aliases)
13
+ at 200,000; direct-OpenAI GPT-5.6 models default to 272,000 as a
14
+ pricing-tier guard (the full 1.05M window is opt-in). One percentage
15
+ cannot express a consistent orchestrator budget across those
16
+ denominators — 40% of a flagship Claude is 400K tokens; 40% of a
17
+ GPT-5.6 is 108.8K. An absolute budget says what it means on every
18
+ model.
19
+
20
+ ## The OpenAI pricing cliff
21
+
22
+ OpenAI long-context pricing is request-wide: a request whose total
23
+ input exceeds 272K tokens bills the ENTIRE request at long-context
24
+ rates. The multipliers are the tier pattern — 2× input, 1.5×
25
+ output, 2× cache read, 2× cache write; the dollar figures are
26
+ `gpt-5.6-sol`'s registered rates (absolute rates vary per model):
27
+ input $5→$10/M, output $30→$45/M, cache read $0.5→$1/M, cache
28
+ write $6.25→$12.5/M. In an agent loop the whole conversation is
29
+ resent every turn, so once the boundary is
30
+ crossed, every subsequent turn stays at those rates. The budget's
31
+ job is to stop the orchestrator before that happens.
32
+
33
+ ## Defaults
34
+
35
+ - **256,000** — the built-in default, ≈16K under the 272K boundary
36
+ so the pause turn and the handoff brief itself stay on
37
+ short-context rates.
38
+ - **`anthropic/.*` → 400,000** — a built-in override for Anthropic
39
+ models, preserving continuity with the previous 40%-of-1M default
40
+ on flagship Claude.
41
+
42
+ ## Configuration (`.pi/slate.json`)
43
+
44
+ `contextBudget` lives in `.pi/slate.json` and, like the rest of
45
+ that file, is honored **only in trusted projects** — untrusted
46
+ projects load no project config, so the built-in defaults apply. It
47
+ accepts a bare positive integer — shorthand for `{"tokens": N}` —
48
+ or an object:
49
+
50
+ ```json
51
+ {
52
+ "contextBudget": {
53
+ "tokens": 256000,
54
+ "overrides": [
55
+ { "match": "openai/gpt-5\\.6-.*", "tokens": 240000 },
56
+ { "match": "anthropic/.*", "tokens": 400000 }
57
+ ]
58
+ }
59
+ }
60
+ ```
61
+
62
+ Each override's `match` is an anchored regex tested against the full
63
+ `provider/id` spec. The FIRST matching override wins, in config
64
+ order. Precedence overall: user override → user scalar (`tokens`) →
65
+ built-in `anthropic/.*` rule → built-in default. Invalid input is
66
+ warned about and never silently changes behavior; what applies
67
+ afterwards depends on where the damage is:
68
+
69
+ - An invalid WHOLE value (bad number, wrong type) is treated as if
70
+ `contextBudget` were unset: legacy percent mode if
71
+ `pauseThresholdPercent` is configured, built-in budget defaults
72
+ otherwise.
73
+ - An invalid `tokens` inside an otherwise valid object is dropped —
74
+ the object still opts into budget mode, so the built-in defaults
75
+ fill the scalar layer.
76
+ - An invalid override entry is skipped — the next layer of the
77
+ precedence chain applies.
78
+
79
+ Legacy note: `pauseThresholdPercent` is deprecated; it keeps its
80
+ exact old semantics only when it is set AND `contextBudget` is
81
+ absent or entirely invalid — an invalid budget sanitizes to absent
82
+ and never disables the percent. A PARTIALLY invalid object (e.g.
83
+ `{"tokens": -1}`) remains a valid budget object: budget mode with
84
+ built-in defaults, percent ignored.
85
+
86
+ ## The clamp
87
+
88
+ The configured budget is clamped against the model's registry
89
+ context window:
90
+
91
+ ```
92
+ effective = min(budget,
93
+ max(contextWindow − compactionReserve − 32768,
94
+ ceil(contextWindow / 2)))
95
+ ```
96
+
97
+ `compactionReserve` comes from pi's compaction settings
98
+ (`compaction.reserveTokens`, default 16,384).
99
+
100
+ | Context window | Effective budget |
101
+ | --- | --- |
102
+ | 200K | 150,848 |
103
+ | 272K | 222,848 |
104
+ | 400K | 256,000 (default binds) |
105
+ | 1M (Anthropic) | 400,000 |
106
+
107
+ Rationale: the clamp guarantees the pause fires before pi's
108
+ auto-compaction and leaves room to write the handoff brief; the
109
+ half-window floor keeps small-window models usable.
110
+
111
+ ## Compaction policy
112
+
113
+ In orchestrator mode, for budget-driven configs only: a
114
+ threshold-triggered compaction that arrives while the session is
115
+ unpaused is cancelled and converted into the Slate pause — the
116
+ handoff, not lossy compaction, handles the full context. Once
117
+ paused, threshold compactions pass through as an escape valve.
118
+ Overflow-recovery compaction and manual `/compact` are never
119
+ touched.
120
+
121
+ ## Using GPT-5.6's full 1.05M window
122
+
123
+ Two deliberate steps. First, raise the registry window in
124
+ `~/.pi/agent/models.json` (pi's custom-models file — NOT
125
+ `settings.json`, where this block is silently ignored):
126
+
127
+ ```json
128
+ {
129
+ "providers": {
130
+ "openai": {
131
+ "modelOverrides": {
132
+ "gpt-5.6-sol": { "contextWindow": 1050000 }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ (repeat for `gpt-5.6-terra` / `gpt-5.6-luna`). Second, raise the
140
+ Slate budget with an override in `.pi/slate.json` — the full nested
141
+ form matters: a bare `{"match": …, "tokens": …}` pasted as the
142
+ `contextBudget` value becomes a global scalar with `match` silently
143
+ dropped:
144
+
145
+ ```json
146
+ {
147
+ "contextBudget": {
148
+ "overrides": [
149
+ { "match": "openai/gpt-5\\.6-.*", "tokens": 900000 }
150
+ ]
151
+ }
152
+ }
153
+ ```
154
+
155
+ WARNING: beyond 272K total input every request bills at the
156
+ long-context rates listed above.
157
+
158
+ ## Accepted limitations
159
+
160
+ - On models with a 272K pricing tier and a larger registry window
161
+ (`gpt-5.4-pro`, `gpt-5.5-pro`, GPT-5.6 after the opt-in above), a
162
+ >16K single-turn overshoot near the 256K default can bill the
163
+ final turns at long-context rates — override to 240,000 to widen
164
+ the margin.
165
+ - The 32K margin in the clamp covers typical turns, not extreme
166
+ single-turn ingestion (several parallel large file reads) — hand
167
+ off earlier or set a lower override when running tier-priced
168
+ models hard.
169
+ - The pause-fires-before-compaction guarantee holds for single-turn
170
+ ingestion under 32,768 tokens — and only where the margin branch
171
+ of the clamp binds (context window ≥ ~98K with the default
172
+ 16,384-token reserve); on smaller, floor-clamped windows the
173
+ pre-compaction margin is narrower. Beyond the margin a pass-through
174
+ compaction can land in the same cycle and the handoff brief is
175
+ written from compacted context — episodes and thread state survive
176
+ on disk, so handoff still functions.
@@ -0,0 +1,153 @@
1
+ # Model failover
2
+
3
+ Opt-in high availability for model outages. `modelFailover` in the
4
+ project's `slate.json` maps a model to an equal-quality alternative;
5
+ when a Slate action fails because of a model API failure, the
6
+ affected site retries ONCE on the mapped model, at code level,
7
+ without user intervention (in worker and orchestrator sessions only
8
+ after pi's own retries are exhausted — see below). The map is empty
9
+ by default: with no entries the feature is off and failures surface
10
+ exactly as before. This document is reference documentation, not
11
+ workflow doctrine.
12
+
13
+ ## Configuration
14
+
15
+ `modelFailover` lives in `.pi/slate.json` and, like the rest of that
16
+ file, is honored **only in trusted projects**. Both sides of every
17
+ entry are full model specs in `provider/id` form:
18
+
19
+ ```json
20
+ {
21
+ "modelFailover": {
22
+ "anthropic/claude-sonnet-5": "openai/gpt-5.2",
23
+ "openai/gpt-5.2": "anthropic/claude-sonnet-5",
24
+ "anthropic/claude-haiku-4": "google/gemini-3-flash"
25
+ }
26
+ }
27
+ ```
28
+
29
+ Validation rules:
30
+
31
+ - Key and value must both have the `provider/id` shape.
32
+ - Key and value must differ (mapping a model to itself is invalid).
33
+ - Invalid entries are dropped with a warning; valid entries in the
34
+ same map still apply.
35
+
36
+ Mutual entries (A→B and B→A, as above) are fine — see the single-hop
37
+ rule below.
38
+
39
+ The map is read ONCE, at session start. Editing `slate.json`
40
+ mid-session — e.g. adding a mapping in the middle of an outage —
41
+ takes effect only in a NEW pi session.
42
+
43
+ Configuring the map is the whole opt-in: in a trusted project with a
44
+ `modelFailover` entry, ORCHESTRATOR failover is active in every pi
45
+ session regardless of whether Slate's orchestrator mode is on — a
46
+ matching model failure switches the session's model (and pi's global
47
+ default, see the caveats) even if you never use threads.
48
+
49
+ ## When failover fires
50
+
51
+ A failure is eligible for failover when it is either:
52
+
53
+ - an error stop reason that is NOT a context overflow, or
54
+ - a preflight failure: a worker prompt that throws is confirmed by
55
+ re-running the auth check on the current model (a transient throw
56
+ with healthy credentials does not switch models); the episode
57
+ compressor treats any thrown attempt as eligible.
58
+
59
+ pi's own retry settings (`retry.enabled` / `retry.maxRetries`, with
60
+ exponential backoff) run first inside worker and orchestrator
61
+ sessions — there, failover fires only after pi gives up on the
62
+ original model. Quota and billing errors are non-retryable in pi, so
63
+ those fail over promptly. The episode compressor is a single direct
64
+ API call with no retry loop of its own, so its mapped retry fires on
65
+ the first failure.
66
+
67
+ What NEVER triggers failover:
68
+
69
+ - **Aborts** — a user abort is not a model failure.
70
+ - **Context overflow** — a different model does not fix an oversized
71
+ prompt; the existing overflow handling applies unchanged.
72
+ - **Worker task-level failures** — the model answered but the action's
73
+ outcome was bad; those surface as failed episodes, as before.
74
+
75
+ Before any switch, the mapped model is resolved against pi's model
76
+ registry and auth-checked. If it is unknown or unauthenticated, no
77
+ switch happens and the original failure stands. The map is applied a
78
+ single hop per failure — it is never chained, so cycles (A→B, B→A)
79
+ cannot loop.
80
+
81
+ ## Per-site behavior
82
+
83
+ Failover applies at three sites:
84
+
85
+ - **Worker threads.** The live worker session switches to the mapped
86
+ model and the failed action is re-prompted once with a continuation
87
+ nudge. The transcript is preserved; usage and cost accumulate across
88
+ both attempts. The thread stays on the mapped model while its
89
+ session is live and returns to its configured model when the session
90
+ is reopened (e.g. after a pi restart). The `threads` listing marks a
91
+ switched live session as `model=A ⇒B (live)` — the marker means a
92
+ live failover switch happened (it is not a value comparison) and
93
+ disappears when the session is reopened.
94
+ - **Episode compression.** The compression call is retried once with
95
+ the mapped compressor model — only if it is distinct from the
96
+ original, resolvable, and authed. If the retry also fails, the
97
+ uncompressed fallback applies exactly as today. Compression cost
98
+ accumulates across both attempts — a failed first attempt may still
99
+ bill.
100
+ - **Orchestrator.** Once pi's retries are exhausted, Slate switches
101
+ the session to the mapped model and automatically re-issues the
102
+ failed turn. A one-shot guard prevents loops; it re-arms only when a
103
+ run settles without a model error (an abort also re-arms it) or on a
104
+ genuine new user prompt. The session STAYS on the mapped model
105
+ afterwards. Failover runs even while Slate is paused for handoff, so
106
+ the handoff brief is written by a working model.
107
+
108
+ ## What you'll see when failover fires
109
+
110
+ - **Orchestrator:** a `slate: model failover <old> ⇒ <new> — retrying
111
+ the failed turn` warning notification, then a visible `[slate]`
112
+ steer message in the conversation instructing the model to re-issue
113
+ the failed action. A skipped or failed switch (no API key, auth
114
+ throw) also notifies, and the original failure stands.
115
+ - **Worker threads:** a `⚠ failover <old> ⇒ <new>` line in the thread
116
+ tool's progress output; if the retry also fails, the episode's
117
+ failure diagnostics carry a `(failover to <new> also failed)`
118
+ suffix. The `threads` listing shows the live-model marker while the
119
+ switched session is live.
120
+ - **Episode compression:** no live signal — the episode header's
121
+ `compressor:` line names whichever model actually produced the
122
+ episode.
123
+
124
+ ## Caveats & accepted limitations
125
+
126
+ - Orchestrator failover persists the mapped model as the user's
127
+ **global default model** — that is pi's `setModel` semantics. Switch
128
+ back with `/model` once the outage is over.
129
+ - Worker sessions use a read-only settings view, so worker failover
130
+ persists nothing.
131
+ - Orchestrator **preflight** auth failures (e.g. an OAuth token that
132
+ expired before any run starts) bypass failover — pi surfaces the
133
+ error itself; recover manually with `/model`.
134
+ - A worker retry holds its concurrency slot across both attempts.
135
+ - Stickiness is per-thread only: each NEW thread still starts on its
136
+ configured/default model during an outage.
137
+ - Cancelling a run while pi is sleeping between its internal retry
138
+ attempts is not reliably distinguishable from pi exhausting those
139
+ retries — pi emits no extension-visible signal for that cancel.
140
+ Slate mitigates by counting consecutive retryable errors against
141
+ pi's retry settings, which blocks failover in the normal cancel
142
+ case; a narrow residual window remains (e.g. retry settings changed
143
+ mid-session) where a cancelled run could still fail over.
144
+
145
+ ## Troubleshooting
146
+
147
+ Failover looked eligible but was skipped: check the mapped model. It
148
+ must be a well-formed `provider/id` spec, present in pi's model
149
+ registry, and pass its auth check at failure time — otherwise the
150
+ original failure stands (malformed entries are additionally dropped
151
+ with a warning when the config is read). If you edited the map
152
+ mid-session, remember it is read only at session start — start a new
153
+ pi session for the change to apply.
@@ -19,6 +19,9 @@ import {
19
19
  serializeConversation,
20
20
  type ExtensionContext,
21
21
  } from "@earendil-works/pi-coding-agent";
22
+ import { isFailoverCandidate, resolveMappedModel } from "./failover.ts";
23
+
24
+ type CompressorModel = NonNullable<ReturnType<ExtensionContext["modelRegistry"]["find"]>>;
22
25
 
23
26
  const MAX_TRANSCRIPT_CHARS = 300_000;
24
27
  const COMPRESSOR_MAX_TOKENS = 4096;
@@ -88,6 +91,7 @@ export interface CompressEpisodeOptions {
88
91
  messages: unknown[]; // AgentMessages produced during this action
89
92
  workerModel?: { provider: string; id: string };
90
93
  configuredModel?: string;
94
+ modelFailover?: Record<string, string>; // "provider/id" → "provider/id" retry map (single hop, one retry)
91
95
  signal?: AbortSignal;
92
96
  }
93
97
 
@@ -98,6 +102,71 @@ export interface CompressedEpisode {
98
102
  costUsd: number; // USD cost of the compression LLM call (0 on fallback)
99
103
  }
100
104
 
105
+ type CompressionAttempt =
106
+ | { kind: "ok"; body: string; costUsd: number }
107
+ | { kind: "failed"; costUsd: number; retriable: boolean }
108
+ | { kind: "aborted"; costUsd: number };
109
+
110
+ /**
111
+ * ONE compression attempt on ONE model. Failure classification (AF7/AF11):
112
+ * - stopReason "error" → failed; treated as failed even when partial text came
113
+ * back (a half-written episode is worse than the uncompressed fallback);
114
+ * retriable unless it is a context overflow — a mapped model cannot shrink
115
+ * the prompt (isFailoverCandidate).
116
+ * - stopReason "aborted" (or a throw with the signal already aborted) →
117
+ * aborted; NEVER retried — cancellation must not spend a mapped attempt.
118
+ * - missing auth or a thrown preflight error → failed & retriable (state-based
119
+ * classification, AF10: the mapped model may be authed when this one isn't).
120
+ * - empty text on a clean stop → failed, not retriable (nothing suggests the
121
+ * mapped model would answer differently).
122
+ */
123
+ async function attemptCompression(
124
+ ctx: ExtensionContext,
125
+ model: CompressorModel,
126
+ promptText: string,
127
+ signal: AbortSignal | undefined,
128
+ ): Promise<CompressionAttempt> {
129
+ let costUsd = 0;
130
+ try {
131
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
132
+ if (!auth.ok || !auth.apiKey) return { kind: "failed", costUsd, retriable: true };
133
+ const response = await complete(
134
+ model,
135
+ {
136
+ messages: [
137
+ {
138
+ role: "user" as const,
139
+ content: [{ type: "text" as const, text: promptText }],
140
+ timestamp: Date.now(),
141
+ },
142
+ ],
143
+ },
144
+ {
145
+ apiKey: auth.apiKey,
146
+ headers: auth.headers,
147
+ env: auth.env,
148
+ maxTokens: COMPRESSOR_MAX_TOKENS,
149
+ signal,
150
+ },
151
+ );
152
+ costUsd = response.usage?.cost?.total ?? 0;
153
+ if (response.stopReason === "aborted") return { kind: "aborted", costUsd };
154
+ if (response.stopReason === "error") {
155
+ return { kind: "failed", costUsd, retriable: isFailoverCandidate(response, model.contextWindow) };
156
+ }
157
+ const text = response.content
158
+ .filter((c: { type: string }): c is { type: "text"; text: string } => c.type === "text")
159
+ .map((c: { text: string }) => c.text)
160
+ .join("\n")
161
+ .trim();
162
+ if (!text) return { kind: "failed", costUsd, retriable: false };
163
+ return { kind: "ok", body: text, costUsd };
164
+ } catch {
165
+ if (signal?.aborted) return { kind: "aborted", costUsd };
166
+ return { kind: "failed", costUsd, retriable: true };
167
+ }
168
+ }
169
+
101
170
  function lastAssistantText(messages: unknown[]): string {
102
171
  for (let i = messages.length - 1; i >= 0; i--) {
103
172
  const m = messages[i] as { role?: string; content?: Array<{ type: string; text?: string }> };
@@ -126,43 +195,44 @@ export async function compressEpisode(opts: CompressEpisodeOptions): Promise<Com
126
195
  try {
127
196
  const model = await resolveCompressorModel(ctx, opts.configuredModel, opts.workerModel);
128
197
  if (model) {
129
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
130
- if (auth.ok && auth.apiKey) {
131
- let transcript = serializeConversation(convertToLlm(opts.messages as never));
132
- if (transcript.length > MAX_TRANSCRIPT_CHARS) {
133
- transcript = `[transcript head truncated]\n...${transcript.slice(-MAX_TRANSCRIPT_CHARS)}`;
134
- }
135
- if (opts.diagnostics) {
136
- transcript += `\n\n[dispatch diagnostics: ${opts.diagnostics}]`;
137
- }
138
- const response = await complete(
139
- model,
140
- {
141
- messages: [
142
- {
143
- role: "user" as const,
144
- content: [{ type: "text" as const, text: compressorPrompt(opts.task, transcript) }],
145
- timestamp: Date.now(),
146
- },
147
- ],
148
- },
149
- {
150
- apiKey: auth.apiKey,
151
- headers: auth.headers,
152
- env: auth.env,
153
- maxTokens: COMPRESSOR_MAX_TOKENS,
154
- signal: opts.signal,
155
- },
156
- );
157
- costUsd = response.usage?.cost?.total ?? 0;
158
- const text = response.content
159
- .filter((c: { type: string }): c is { type: "text"; text: string } => c.type === "text")
160
- .map((c: { text: string }) => c.text)
161
- .join("\n")
162
- .trim();
163
- if (text) {
164
- body = text;
165
- compressor = `${model.provider}/${model.id}`;
198
+ let transcript = serializeConversation(convertToLlm(opts.messages as never));
199
+ if (transcript.length > MAX_TRANSCRIPT_CHARS) {
200
+ transcript = `[transcript head truncated]\n...${transcript.slice(-MAX_TRANSCRIPT_CHARS)}`;
201
+ }
202
+ if (opts.diagnostics) {
203
+ transcript += `\n\n[dispatch diagnostics: ${opts.diagnostics}]`;
204
+ }
205
+ const promptText = compressorPrompt(opts.task, transcript);
206
+
207
+ const first = await attemptCompression(ctx, model, promptText, opts.signal);
208
+ // Cost ACCUMULATES across attempts — a failed first attempt may still bill.
209
+ costUsd += first.costUsd;
210
+ if (first.kind === "ok") {
211
+ body = first.body;
212
+ compressor = `${model.provider}/${model.id}`;
213
+ } else if (first.kind === "failed" && first.retriable && !opts.signal?.aborted) {
214
+ // Model failover — single hop, at most ONCE: retry iff the mapped
215
+ // model resolves, is authed, and differs from the attempt-1 model.
216
+ const mapped = await resolveMappedModel(ctx, opts.modelFailover ?? {}, model.provider, model.id);
217
+ // CQ4: resolveMappedModel keeps the looser auth.ok semantics shared
218
+ // with the setModel-based sites, but attemptCompression additionally
219
+ // requires an explicit apiKey — pre-check it so an env/header-auth
220
+ // mapped model does not buy a guaranteed-futile retry.
221
+ const mappedAuth = mapped ? await ctx.modelRegistry.getApiKeyAndHeaders(mapped) : undefined;
222
+ if (
223
+ mapped &&
224
+ mappedAuth?.ok &&
225
+ mappedAuth.apiKey &&
226
+ (mapped.provider !== model.provider || mapped.id !== model.id)
227
+ ) {
228
+ const second = await attemptCompression(ctx, mapped, promptText, opts.signal);
229
+ costUsd += second.costUsd;
230
+ if (second.kind === "ok") {
231
+ body = second.body;
232
+ // The header's compressor line shows whichever model actually
233
+ // produced the episode — that is where failover stays visible.
234
+ compressor = `${mapped.provider}/${mapped.id}`;
235
+ }
166
236
  }
167
237
  }
168
238
  }