ytdb-slate 0.1.2 → 0.2.1
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 +7 -4
- package/docs/context-budget.md +176 -0
- package/docs/model-failover.md +153 -0
- package/extension/episodes.ts +107 -37
- package/extension/failover.ts +287 -0
- package/extension/handoff.ts +367 -31
- package/extension/index.ts +27 -4
- package/extension/state.ts +14 -1
- package/extension/threads.ts +155 -9
- package/extension/tools.ts +13 -1
- package/extension/worker.ts +40 -3
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
6
|
|
|
7
7
|
## Why Slate?
|
|
8
8
|
|
|
@@ -67,12 +67,13 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
|
|
|
67
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. |
|
|
68
68
|
| `workerTools` | string[] | `["read", "bash", "edit", "write", "grep", "find", "ls"]` | Tools available to worker threads (an empty list also falls back to the default). |
|
|
69
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). |
|
|
70
|
-
| `
|
|
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). |
|
|
71
71
|
| `orchestratorPromptDocs` | string[] | `[]` | Project markdown files (paths relative to the project root) whose **contents** are appended to the orchestrator system prompt. |
|
|
72
72
|
| `workerPromptDocs` | string[] | `[]` | Project markdown files whose **contents** are appended to every worker-thread system prompt. |
|
|
73
73
|
| `workflow.draftPRs` | boolean | `false` | Enable umbrella draft-PR publishing for tracks. |
|
|
74
74
|
| `doctrineExtraPath` | string | — | Project markdown whose **content** is appended to the orchestrator doctrine (project-specific workflow additions). |
|
|
75
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). |
|
|
76
77
|
|
|
77
78
|
Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
|
|
78
79
|
|
|
@@ -81,12 +82,12 @@ Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point
|
|
|
81
82
|
"orchestratorModeDefault": true,
|
|
82
83
|
"episodeModel": "anthropic/claude-sonnet-5",
|
|
83
84
|
"maxConcurrent": 4,
|
|
84
|
-
"pauseThresholdPercent": 40,
|
|
85
85
|
"orchestratorPromptDocs": ["docs/agents/orchestrator-guidelines.md"],
|
|
86
86
|
"workerPromptDocs": ["docs/agents/thread-guidelines.md"],
|
|
87
87
|
"workflow": { "draftPRs": true },
|
|
88
88
|
"doctrineExtraPath": "docs/agents/workflow-additions.md",
|
|
89
|
-
"reviewPerspectivesPath": "docs/agents/review-perspectives.md"
|
|
89
|
+
"reviewPerspectivesPath": "docs/agents/review-perspectives.md",
|
|
90
|
+
"modelFailover": { "anthropic/claude-sonnet-5": "openai/gpt-5.2" }
|
|
90
91
|
}
|
|
91
92
|
```
|
|
92
93
|
|
|
@@ -104,6 +105,8 @@ In orchestrator mode, Slate appends a short **doctrine** (a block of numbered ru
|
|
|
104
105
|
- `docs/pr-publishing.md` — umbrella draft-PR publishing (cited only when `workflow.draftPRs` is `true`)
|
|
105
106
|
- `docs/review-rules.md` — review discipline and finding rules
|
|
106
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)
|
|
107
110
|
|
|
108
111
|
Project-specific additions layer on top — they extend, not replace, the shipped doctrine — via two distinct mechanisms:
|
|
109
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.
|
package/extension/episodes.ts
CHANGED
|
@@ -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
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
}
|