ytdb-slate 0.6.1 → 0.7.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 +11 -2
- package/docs/model-routing.md +126 -73
- package/extension/index.ts +62 -6
- package/extension/model-router.ts +255 -55
- package/extension/notify.ts +11 -7
- package/extension/state.ts +11 -3
- package/extension/threads.ts +24 -6
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ytdb-slate
|
|
2
2
|
|
|
3
|
-
[](https://github.com/JetBrains/ytdb-slate/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
5
|
Slate is a thread-weaving orchestration extension for the [pi coding agent](https://pi.dev).
|
|
6
6
|
|
|
@@ -57,6 +57,14 @@ Slate can ride through model API outages. The opt-in `modelFailover` map in `.pi
|
|
|
57
57
|
|
|
58
58
|
Slate can pick the model and the effort level per dispatched action rather than running every action on whatever model the orchestrator happens to be on. `router.models` in `.pi/slate.json` (trusted projects only) is a closed list of `provider/id` specs. Each action uses its `model` and `effort` arguments when present. Otherwise, it uses the thread's base model and a level DERIVED for the model it routes to. That level is the lowest one with a capability measurement, never a fixed default. The list is empty by default. An empty list disables candidate-list enforcement, base seeding, effort derivation, and the router's window, billing, and substitution paths. Per-action `model` and `effort` arguments stay active on both router states. Slate always checks an explicit effort against pi's vocabulary, and against the ladder of the model it routes to whenever it has that model's profile data. An action that omits `model` plans for the thread's pre-router pin, and a thread with no pin gives it no plan target. An action that omits `effort` resolves no level. In both cases the worker session returns to what it opened on. [`docs/model-routing.md`](docs/model-routing.md) holds the full contract, including the known cases where the model or level that runs differs. Only models Slate ships a benchmark profile for can be routed to; entries pi cannot serve, or has no profile for, are dropped with a warning naming them.
|
|
59
59
|
|
|
60
|
+
Router startup warnings have two classes. One class is a configuration fault. It reports project configuration that Slate ignored or dropped. A configuration fault also covers problems the user can stop by adding a model, credential, or failover entry.
|
|
61
|
+
|
|
62
|
+
The addition must go into their project configuration or pi credentials. Removing the model named by a warning does not meet this second test. Slate always shows configuration faults.
|
|
63
|
+
|
|
64
|
+
The other class is a model data note. It reports shipped research data that project configuration cannot correct. Slate hides those notes by default.
|
|
65
|
+
|
|
66
|
+
With this repository's six-model list and pi's stock registry, Slate hides eleven notes. Slate shows one discoverability line instead. A local registry override can change that count. The line names the hidden warning count and the `router.showWarnings` setting. A hidden note can still affect which model runs an action.
|
|
67
|
+
|
|
60
68
|
What the code enforces is deliberately narrower than what the routing data advises. The dispatch guards refuse an unlisted model, a level off the target model's ladder and a level the provider rejects outright; they warn about — and mark — an unmeasured level, refusing it only when `router.allowUnmeasuredEffort` is `false`; and the context-window guard substitutes a wider listed model instead of ever blocking an action. The obligations that come from the profile data itself — keeping review and gate actions on measured levels, and honoring a REFUSE such as `anthropic/claude-fable-5`'s for zero-data-retention work — are stated in the doctrine for the orchestrator to honor; they are **not** code-enforced guards. Full semantics: the `router.*` rows in [Configuration](#configuration) and the shipped [`docs/model-routing.md`](docs/model-routing.md) — if they disagree, that document wins.
|
|
61
69
|
|
|
62
70
|
## Optional writing guidance
|
|
@@ -105,6 +113,7 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
|
|
|
105
113
|
| `preserveGlobalModelDefault` | boolean | `true` | Put pi's global model defaults (`defaultProvider`, `defaultModel`, `defaultThinkingLevel`) back after a Slate-initiated model switch — orchestrator failover and handoff adoption — so the switch stays session-scoped. `false` turns that restore off at both sites (the switches themselves are unaffected), leaving each one persisted in pi's global settings. Best-effort: Slate reverts only a value its own switch produced, and can stand down or give up — limits in [`docs/model-failover.md`](docs/model-failover.md). |
|
|
106
114
|
| `router.models` | string[] | `[]` (empty — routing off) | Closed list of models (`provider/id`) an action may use. Empty or absent means the router is OFF. Slate enforces no candidate list, seeds no thread base, derives no effort level, and runs no router-only window, billing, or substitution path. Per-action `model` and `effort` arguments stay active on both router states. Slate still checks explicit effort against pi's vocabulary and the routed model's ladder when it has that model's profile data. An action that omits `model` plans for the thread's pre-router pin, and a thread with no pin gives it no plan target. An action that omits `effort` resolves no level. In both cases the worker session returns to what it opened on. The known cases where the model or level that runs differs are listed in [`docs/model-routing.md`](docs/model-routing.md). A listed model becomes routable only if Slate has its benchmark profile, pi registers it, and credentials are configured. Slate drops other entries with a warning. If all entries drop, routing turns off with one summary warning. Slate reads and resolves the list once per session. See [`docs/model-routing.md`](docs/model-routing.md). |
|
|
107
115
|
| `router.allowUnmeasuredEffort` | boolean | `true` | What happens when an explicitly requested effort level is on the target model's ladder but has no capability measurement in Slate's profiles: `true` dispatches it with a ⚠ notice and marks the episode `(unmeasured level)`; `false` refuses the dispatch. Either way a level that is off the model's ladder, or one the provider rejects outright, is refused, and a level Slate DERIVES for an action is always a measured one. |
|
|
116
|
+
| `router.showWarnings` | boolean | `false` | Reveal model data notes about Slate's shipped routing research. Configuration faults are never hidden. See [`docs/model-routing.md`](docs/model-routing.md). |
|
|
108
117
|
|
|
109
118
|
Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
|
|
110
119
|
|
|
@@ -168,7 +177,7 @@ In orchestrator mode, Slate appends a short **doctrine** (a block of numbered ru
|
|
|
168
177
|
- `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)
|
|
169
178
|
- `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)
|
|
170
179
|
- `docs/writing-guidance.md` — the optional writing convention, `writing.check` behavior, status line, and checker CLI
|
|
171
|
-
- `docs/model-routing.md` — action-level model routing
|
|
180
|
+
- `docs/model-routing.md` — the action-level model routing reference. It covers the three `router` keys, model eligibility, omitted effort, and dispatch guards. It also covers first-session warnings. The doctrine cites this absolute path only while the routing rule renders. This path is fourth normally and fifth when `workflow.draftPRs` is on. The rule renders the live list because it depends on the session registry and credentials.
|
|
172
181
|
|
|
173
182
|
Project-specific additions layer on top — they extend, not replace, the shipped doctrine — via two distinct mechanisms:
|
|
174
183
|
|
package/docs/model-routing.md
CHANGED
|
@@ -199,7 +199,7 @@ opened on. M7 and E6 are known cases in which it does not.
|
|
|
199
199
|
|
|
200
200
|
`router` lives in `.pi/slate.json` and, like the rest of that file,
|
|
201
201
|
is honored **only in trusted projects** — an untrusted project loads
|
|
202
|
-
no project config, so the router stays off. There are exactly
|
|
202
|
+
no project config, so the router stays off. There are exactly three
|
|
203
203
|
keys:
|
|
204
204
|
|
|
205
205
|
```json
|
|
@@ -213,7 +213,8 @@ keys:
|
|
|
213
213
|
"anthropic/claude-opus-5",
|
|
214
214
|
"anthropic/claude-fable-5"
|
|
215
215
|
],
|
|
216
|
-
"allowUnmeasuredEffort": true
|
|
216
|
+
"allowUnmeasuredEffort": true,
|
|
217
|
+
"showWarnings": false
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
```
|
|
@@ -222,6 +223,7 @@ keys:
|
|
|
222
223
|
| --- | --- | --- | --- |
|
|
223
224
|
| `models` | array of `"provider/id"` strings | `[]` | the closed candidate list; empty or absent = router OFF |
|
|
224
225
|
| `allowUnmeasuredEffort` | boolean | `true` | `false` turns an evidence gap from a warning into a refusal |
|
|
226
|
+
| `showWarnings` | boolean | `false` | `true` shows model data notes in addition to the always-visible configuration faults |
|
|
225
227
|
|
|
226
228
|
Validation, all of it at session start so a broken list surfaces
|
|
227
229
|
before a dispatch is refused by it:
|
|
@@ -237,6 +239,9 @@ before a dispatch is refused by it:
|
|
|
237
239
|
entries in the same list still apply.
|
|
238
240
|
- A non-boolean `allowUnmeasuredEffort` is warned about and treated
|
|
239
241
|
as `true`.
|
|
242
|
+
- A non-boolean `showWarnings` is warned about as a configuration
|
|
243
|
+
fault and treated as `false`. The fault remains visible despite
|
|
244
|
+
that fallback.
|
|
240
245
|
|
|
241
246
|
The config is read once, at session start. Editing `slate.json`
|
|
242
247
|
mid-session takes effect only in a new pi session.
|
|
@@ -249,24 +254,54 @@ the credentials pi actually has configured. Resolution happens once
|
|
|
249
254
|
per session, lazily at the first consultation, and the answer is
|
|
250
255
|
then frozen for that session.
|
|
251
256
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
257
|
+
The class test has two independent conditions. A warning is a
|
|
258
|
+
**configuration fault** when EITHER condition holds:
|
|
259
|
+
|
|
260
|
+
1. Slate ignored or dropped part of the user's configuration.
|
|
261
|
+
2. The user can stop the warning by ADDING something to their own
|
|
262
|
+
project config or pi credentials, such as a model, credential or
|
|
263
|
+
failover entry. This differs from only removing the model named
|
|
264
|
+
by the warning.
|
|
265
|
+
|
|
266
|
+
Every other warning is a **model data note**. The single-part test
|
|
267
|
+
that asks only whether a user can stop a warning does not partition
|
|
268
|
+
the classes: removing a dropped `router.models` entry can silence its
|
|
269
|
+
warning, but removal is not the ADD remedy in condition 2. Condition
|
|
270
|
+
1 catches every silently ignored or dropped config value first.
|
|
271
|
+
|
|
272
|
+
| what happens | outcome | warning fragment | class |
|
|
273
|
+
| --- | --- | --- | --- |
|
|
274
|
+
| entry is not a canonical `provider/id` | dropped | "It is not a canonical \"provider/id\" model spec. Reason:" | configuration fault |
|
|
275
|
+
| no profile in Slate's model profile table | dropped | "has no entry in slate's model profile table" | configuration fault |
|
|
276
|
+
| two entries resolve to the same profile (an alias or a case variant) | first kept, second dropped | "name the same profiled model" | configuration fault |
|
|
277
|
+
| the same spec is listed twice | first kept | none (silent) | — |
|
|
278
|
+
| pi's model registry does not know it | dropped | "is not in pi's model registry. Slate drops it from routing." | configuration fault |
|
|
279
|
+
| pi has no usable credentials configured for it | dropped | "has no usable credentials configured in pi. Slate drops it from routing." | configuration fault |
|
|
280
|
+
| every entry is dropped | router turns OFF | "survived validation. The warnings above name each dropped entry" | configuration fault |
|
|
281
|
+
| no usable input price exists for today's date | kept, ordered last | "Slate cannot compare its cost with the other models." | model data note |
|
|
282
|
+
| no usable effort ladder exists in the profile | kept | "Such a level passes through to pi, which clamps it" | model data note |
|
|
283
|
+
| profile context window differs from the registry window | kept, registry value used | "differs between two sources" | model data note |
|
|
284
|
+
| the first profile names unknown routing-critical fields | kept | "picks models from a research table shipped inside slate" | model data note |
|
|
285
|
+
| a profile names unknown routing-critical fields | kept | "model fact that slate could not trace to a source" or "model facts that slate could not trace to a source" | model data note |
|
|
286
|
+
| a reportable profile/registry context-window divergence exists, the registry figure is not the profile's recorded known-divergence figure, and the registry figure equals the model's own long-context billing threshold | kept | "context window equal to the model's own long-context billing threshold" | model data note |
|
|
287
|
+
| no `modelFailover` entry exists for a candidate | kept | "routable models have no modelFailover entry" | configuration fault |
|
|
288
|
+
| every configured candidate is marked non-preferred | cheapest candidate becomes the base | "profiles mark every configured model as one it must never pick by itself" | configuration fault |
|
|
289
|
+
| resolution throws | router turns OFF | "routing is disabled. The router could not resolve its model list" | configuration fault |
|
|
290
|
+
|
|
291
|
+
Every warning is emitted at most once per session. The complete
|
|
292
|
+
resolution retains every warning regardless of display filtering.
|
|
293
|
+
With `router.showWarnings: true`, every warning reaches the same
|
|
294
|
+
channel as the other config warnings: a UI notification when a UI is
|
|
295
|
+
attached, the console otherwise. With the default `false`, every
|
|
296
|
+
configuration fault still reaches that channel and model data notes
|
|
297
|
+
do not.
|
|
298
|
+
|
|
299
|
+
When at least one note is hidden, Slate emits one discoverability
|
|
300
|
+
line after resolution. It gives the hidden warning count, names
|
|
301
|
+
`router.showWarnings`, and says that a hidden warning can affect
|
|
302
|
+
which model runs an action. The count is warnings, not physical
|
|
303
|
+
display lines: one warning can wrap across several lines. No
|
|
304
|
+
discoverability line appears when nothing was hidden.
|
|
270
305
|
|
|
271
306
|
Half a list is still a routing policy, so partial drops leave the
|
|
272
307
|
router ON. Nothing surviving is not a policy: the router turns OFF
|
|
@@ -463,60 +498,78 @@ model's ladder is unreadable.
|
|
|
463
498
|
|
|
464
499
|
## Expected first-session warnings
|
|
465
500
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
501
|
+
The stock count below is a render over this repository's
|
|
502
|
+
`.pi/slate.json`, the shipped profile table, and the provider data
|
|
503
|
+
inside the pinned pi 0.83.0 package. It does not read the live model
|
|
504
|
+
registry. The packaged OpenAI data at
|
|
505
|
+
`@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/openai.json`
|
|
506
|
+
reports a **272,000-token** context window for
|
|
507
|
+
`openai/gpt-5.6-luna`, `openai/gpt-5.6-terra` and
|
|
508
|
+
`openai/gpt-5.6-sol`. The packaged Anthropic data reports 1,000,000
|
|
509
|
+
for each configured Anthropic model. The render treated all six as
|
|
510
|
+
authenticated, matching this repository's configured-auth state.
|
|
511
|
+
|
|
512
|
+
The shipped profiles record 1,050,000 tokens for those three OpenAI
|
|
513
|
+
models. Each stock registry value therefore produces a context-window
|
|
514
|
+
divergence note. The same 272,000-token value equals each model's
|
|
515
|
+
long-context billing threshold, so one aggregate billing-pattern note
|
|
516
|
+
also fires.
|
|
517
|
+
|
|
518
|
+
Resolution emits **11 warnings: 0 configuration faults and 11 model
|
|
519
|
+
data notes**. The default `router.showWarnings: false` therefore
|
|
520
|
+
shows **0 of those warnings** and one discoverability line. That line
|
|
521
|
+
begins `slate: there are 11 hidden warnings in the model router.`
|
|
522
|
+
Enabling the option shows all 11 warnings and no discoverability
|
|
523
|
+
line. Each warning fires at most once per session, and the resolution
|
|
524
|
+
is frozen after the first consultation.
|
|
525
|
+
|
|
526
|
+
| warnings | class | condition keys | why | shown by default |
|
|
527
|
+
| --- | --- | --- | --- | --- |
|
|
528
|
+
| 1 | model data note | `w3-explainer` | explains the shipped research table before the first unknown-data warning | 0 |
|
|
529
|
+
| 6 | model data note | one `w3:string:<JSON spec>` for each configured model | each profile names model facts with no traced source, or conflicting figures with no adjudication | 0 |
|
|
530
|
+
| 3 | model data note | one `w1:string:<JSON spec>` for each configured OpenAI model | each stock registry window differs from its profile window | 0 |
|
|
531
|
+
| 1 | model data note | `w1-billing-pattern` | aggregates the three registry windows that equal their models' billing thresholds | 0 |
|
|
532
|
+
| **11** | **all model data notes** | — | stock measured total | **0 warnings; 1 discoverability line** |
|
|
533
|
+
|
|
534
|
+
The stock emission order is:
|
|
535
|
+
|
|
536
|
+
1. `w1:string:"openai/gpt-5.6-luna"`
|
|
537
|
+
2. `w3-explainer`
|
|
538
|
+
3. `w3:string:"openai/gpt-5.6-luna"`
|
|
539
|
+
4. `w1:string:"openai/gpt-5.6-terra"`
|
|
540
|
+
5. `w3:string:"openai/gpt-5.6-terra"`
|
|
541
|
+
6. `w1:string:"openai/gpt-5.6-sol"`
|
|
542
|
+
7. `w3:string:"openai/gpt-5.6-sol"`
|
|
543
|
+
8. `w3:string:"anthropic/claude-sonnet-5"`
|
|
544
|
+
9. `w3:string:"anthropic/claude-opus-5"`
|
|
545
|
+
10. `w3:string:"anthropic/claude-fable-5"`
|
|
546
|
+
11. `w1-billing-pattern`
|
|
547
|
+
|
|
548
|
+
This count depends on pi's registry data and any local registry
|
|
549
|
+
override. This machine's `~/.pi/agent/models.json` overrides the
|
|
550
|
+
three OpenAI windows to 1,050,000 tokens. A live render here therefore
|
|
551
|
+
suppresses the three divergence notes and the billing-pattern note,
|
|
552
|
+
leaving **7 model data notes**. That seven-note result describes this
|
|
553
|
+
machine, not a stock install.
|
|
554
|
+
|
|
555
|
+
The configured failover map covers all six candidates, so the
|
|
556
|
+
failover-coverage condition does not fire in either render. Preferred
|
|
557
|
+
candidates remain in the list, so the non-preferred-base condition
|
|
558
|
+
does not fire either.
|
|
559
|
+
|
|
560
|
+
In orchestrator mode the first consultation is the DOCTRINE BUILD,
|
|
473
561
|
not a dispatch: the orchestrator's system prompt carries the
|
|
474
|
-
routable model table, and it is assembled when
|
|
475
|
-
starts a turn.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
| --- | --- | --- |
|
|
486
|
-
| 6 | unknown routing data | each of the six profiles names routing-critical fields with no traced value, or with two published values and no adjudication, so routing decisions for them are provisional |
|
|
487
|
-
| 3 | context-window divergence | the three `openai/gpt-5.6-*` models — see below |
|
|
488
|
-
| 1 | billing-pattern note | one aggregate line naming those three models |
|
|
489
|
-
| 1 | failover coverage | one aggregate line, when `modelFailover` covers none of them |
|
|
490
|
-
|
|
491
|
-
The window divergence is real, and both sides are stated without
|
|
492
|
-
adjudication. On a stock install pi's registry reports **272,000**
|
|
493
|
-
tokens for `openai/gpt-5.6-luna`, `-terra` and `-sol`, while their
|
|
494
|
-
profiles record **1,050,000** (with the profile's asOf date in the
|
|
495
|
-
same line). Routing uses the REGISTRY figure — that is a fact about
|
|
496
|
-
this code, and it is stated as such — but which figure is factually
|
|
497
|
-
correct is not established by the router: it sees two numbers and no
|
|
498
|
-
provenance for either.
|
|
499
|
-
|
|
500
|
-
The registry's 272,000 is also exactly those models' long-context
|
|
501
|
-
BILLING threshold, so one further line says so once, naming all
|
|
502
|
-
three: a window equal to its own threshold would leave the
|
|
503
|
-
long-context price tier unreachable, which is the
|
|
504
|
-
billing-row-restated-as-a-capacity pattern. That is suggestive, not
|
|
505
|
-
proof, which is why it is a pointer and not a verdict.
|
|
506
|
-
|
|
507
|
-
**Why Slate does not "fix" this by changing its own number.** The
|
|
508
|
-
research pass that produced this table explicitly withdrew 272,000
|
|
509
|
-
as a capacity figure: in the source it appears only inside a pricing
|
|
510
|
-
row, as a boundary on input tokens. Restating it as a window is the
|
|
511
|
-
fabrication that pass caught and corrected. Editing the profile to
|
|
512
|
-
match the registry would silence the warning by asserting something
|
|
513
|
-
the evidence does not support, and would suppress a divergence that
|
|
514
|
-
is currently true. The honest options are the two you already have:
|
|
515
|
-
leave it (routing uses the registry number, which is the
|
|
516
|
-
conservative one) or raise the registry window deliberately in pi's
|
|
517
|
-
custom-models file, after which the warning stops on its own —
|
|
518
|
-
`context-budget.md` documents that opt-in and the billing
|
|
519
|
-
consequences of running above the threshold.
|
|
562
|
+
routable model table, and it is assembled when the first message
|
|
563
|
+
starts a turn. Outside orchestrator mode the first dispatch triggers
|
|
564
|
+
resolution instead. Config-shape warnings land earlier, at session
|
|
565
|
+
start.
|
|
566
|
+
|
|
567
|
+
A hidden warning can still alter which model runs an action. Context-
|
|
568
|
+
window divergence is the concrete case, and the stock registry emits
|
|
569
|
+
three such hidden notes for this list. Routing uses the registry
|
|
570
|
+
window, and that value drives the context-window substitution guard.
|
|
571
|
+
`router.showWarnings` changes display only; it does not change the
|
|
572
|
+
resolution or the guard input.
|
|
520
573
|
|
|
521
574
|
## What the router does NOT enforce
|
|
522
575
|
|
package/extension/index.ts
CHANGED
|
@@ -38,11 +38,14 @@
|
|
|
38
38
|
* "preserveGlobalModelDefault": true,
|
|
39
39
|
* "doctrineExtraPath": "docs/project-doctrine.md",
|
|
40
40
|
* "reviewPerspectivesPath": "docs/review-perspectives.md",
|
|
41
|
-
* "router": { "models": ["provider/id", ...], "allowUnmeasuredEffort": true
|
|
41
|
+
* "router": { "models": ["provider/id", ...], "allowUnmeasuredEffort": true,
|
|
42
|
+
* "showWarnings": false },
|
|
42
43
|
* "writing": { "check": false } }
|
|
43
44
|
* router.models is the closed list of models an action may be routed to (empty
|
|
44
45
|
* or absent = router off, the default); allowUnmeasuredEffort (default true)
|
|
45
|
-
* governs effort levels with no capability evidence
|
|
46
|
+
* governs effort levels with no capability evidence; showWarnings (default
|
|
47
|
+
* false) also shows the router's model data notes, the warnings about shipped
|
|
48
|
+
* research data that no project config can fix — see model-router.ts.
|
|
46
49
|
* contextBudget also takes { "tokens": N, "overrides": [{ "match": "regex",
|
|
47
50
|
* "tokens": N }] } (match is anchored against "provider/id"); absent, the
|
|
48
51
|
* built-in defaults apply (256k tokens; 400k for anthropic/*). The DEPRECATED
|
|
@@ -58,7 +61,13 @@ import { createBaseModelTracker, readLiveEffort, type BaseModelTracker } from ".
|
|
|
58
61
|
import { registerOrchestratorFailover, sanitizeModelFailover } from "./failover.ts";
|
|
59
62
|
import { registerSlateHandoff, sanitizeContextBudget } from "./handoff.ts";
|
|
60
63
|
import { registerSlateMode } from "./mode.ts";
|
|
61
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
createModelRouterResolver,
|
|
66
|
+
ROUTER_OFF,
|
|
67
|
+
sanitizeRouterConfig,
|
|
68
|
+
type ModelRouterResolution,
|
|
69
|
+
type RouterWarningClass,
|
|
70
|
+
} from "./model-router.ts";
|
|
62
71
|
import { sanitizeEpisodeModel, SlateStore, type SlateConfig } from "./state.ts";
|
|
63
72
|
import { ThreadManager } from "./threads.ts";
|
|
64
73
|
import { registerSlateTools } from "./tools.ts";
|
|
@@ -146,7 +155,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
155
|
config.workerExtensions = sanitizeWorkerExtensions(config.workerExtensions, warn);
|
|
147
156
|
// router likewise: a malformed model list must surface at session start, not
|
|
148
157
|
// when a dispatch is refused for naming a model the list silently dropped.
|
|
149
|
-
|
|
158
|
+
// The router's own warn sink. It reads the CLASS the router tags each warning
|
|
159
|
+
// with (model-router.ts) and hides a MODEL DATA NOTE — a fact about slate's
|
|
160
|
+
// shipped research table that no project config and no credential can change —
|
|
161
|
+
// unless the project asked for those with router.showWarnings. A CONFIGURATION
|
|
162
|
+
// FAULT always reaches the user, and so does a warning from a sink that omits
|
|
163
|
+
// the class argument. Nothing is dropped from the resolution itself: the router
|
|
164
|
+
// still emits every warning and still collects every one on its result.
|
|
165
|
+
let showRouterWarnings = false;
|
|
166
|
+
let hiddenRouterWarnings = 0;
|
|
167
|
+
const routerWarn = (msg: string, warningClass?: RouterWarningClass) => {
|
|
168
|
+
if (warningClass === "model-data-note" && !showRouterWarnings) {
|
|
169
|
+
hiddenRouterWarnings += 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
warn(msg);
|
|
173
|
+
};
|
|
174
|
+
config.router = sanitizeRouterConfig(config.router, routerWarn);
|
|
175
|
+
// Read AFTER sanitization, so an invalid value has already fallen back to false
|
|
176
|
+
// and reported itself as a configuration fault.
|
|
177
|
+
showRouterWarnings = config.router.showWarnings === true;
|
|
150
178
|
config.writing = sanitizeWritingConfig(config.writing, warn);
|
|
151
179
|
// episodeModel too (RG20): an unusable value falls back to the built-in
|
|
152
180
|
// compressor, and until this ran it did so without saying anything at all.
|
|
@@ -169,14 +197,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
169
197
|
// visible — and every later consultation gets that same frozen answer (CQ7).
|
|
170
198
|
// `failover` is passed so the resolver can report candidates with no failover
|
|
171
199
|
// coverage; both keys are read through the closure, not captured by value.
|
|
172
|
-
|
|
200
|
+
const memoizedRouterResolution = createModelRouterResolver(
|
|
173
201
|
() => ({
|
|
174
202
|
registry: ctx.modelRegistry,
|
|
175
203
|
models: config.router?.models ?? [],
|
|
176
204
|
failover: config.modelFailover ?? {},
|
|
177
205
|
}),
|
|
178
|
-
|
|
206
|
+
routerWarn,
|
|
179
207
|
);
|
|
208
|
+
// The discoverability line, at most once per session. It is flushed AFTER the
|
|
209
|
+
// memoized resolution returns on every return path. The current resolver emits
|
|
210
|
+
// no model-data note on its all-dropped path, but the flush stays there because
|
|
211
|
+
// this wrapper must cover that return path if the resolver gains one later. The
|
|
212
|
+
// memo means a later consultation re-emits nothing and cannot count a warning
|
|
213
|
+
// twice, and `noticeFlushed` covers the FIRST consultation
|
|
214
|
+
// producing no hidden note at all. It counts WARNINGS, not rendered lines: one
|
|
215
|
+
// warning can wrap across several of those.
|
|
216
|
+
let noticeFlushed = false;
|
|
217
|
+
resolveModelRouterResolution = () => {
|
|
218
|
+
const resolution = memoizedRouterResolution();
|
|
219
|
+
if (!noticeFlushed) {
|
|
220
|
+
noticeFlushed = true;
|
|
221
|
+
if (hiddenRouterWarnings > 0) {
|
|
222
|
+
const count = hiddenRouterWarnings;
|
|
223
|
+
const plural = count === 1 ? "is 1 hidden warning" : `are ${count} hidden warnings`;
|
|
224
|
+
try {
|
|
225
|
+
warn(
|
|
226
|
+
`slate: there ${plural} in the model router. Set "router.showWarnings" to true in ` +
|
|
227
|
+
`${CONFIG_DIR_NAME}/slate.json to read them. A hidden warning can affect which model runs an action.`,
|
|
228
|
+
);
|
|
229
|
+
} catch {
|
|
230
|
+
/* BG3: a throwing sink costs the notice, never the resolution */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return resolution;
|
|
235
|
+
};
|
|
180
236
|
// Fresh tracker per session, seeded from the session's OWN resolved model —
|
|
181
237
|
// undefined is legitimate (no model, or no auth for one) and stays silent.
|
|
182
238
|
// Same warn channel as the sanitizers above. A handoff adoption re-seeds it
|
|
@@ -80,9 +80,18 @@
|
|
|
80
80
|
* path, Track 02, gated by `router.allowUnmeasuredEffort`, default true) that
|
|
81
81
|
* decides what to do with that. This module never refuses anything.
|
|
82
82
|
*
|
|
83
|
+
* - EVERY WARNING CARRIES A CLASS (see RouterWarningClass): a CONFIGURATION
|
|
84
|
+
* FAULT the user can act on, or a MODEL DATA NOTE about shipped research the
|
|
85
|
+
* user cannot change. This module never hides anything: it tags each warning
|
|
86
|
+
* and sends every one of them to the sink it was handed. The extension entry
|
|
87
|
+
* point (index.ts) is what filters notes when `router.showWarnings` is off,
|
|
88
|
+
* and what reports how many it hid.
|
|
89
|
+
*
|
|
83
90
|
* - EVERY WARNING AT MOST ONCE PER SESSION (D58), sanitized through the shared
|
|
84
91
|
* sanitizeForNotify before display, exactly like the other sanitizers: config
|
|
85
92
|
* values and profile fields are user-editable text that reaches ctx.ui.notify.
|
|
93
|
+
* Each assembled message is capped as a WHOLE (ROUTER_MESSAGE_MAX), so the
|
|
94
|
+
* control-byte guard covers the finished string and not only its parts.
|
|
86
95
|
* Warnings are BOTH pushed to the sink and collected on the result, and the
|
|
87
96
|
* sink is called defensively — a throwing UI must not cost a resolution
|
|
88
97
|
* (BG3). Per-candidate notices that would otherwise scale with the list
|
|
@@ -138,6 +147,109 @@ export const SHIPPED_PROFILE_SOURCE: RouterProfileSource = {
|
|
|
138
147
|
ladderFor: shippedLadderFor,
|
|
139
148
|
};
|
|
140
149
|
|
|
150
|
+
/**
|
|
151
|
+
* What KIND of problem a router warning reports. There are exactly two.
|
|
152
|
+
*
|
|
153
|
+
* THE TEST, and it has two parts. A warning is a `configuration-fault` when
|
|
154
|
+
* EITHER part holds:
|
|
155
|
+
* (a) slate ignored or dropped part of the user's configuration, or
|
|
156
|
+
* (b) the user can stop the warning by ADDING something to their own project
|
|
157
|
+
* config or pi credentials, such as a model, credential or failover entry.
|
|
158
|
+
* This differs from only removing the model named by the warning.
|
|
159
|
+
* Otherwise it is a `model-data-note`.
|
|
160
|
+
*
|
|
161
|
+
* BOTH parts are load-bearing. The single-part "can the user stop it" question
|
|
162
|
+
* does not partition the classes: removing a dropped `router.models` entry can
|
|
163
|
+
* silence its warning, but removal is not the ADD remedy in part (b). Part (a)
|
|
164
|
+
* catches every silently ignored or dropped config value first.
|
|
165
|
+
*
|
|
166
|
+
* A `model-data-note` reports the shipped research table itself: a figure that
|
|
167
|
+
* has no traced source, two sources that disagree, a price row that does not
|
|
168
|
+
* cover today. No project config and no credential closes that gap, so these are
|
|
169
|
+
* hidden unless `router.showWarnings` is true.
|
|
170
|
+
*/
|
|
171
|
+
export type RouterWarningClass = "configuration-fault" | "model-data-note";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The warn sink, widened to carry the class alongside the message.
|
|
175
|
+
*
|
|
176
|
+
* A one-parameter `(message: string) => void` stays assignable to this type, so
|
|
177
|
+
* every existing caller keeps compiling AND keeps receiving every warning. Only
|
|
178
|
+
* a sink that WANTS to filter reads the second argument.
|
|
179
|
+
*/
|
|
180
|
+
export type RouterWarnSink = (message: string, warningClass: RouterWarningClass) => void;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Per-field cap for PROFILE-SOURCED text (the unknown-field entries and the
|
|
184
|
+
* non-preferred reason). BOTH bounds matter:
|
|
185
|
+
* - the longest entry in the shipped table measures 177 characters today (the
|
|
186
|
+
* design text quoted 167, measured before the last profile refresh), so 180
|
|
187
|
+
* keeps every real entry whole instead of cutting it mid-word;
|
|
188
|
+
* - the hostile fixture in verification/resolver-checks.mjs asserts that no
|
|
189
|
+
* warning carries a 200-character run, so the cap must stay BELOW 200.
|
|
190
|
+
* That leaves a narrow band, and 180 sits in it. The 80-character cap this
|
|
191
|
+
* replaces truncated most real entries.
|
|
192
|
+
*/
|
|
193
|
+
const PROFILE_FIELD_MAX = 180;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Cap for one WHOLE assembled warning. A profile may carry any number of
|
|
197
|
+
* fields, so a per-field cap alone does not bound the message. 799 is chosen
|
|
198
|
+
* against the same hostile check: sanitizeForNotify appends an ellipsis, so the
|
|
199
|
+
* displayed string stays at or below the 800 characters that check allows, and
|
|
200
|
+
* the longest real message stays whole.
|
|
201
|
+
*/
|
|
202
|
+
const ROUTER_MESSAGE_MAX = 799;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Separator between field entries: U+00B7 MIDDLE DOT.
|
|
206
|
+
*
|
|
207
|
+
* A newline is a control byte, and the hostile check bans one in any warning.
|
|
208
|
+
* U+00B7 was verified to survive sanitizeForNotify unchanged, and the
|
|
209
|
+
* confusable annotation (describeConfusables) never sees it, because that note
|
|
210
|
+
* is applied to a model SPEC and never to an assembled message.
|
|
211
|
+
*/
|
|
212
|
+
const FIELD_SEPARATOR = " · ";
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Display form of PROFILE-SOURCED text.
|
|
216
|
+
*
|
|
217
|
+
* The shipped table carries bracketed source tags ([G1e], [RI36], [arb]) that
|
|
218
|
+
* name unpublished research artifacts. They mean nothing to a reader of a
|
|
219
|
+
* warning, so they are removed here, the whitespace the removal leaves behind is
|
|
220
|
+
* collapsed, and a separator left dangling at the end is trimmed.
|
|
221
|
+
*
|
|
222
|
+
* USE THIS ONLY FOR PROFILE TEXT. An echoed USER value must keep its brackets:
|
|
223
|
+
* a user who wrote a nested array into `router.models` has to see those brackets
|
|
224
|
+
* to recognise the value the warning is talking about.
|
|
225
|
+
*/
|
|
226
|
+
function profileText(text: string, max = PROFILE_FIELD_MAX): string {
|
|
227
|
+
// Bound BEFORE scanning for tags. This keeps an unclosed bracket run from
|
|
228
|
+
// consuming session-start time, and the cap remains below the hostile 200-char
|
|
229
|
+
// fixture while clearing every real shipped entry.
|
|
230
|
+
const wasTruncated = text.length > max;
|
|
231
|
+
const bounded = text.slice(0, max);
|
|
232
|
+
const collapsed = bounded
|
|
233
|
+
// A citation can act as the subject after punctuation. Restore a neutral
|
|
234
|
+
// subject before removing it: "; [G3] gives" becomes "; the source gives".
|
|
235
|
+
.replace(/([;:,.])\s*\[[^\]]*\]\s+(?=[A-Za-z])/g, "$1 the source ")
|
|
236
|
+
.replace(/\[[^\]]*\]/g, "") // the source tags themselves
|
|
237
|
+
.replace(/\u00b7/g, " ") // profile text cannot forge the field-list separator
|
|
238
|
+
.replace(/\s+/g, " ") // the hole each removal leaves
|
|
239
|
+
.replace(/,\s*\)/g, ")") // "(vendor, [G3])" would otherwise read "(vendor, )"
|
|
240
|
+
.replace(/\(\s*\)/g, "") // a parenthesis that held nothing but a tag
|
|
241
|
+
.replace(/\s+([,;:.)])/g, "$1") // "1736 , unadjudicated" would otherwise keep the gap
|
|
242
|
+
.replace(/\s+/g, " ")
|
|
243
|
+
.replace(/[\s,;:—–-]+$/, "") // a separator the last removal left dangling
|
|
244
|
+
.trim();
|
|
245
|
+
if (!wasTruncated) return sanitizeForNotify(collapsed, max);
|
|
246
|
+
// Preserve an explicit truncation mark even when slicing made the cleaned text
|
|
247
|
+
// exactly max characters. Remove the sanitizer's own mark before adding one,
|
|
248
|
+
// so the visible result has one mark and remains within the same cap.
|
|
249
|
+
const cleaned = sanitizeForNotify(collapsed, max - 1);
|
|
250
|
+
return `${cleaned.endsWith("…") ? cleaned.slice(0, -1) : cleaned}…`;
|
|
251
|
+
}
|
|
252
|
+
|
|
141
253
|
/** One routable model: what survived validation, plus everything a caller needs to explain it. */
|
|
142
254
|
export interface RouterCandidate {
|
|
143
255
|
spec: string; // canonical "provider/id"
|
|
@@ -356,7 +468,7 @@ export function effectivePriceRow(profile: ModelProfile, today: string): PriceRo
|
|
|
356
468
|
}
|
|
357
469
|
|
|
358
470
|
/** The known `router` keys — anything else is a typo worth surfacing (CQ1). */
|
|
359
|
-
const ROUTER_KEYS = ["models", "allowUnmeasuredEffort"];
|
|
471
|
+
const ROUTER_KEYS = ["models", "allowUnmeasuredEffort", "showWarnings"];
|
|
360
472
|
|
|
361
473
|
/**
|
|
362
474
|
* Validate the raw `router` config value (D4/D53). undefined → the defaults
|
|
@@ -366,33 +478,56 @@ const ROUTER_KEYS = ["models", "allowUnmeasuredEffort"];
|
|
|
366
478
|
* and an unknown key is reported the way sanitizeContextBudget reports one
|
|
367
479
|
* (CQ1 — a typo'd `"model"` must not look like an empty list).
|
|
368
480
|
* `allowUnmeasuredEffort` defaults to TRUE — an evidence gap is advisory.
|
|
481
|
+
* `showWarnings` defaults to FALSE — model data notes are hidden until asked for.
|
|
482
|
+
*
|
|
483
|
+
* EVERY warning here is a configuration fault by the class test: each one names
|
|
484
|
+
* a value slate read from the project config and then ignored.
|
|
369
485
|
*/
|
|
370
|
-
export function sanitizeRouterConfig(raw: unknown, warn:
|
|
371
|
-
const defaults: Required<RouterConfig> = { models: [], allowUnmeasuredEffort: true };
|
|
486
|
+
export function sanitizeRouterConfig(raw: unknown, warn: RouterWarnSink): Required<RouterConfig> {
|
|
487
|
+
const defaults: Required<RouterConfig> = { models: [], allowUnmeasuredEffort: true, showWarnings: false };
|
|
488
|
+
const fault = (message: string) => warn(sanitizeForNotify(message, ROUTER_MESSAGE_MAX), "configuration-fault");
|
|
372
489
|
if (raw === undefined) return defaults;
|
|
373
490
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
374
|
-
|
|
491
|
+
fault('slate: ignoring the router config. It must be an object like { "models": ["provider/id"], "allowUnmeasuredEffort": true }.');
|
|
375
492
|
return defaults;
|
|
376
493
|
}
|
|
377
|
-
const value = raw as { models?: unknown; allowUnmeasuredEffort?: unknown };
|
|
494
|
+
const value = raw as { models?: unknown; allowUnmeasuredEffort?: unknown; showWarnings?: unknown };
|
|
378
495
|
|
|
379
|
-
|
|
496
|
+
let unknownKeys: string[];
|
|
497
|
+
try {
|
|
498
|
+
unknownKeys = Object.keys(value).filter((k) => !ROUTER_KEYS.includes(k));
|
|
499
|
+
} catch {
|
|
500
|
+
fault("slate: ignoring the router config because its keys could not be read. Slate uses the router defaults.");
|
|
501
|
+
return defaults;
|
|
502
|
+
}
|
|
380
503
|
if (unknownKeys.length > 0) {
|
|
381
|
-
|
|
382
|
-
`slate: ignoring unknown router key(s): ${sanitizeForNotify(unknownKeys.join(", "))}
|
|
504
|
+
fault(
|
|
505
|
+
`slate: ignoring unknown router key(s): ${sanitizeForNotify(unknownKeys.join(", "))}. The known router keys are ${ROUTER_KEYS.map(
|
|
383
506
|
(k) => `"${k}"`,
|
|
384
|
-
).join(", ")}
|
|
507
|
+
).join(", ")}.`,
|
|
385
508
|
);
|
|
386
509
|
}
|
|
387
510
|
|
|
511
|
+
const read = (key: keyof typeof value): { readable: boolean; value: unknown } => {
|
|
512
|
+
try {
|
|
513
|
+
return { readable: true, value: value[key] };
|
|
514
|
+
} catch {
|
|
515
|
+
fault(`slate: ignoring router.${key} because its value could not be read. Slate uses the default.`);
|
|
516
|
+
return { readable: false, value: undefined };
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const rawModels = read("models");
|
|
388
521
|
const models: string[] = [];
|
|
389
|
-
if (
|
|
390
|
-
if (!Array.isArray(value
|
|
391
|
-
|
|
522
|
+
if (rawModels.readable && rawModels.value !== undefined) {
|
|
523
|
+
if (!Array.isArray(rawModels.value)) {
|
|
524
|
+
fault('slate: router.models must be an array of "provider/id" strings. Slate ignores the value, so the router stays off.');
|
|
392
525
|
} else {
|
|
393
|
-
for (const entry of value
|
|
526
|
+
for (const entry of rawModels.value) {
|
|
394
527
|
if (!isModelSpec(entry)) {
|
|
395
|
-
|
|
528
|
+
// quoted() echoes a USER value, so its brackets stay: a nested array has
|
|
529
|
+
// to be recognisable in the warning that rejects it.
|
|
530
|
+
fault(`slate: ignoring the router.models entry ${quoted(entry)}. Reason: ${describeSpecDefect(entry)}.`);
|
|
396
531
|
continue;
|
|
397
532
|
}
|
|
398
533
|
models.push(entry);
|
|
@@ -401,17 +536,31 @@ export function sanitizeRouterConfig(raw: unknown, warn: (msg: string) => void):
|
|
|
401
536
|
}
|
|
402
537
|
|
|
403
538
|
let allowUnmeasuredEffort = true;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
539
|
+
const rawAllowUnmeasuredEffort = read("allowUnmeasuredEffort");
|
|
540
|
+
if (rawAllowUnmeasuredEffort.readable && rawAllowUnmeasuredEffort.value !== undefined) {
|
|
541
|
+
if (typeof rawAllowUnmeasuredEffort.value !== "boolean") {
|
|
542
|
+
fault(
|
|
543
|
+
`slate: ignoring router.allowUnmeasuredEffort ${quoted(rawAllowUnmeasuredEffort.value)}. Expected true or false. Slate uses true.`,
|
|
408
544
|
);
|
|
409
545
|
} else {
|
|
410
|
-
allowUnmeasuredEffort = value
|
|
546
|
+
allowUnmeasuredEffort = rawAllowUnmeasuredEffort.value;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Same shape as allowUnmeasuredEffort above: a non-boolean value is reported
|
|
551
|
+
// and the default stands. Reporting it is itself a configuration fault, so a
|
|
552
|
+
// typo here can never hide behind the very option it fails to set.
|
|
553
|
+
let showWarnings = false;
|
|
554
|
+
const rawShowWarnings = read("showWarnings");
|
|
555
|
+
if (rawShowWarnings.readable && rawShowWarnings.value !== undefined) {
|
|
556
|
+
if (typeof rawShowWarnings.value !== "boolean") {
|
|
557
|
+
fault(`slate: ignoring router.showWarnings ${quoted(rawShowWarnings.value)}. Expected true or false. Slate uses false.`);
|
|
558
|
+
} else {
|
|
559
|
+
showWarnings = rawShowWarnings.value;
|
|
411
560
|
}
|
|
412
561
|
}
|
|
413
562
|
|
|
414
|
-
return { models, allowUnmeasuredEffort };
|
|
563
|
+
return { models, allowUnmeasuredEffort, showWarnings };
|
|
415
564
|
}
|
|
416
565
|
|
|
417
566
|
/** Sort key for a tier: a malformed/absent tier sorts LAST instead of poisoning the comparator with NaN (CQ5). */
|
|
@@ -434,7 +583,7 @@ function tierOf(profile: ModelProfile): number {
|
|
|
434
583
|
* normally runs first, but this function is also called directly — by the
|
|
435
584
|
* checks, and by any future caller that builds a list itself (BG4).
|
|
436
585
|
*/
|
|
437
|
-
export function resolveModelRouter(input: ModelRouterInput, warn:
|
|
586
|
+
export function resolveModelRouter(input: ModelRouterInput, warn: RouterWarnSink = () => {}): ModelRouterResolution {
|
|
438
587
|
const models = Array.isArray(input.models) ? input.models : [];
|
|
439
588
|
if (models.length === 0) return ROUTER_OFF;
|
|
440
589
|
|
|
@@ -446,14 +595,21 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
446
595
|
// what identifies a condition, so a reworded message cannot start repeating.
|
|
447
596
|
// BG3: the sink is called inside a try/catch — a throwing UI must not abort a
|
|
448
597
|
// resolution (and, through the memo below, must not un-cache it either).
|
|
598
|
+
//
|
|
599
|
+
// The CLASS is the third parameter, and it DEFAULTS to "configuration-fault".
|
|
600
|
+
// A future warning added here without a class is therefore visible rather than
|
|
601
|
+
// silently hidden: forgetting the class costs noise, never a lost report.
|
|
449
602
|
const emitted = new Set<string>();
|
|
450
603
|
const warnings: string[] = [];
|
|
451
|
-
const once = (key: string, message: string) => {
|
|
604
|
+
const once = (key: string, message: string, warningClass: RouterWarningClass = "configuration-fault") => {
|
|
452
605
|
if (emitted.has(key)) return;
|
|
453
606
|
emitted.add(key);
|
|
454
|
-
|
|
607
|
+
// The whole assembled message is capped here, so the control-byte strip and the
|
|
608
|
+
// length bound apply to the finished string and not only to its interpolations.
|
|
609
|
+
const text = sanitizeForNotify(message, ROUTER_MESSAGE_MAX);
|
|
610
|
+
warnings.push(text);
|
|
455
611
|
try {
|
|
456
|
-
warn(
|
|
612
|
+
warn(text, warningClass);
|
|
457
613
|
} catch {
|
|
458
614
|
/* a broken warn sink costs the notice, never the resolution (BG3) */
|
|
459
615
|
}
|
|
@@ -468,7 +624,9 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
468
624
|
if (!isModelSpec(raw)) {
|
|
469
625
|
once(
|
|
470
626
|
conditionKey("malformed", raw),
|
|
471
|
-
|
|
627
|
+
// quoted() echoes a USER value: brackets and all, so the entry stays recognisable.
|
|
628
|
+
`slate: model router: ignoring the router.models entry ${quoted(raw)}. It is not a canonical "provider/id" ` +
|
|
629
|
+
`model spec. Reason: ${describeSpecDefect(raw)}.`,
|
|
472
630
|
);
|
|
473
631
|
continue;
|
|
474
632
|
}
|
|
@@ -485,7 +643,8 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
485
643
|
if (!profile || typeof profile !== "object") {
|
|
486
644
|
once(
|
|
487
645
|
conditionKey("unprofiled", raw),
|
|
488
|
-
`slate: model router: ${label} has no
|
|
646
|
+
`slate: model router: ${label} has no entry in slate's model profile table. Slate has no benchmark data for it, ` +
|
|
647
|
+
"so slate drops it from routing. Remove it from router.models, or list a profiled model instead.",
|
|
489
648
|
);
|
|
490
649
|
continue;
|
|
491
650
|
}
|
|
@@ -499,8 +658,8 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
499
658
|
if (claimedBy !== undefined) {
|
|
500
659
|
once(
|
|
501
660
|
conditionKey("alias-duplicate", raw),
|
|
502
|
-
`slate: model router: ${label}
|
|
503
|
-
`
|
|
661
|
+
`slate: model router: ${label} and ${sanitizeForNotify(claimedBy)} name the same profiled model. ` +
|
|
662
|
+
`Both resolve to the model profile ${sanitizeForNotify(profileId)}. Slate keeps the first entry and drops ${label}.`,
|
|
504
663
|
);
|
|
505
664
|
continue;
|
|
506
665
|
}
|
|
@@ -517,7 +676,9 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
517
676
|
if (!model || typeof model !== "object") {
|
|
518
677
|
once(
|
|
519
678
|
conditionKey("unknown", raw),
|
|
520
|
-
`slate: model router: ${label} is not in pi's model registry
|
|
679
|
+
`slate: model router: ${label} is not in pi's model registry. Slate drops it from routing. ` +
|
|
680
|
+
"A dispatch to a model pi does not know could only produce a billed failure. " +
|
|
681
|
+
"Add the model to pi's model registry, or remove it from router.models.",
|
|
521
682
|
);
|
|
522
683
|
continue;
|
|
523
684
|
}
|
|
@@ -530,7 +691,8 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
530
691
|
if (!authed) {
|
|
531
692
|
once(
|
|
532
693
|
conditionKey("noauth", raw),
|
|
533
|
-
`slate: model router: ${label} has no usable credentials configured in pi
|
|
694
|
+
`slate: model router: ${label} has no usable credentials configured in pi. Slate drops it from routing. ` +
|
|
695
|
+
"A dispatch to a model without credentials could only produce a billed failure. Add credentials in pi to route to it.",
|
|
534
696
|
);
|
|
535
697
|
continue;
|
|
536
698
|
}
|
|
@@ -541,8 +703,9 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
541
703
|
if (inUsdPerMTok === undefined) {
|
|
542
704
|
once(
|
|
543
705
|
conditionKey("price", raw),
|
|
544
|
-
`slate: model router: ${label} has no usable input price for ${today} in its profile
|
|
545
|
-
"
|
|
706
|
+
`slate: model router: ${label} has no usable input price for ${today} in its model profile. ` +
|
|
707
|
+
"Slate keeps the model and orders it last. Slate cannot compare its cost with the other models.",
|
|
708
|
+
"model-data-note",
|
|
546
709
|
);
|
|
547
710
|
}
|
|
548
711
|
|
|
@@ -596,10 +759,12 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
596
759
|
// from the user, this line must name WHICH source it read — the sentence
|
|
597
760
|
// contrasts two sources, and mislabelling one of them is the whole defect
|
|
598
761
|
// this wording was rewritten to avoid.
|
|
599
|
-
`slate: model router: context window for ${label} differs between sources
|
|
600
|
-
`${profileWindow} tokens
|
|
601
|
-
|
|
602
|
-
|
|
762
|
+
`slate: model router: the context window for ${label} differs between two sources. The model profile records ` +
|
|
763
|
+
`${profileWindow} tokens, and that profile was recorded as of ${profileText(quoted(profile.asOf ?? "unknown"))}. ` +
|
|
764
|
+
`The pi model registry reports ${registryWindow} tokens. Routing uses the registry figure. ` +
|
|
765
|
+
"Slate does not establish here which source is correct." +
|
|
766
|
+
`${matchesBillingThreshold ? " That registry figure is also this model's long-context billing threshold. A separate note below names that pattern." : ""}`,
|
|
767
|
+
"model-data-note",
|
|
603
768
|
);
|
|
604
769
|
}
|
|
605
770
|
|
|
@@ -607,11 +772,25 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
607
772
|
const unknownFields = Array.isArray(profile.unknownRoutingCriticalFields)
|
|
608
773
|
? profile.unknownRoutingCriticalFields.filter((f) => typeof f === "string" && f !== "")
|
|
609
774
|
: [];
|
|
610
|
-
|
|
775
|
+
const renderedUnknownFields = unknownFields.map((field) => profileText(field)).filter((field) => field !== "");
|
|
776
|
+
if (renderedUnknownFields.length > 0) {
|
|
777
|
+
// The CLASS explanation, once per session and only when a W3 warning fires at
|
|
778
|
+
// all. It is emitted BEFORE the first per-model line, so a reader meets the
|
|
779
|
+
// meaning of the class before the first instance of it (BG27's aggregation
|
|
780
|
+
// argument, applied to an explanation rather than to a set of models).
|
|
781
|
+
once(
|
|
782
|
+
"w3-explainer",
|
|
783
|
+
"slate: model router: slate picks models from a research table shipped inside slate. Some figures in that " +
|
|
784
|
+
"table have no traced source. Slate records the absence instead of a guess. Routing still works. " +
|
|
785
|
+
"The ranking of such a model rests on less evidence. You cannot close this gap from your configuration.",
|
|
786
|
+
"model-data-note",
|
|
787
|
+
);
|
|
788
|
+
const factNoun = renderedUnknownFields.length === 1 ? "fact" : "facts";
|
|
611
789
|
once(
|
|
612
790
|
conditionKey("w3", raw),
|
|
613
|
-
`slate: model router: ${label} has
|
|
614
|
-
|
|
791
|
+
`slate: model router: ${label} has ${renderedUnknownFields.length} model ${factNoun} that slate could not trace to a ` +
|
|
792
|
+
`source: ${renderedUnknownFields.join(FIELD_SEPARATOR)}. Routing to this model still works.`,
|
|
793
|
+
"model-data-note",
|
|
615
794
|
);
|
|
616
795
|
}
|
|
617
796
|
|
|
@@ -631,10 +810,17 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
631
810
|
? [...new Set(ladderRaw.filter((l): l is ThinkingLevel => THINKING_LEVELS.includes(l as string)))]
|
|
632
811
|
: [];
|
|
633
812
|
if (ladder.length === 0) {
|
|
813
|
+
// The earlier text claimed that every explicit effort level would read as
|
|
814
|
+
// off-ladder. That was wrong: guard 2 in route.ts fires only on a KNOWN ladder
|
|
815
|
+
// (`ladderKnown`), so an unreadable ladder makes it stand down and the level goes
|
|
816
|
+
// to pi, which clamps it. Guard 4 keeps its carve-out either way.
|
|
634
817
|
once(
|
|
635
818
|
conditionKey("ladder", raw),
|
|
636
|
-
`slate: model router: ${label} has no usable effort ladder in its profile
|
|
637
|
-
"
|
|
819
|
+
`slate: model router: ${label} has no usable effort ladder in its model profile. Slate keeps the model. ` +
|
|
820
|
+
"Slate cannot check an explicit effort level against a ladder it could not read. Such a level passes " +
|
|
821
|
+
"through to pi, which clamps it to a level the model supports. A level the provider rejects outright " +
|
|
822
|
+
"is still refused.",
|
|
823
|
+
"model-data-note",
|
|
638
824
|
);
|
|
639
825
|
}
|
|
640
826
|
|
|
@@ -663,8 +849,8 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
663
849
|
if (!isNonEmpty(candidates)) {
|
|
664
850
|
once(
|
|
665
851
|
"all-dropped",
|
|
666
|
-
`slate: model router: routing is disabled
|
|
667
|
-
"
|
|
852
|
+
`slate: model router: routing is disabled. None of the ${models.length} configured router.models entries ` +
|
|
853
|
+
"survived validation. The warnings above name each dropped entry and the reason for it.",
|
|
668
854
|
);
|
|
669
855
|
return frozenResolution(false, [], undefined, false, warnings);
|
|
670
856
|
}
|
|
@@ -677,9 +863,11 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
677
863
|
if (billingPatternSpecs.length > 0) {
|
|
678
864
|
once(
|
|
679
865
|
"w1-billing-pattern",
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
"tier unreachable
|
|
866
|
+
"slate: model router: for these models the pi model registry reports a context window equal to the model's own " +
|
|
867
|
+
`long-context billing threshold: ${billingPatternSpecs.join(", ")}. A window equal to its own threshold would ` +
|
|
868
|
+
"leave the long-context price tier unreachable. That shape suggests a billing figure restated as a capacity " +
|
|
869
|
+
"figure. Slate reports the pattern and does not decide which figure is right.",
|
|
870
|
+
"model-data-note",
|
|
683
871
|
);
|
|
684
872
|
}
|
|
685
873
|
|
|
@@ -690,8 +878,9 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
690
878
|
if (uncovered.length > 0) {
|
|
691
879
|
once(
|
|
692
880
|
"failover-coverage",
|
|
693
|
-
`slate: model router: no modelFailover entry
|
|
694
|
-
"
|
|
881
|
+
`slate: model router: these routable models have no modelFailover entry: ${uncovered.join(", ")}. ` +
|
|
882
|
+
"A model failure during an action routed to one of them has no failover coverage. " +
|
|
883
|
+
"Add a modelFailover entry for each of them to cover that case.",
|
|
695
884
|
);
|
|
696
885
|
}
|
|
697
886
|
|
|
@@ -735,10 +924,15 @@ export function resolveModelRouter(input: ModelRouterInput, warn: (msg: string)
|
|
|
735
924
|
for (const c of pool) if (price(c) < price(cheapest)) cheapest = c;
|
|
736
925
|
const cheapestNonPreferred = cheapest.nonPreferred !== null;
|
|
737
926
|
if (cheapestNonPreferred) {
|
|
927
|
+
// Always visible (a configuration fault by part (b) of the class test: adding
|
|
928
|
+
// one preferred model to router.models stops it), so the text is
|
|
929
|
+
// project-authored and the interpolated reason is stripped of its source tags.
|
|
738
930
|
once(
|
|
739
931
|
"nonpreferred-base",
|
|
740
|
-
|
|
741
|
-
`
|
|
932
|
+
"slate: model router: slate's model profiles mark every configured model as one it must never pick by " +
|
|
933
|
+
`itself. Slate still needs a default base model, so ${sanitizeForNotify(cheapest.spec, 60)} is the base ` +
|
|
934
|
+
`model for new threads. The recorded reason for that mark is: ${profileText(cheapest.nonPreferred ?? "")}. ` +
|
|
935
|
+
"Add a model without that mark to router.models to change the base model.",
|
|
742
936
|
);
|
|
743
937
|
}
|
|
744
938
|
|
|
@@ -837,7 +1031,7 @@ export function checkEffort(resolution: ModelRouterResolution, spec: string, eff
|
|
|
837
1031
|
*/
|
|
838
1032
|
export function createModelRouterResolver(
|
|
839
1033
|
getInput: () => ModelRouterInput,
|
|
840
|
-
warn:
|
|
1034
|
+
warn: RouterWarnSink = () => {},
|
|
841
1035
|
): () => ModelRouterResolution {
|
|
842
1036
|
let cached: ModelRouterResolution | undefined;
|
|
843
1037
|
return () => {
|
|
@@ -845,11 +1039,17 @@ export function createModelRouterResolver(
|
|
|
845
1039
|
try {
|
|
846
1040
|
cached = resolveModelRouter(getInput(), warn);
|
|
847
1041
|
} catch (error) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1042
|
+
// A configuration fault by the class test: the user's own model list, config or
|
|
1043
|
+
// credentials are the most likely input to a failed resolution, and a silent
|
|
1044
|
+
// router that routes nothing is the worst outcome to hide.
|
|
1045
|
+
const message = sanitizeForNotify(
|
|
1046
|
+
`slate: model router: routing is disabled. The router could not resolve its model list. The error was: ${sanitizeForNotify(
|
|
1047
|
+
error instanceof Error ? error.message : String(error),
|
|
1048
|
+
)}`,
|
|
1049
|
+
ROUTER_MESSAGE_MAX,
|
|
1050
|
+
);
|
|
851
1051
|
try {
|
|
852
|
-
warn(message);
|
|
1052
|
+
warn(message, "configuration-fault");
|
|
853
1053
|
} catch {
|
|
854
1054
|
/* see BG3 above */
|
|
855
1055
|
}
|
package/extension/notify.ts
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
* control/ANSI codes verbatim, so a raw value could inject terminal escapes —
|
|
8
8
|
* strip control characters and cap the length before display.
|
|
9
9
|
*
|
|
10
|
-
* Imported
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* Imported across failover.ts, handoff.ts, worker.ts, worker-extensions.ts,
|
|
11
|
+
* writing.ts, state.ts, episodes.ts, threads.ts, route.ts, base-model.ts and
|
|
12
|
+
* model-router.ts. This shared change therefore affects every notify or console
|
|
13
|
+
* warning those modules emit. model-default.ts deliberately does NOT import this:
|
|
14
|
+
* it also needs the bare control-char strip as a standalone primitive (for its
|
|
15
|
+
* word-boundary report truncation, which this helper does not do), so it keeps
|
|
16
|
+
* that primitive and its own fragment cap together locally — see the note there.
|
|
16
17
|
*/
|
|
17
18
|
export function sanitizeForNotify(s: string, max = 120): string {
|
|
18
|
-
|
|
19
|
+
// Remove all C0 and C1 controls, DEL, and Unicode bidirectional formatting
|
|
20
|
+
// controls. A directional override can reorder visible warning text even when
|
|
21
|
+
// it is not a terminal escape, and truncation can otherwise separate its pair.
|
|
22
|
+
const clean = s.replace(/[\u0000-\u001f\u007f\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
19
23
|
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
20
24
|
}
|
package/extension/state.ts
CHANGED
|
@@ -474,12 +474,16 @@ export interface ContextBudgetObject {
|
|
|
474
474
|
* pre-existing failover remain outside that feature-off statement.
|
|
475
475
|
* `allowUnmeasuredEffort` (default TRUE) decides what the dispatch path does
|
|
476
476
|
* with an effort level that is ladder-valid but has no capability evidence —
|
|
477
|
-
* an evidence gap is advisory, not a prohibition.
|
|
478
|
-
*
|
|
477
|
+
* an evidence gap is advisory, not a prohibition. `showWarnings` (default
|
|
478
|
+
* FALSE) reveals the router's MODEL DATA NOTES — the warnings a user cannot stop
|
|
479
|
+
* by changing this file or their pi credentials. A configuration fault is always
|
|
480
|
+
* shown, whatever this key says. Validated by sanitizeRouterConfig in
|
|
481
|
+
* model-router.ts.
|
|
479
482
|
*/
|
|
480
483
|
export interface RouterConfig {
|
|
481
484
|
models?: string[];
|
|
482
485
|
allowUnmeasuredEffort?: boolean;
|
|
486
|
+
showWarnings?: boolean;
|
|
483
487
|
}
|
|
484
488
|
|
|
485
489
|
/** Optional writing checks and context-cadenced reminders. */
|
|
@@ -527,7 +531,11 @@ export class SlateStore {
|
|
|
527
531
|
/** Invoked after every save/restore; used by mode.ts to refresh the widget. */
|
|
528
532
|
onDidChange?: () => void;
|
|
529
533
|
|
|
530
|
-
|
|
534
|
+
private pi: ExtensionAPI;
|
|
535
|
+
|
|
536
|
+
constructor(pi: ExtensionAPI) {
|
|
537
|
+
this.pi = pi;
|
|
538
|
+
}
|
|
531
539
|
|
|
532
540
|
nextThreadId(): string {
|
|
533
541
|
let max = 0;
|
package/extension/threads.ts
CHANGED
|
@@ -202,7 +202,10 @@ const FAILOVER_NUDGE =
|
|
|
202
202
|
class Semaphore {
|
|
203
203
|
private waiters: Array<() => void> = [];
|
|
204
204
|
private active = 0;
|
|
205
|
-
|
|
205
|
+
private limit: number;
|
|
206
|
+
constructor(limit: number) {
|
|
207
|
+
this.limit = limit;
|
|
208
|
+
}
|
|
206
209
|
async acquire(): Promise<void> {
|
|
207
210
|
if (this.active < this.limit) {
|
|
208
211
|
this.active++;
|
|
@@ -253,27 +256,42 @@ export class ThreadManager {
|
|
|
253
256
|
private liveBaselines = new Map<string, SessionBaseline>();
|
|
254
257
|
/** pi's compaction settings, read once (a lock-protected disk read) — see compactionSettings(). */
|
|
255
258
|
private cachedCompaction?: { enabled: boolean; reserveTokens: number; keepRecentTokens: number };
|
|
259
|
+
/** Session-owned persisted thread and episode state. */
|
|
260
|
+
private store: SlateStore;
|
|
261
|
+
/** This manager's immutable session configuration. */
|
|
262
|
+
private config: SlateConfig;
|
|
263
|
+
/** Frozen worker-extension resolver, bound by value to this session (AD41/CN20). */
|
|
264
|
+
private resolveExtensions: () => WorkerExtensionSet;
|
|
265
|
+
/** Memoized router resolver, bound by value to this session. */
|
|
266
|
+
private resolveRouter: () => ModelRouterResolution;
|
|
267
|
+
/** Orchestrator base model used only by the episode compressor's last-resort rung. */
|
|
268
|
+
private baseModelTracker?: BaseModelTracker;
|
|
256
269
|
|
|
257
270
|
constructor(
|
|
258
|
-
|
|
259
|
-
|
|
271
|
+
store: SlateStore,
|
|
272
|
+
config: SlateConfig,
|
|
260
273
|
// This session's frozen worker-extension resolver (AD41), bound BY VALUE at
|
|
261
274
|
// construction (CN20) so a manager orphaned by a session swap keeps its own
|
|
262
275
|
// session's set instead of resolving against a later one's. Read lazily per
|
|
263
276
|
// new worker. Defaults to the empty-set function so existing construction
|
|
264
277
|
// sites and test harnesses keep working with the feature off.
|
|
265
|
-
|
|
278
|
+
resolveExtensions: () => WorkerExtensionSet = () => EMPTY_WORKER_EXTENSION_SET,
|
|
266
279
|
// This session's MEMOIZED model-router resolution (model-router.ts's
|
|
267
280
|
// createModelRouterResolver), bound by value for the same reason as the
|
|
268
281
|
// resolver above: a manager orphaned by a session swap must keep answering
|
|
269
282
|
// with its own session's frozen candidate list. Defaults to the shared OFF
|
|
270
283
|
// resolution, which supplies no candidates or router-owned base.
|
|
271
|
-
|
|
284
|
+
resolveRouter: () => ModelRouterResolution = () => ROUTER_OFF,
|
|
272
285
|
// The orchestrator's base model, EXCLUDING failover fallbacks (base-model.ts).
|
|
273
286
|
// Consulted only for the episode compressor's last-resort model rung; route
|
|
274
287
|
// planning never seeds a worker-thread base from this tracker.
|
|
275
|
-
|
|
288
|
+
baseModelTracker?: BaseModelTracker,
|
|
276
289
|
) {
|
|
290
|
+
this.store = store;
|
|
291
|
+
this.config = config;
|
|
292
|
+
this.resolveExtensions = resolveExtensions;
|
|
293
|
+
this.resolveRouter = resolveRouter;
|
|
294
|
+
this.baseModelTracker = baseModelTracker;
|
|
277
295
|
this.semaphore = new Semaphore(config.maxConcurrent ?? 4); // default rationale: docs/design-principles.md §5 repo-local note
|
|
278
296
|
}
|
|
279
297
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ytdb-slate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Slate: thread-weaving orchestration for the pi coding agent — bounded worker actions, compressed episodes, mandatory research/design/adversarial/track review workflow, configurable model failover, and orchestrator context-budget handoff.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
26
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
27
|
+
"test": "bash verification/run-tests.sh",
|
|
28
|
+
"test:coverage": "bash verification/run-tests.sh",
|
|
29
|
+
"coverage:gate": "node verification/coverage-gate.mjs",
|
|
30
|
+
"link:peers": "bash verification/link-peers.sh"
|
|
27
31
|
},
|
|
28
32
|
"peerDependencies": {
|
|
29
33
|
"@earendil-works/pi-ai": "*",
|