@crewhaus/spec 0.2.0 → 0.2.2

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +5047 -125
  2. package/dist/index.js +148 -27
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -195,6 +195,13 @@ const toolConfigBlock = z
195
195
  * normal model-router path — a fallback with a missing key warns at boot
196
196
  * and is skipped when tried, never hard-failing the run.
197
197
  *
198
+ * The declared ORDER is honoured verbatim — it is a TRUST ordering ("what do
199
+ * I run when my primary is down"), and the runtime never reorders it (a
200
+ * 2026-07 design review deliberately retired the cost-ranking idea; see the
201
+ * `rankFallbacks` docstring in model-router's failover.ts). Want cheaper
202
+ * models preferred? Order the list yourself — or use `agent.model_pool`,
203
+ * which is the cost/quality routing surface.
204
+ *
198
205
  * `circuit_breaker` tunes the per-candidate breakers. Field names mirror
199
206
  * `CircuitBreakerOptions` in `@crewhaus/circuit-breaker` exactly
200
207
  * (failureThreshold / windowMs / cooldownMs); package defaults (5 failures
@@ -241,6 +248,110 @@ const modelTiersBlock = z
241
248
  })
242
249
  .strict()
243
250
  .optional();
251
+ /**
252
+ * Adaptive model routing — an opt-in `model_pool` of user-declared candidate
253
+ * models the runtime selects among PER TURN, with a selection `policy` that can
254
+ * improve the more the harness runs. The N-candidate generalisation of
255
+ * `model_tiers` (which is the two-candidate special case), so the two are
256
+ * mutually exclusive on an agent block (enforced by a refine on each shape).
257
+ *
258
+ * The user always declares the SET; the runtime only ever picks WITHIN it —
259
+ * `agent.model` (and the optimizer's model-path exclusion) is untouched, so
260
+ * learning tunes selection policy, never the candidate roster.
261
+ *
262
+ * - `candidates` — ≥2 model-router grammar strings, each with free-form
263
+ * `tags` (e.g. `cheap`, `strong`) the heuristic routes on. Declare
264
+ * cheapest→strongest; tags override that order.
265
+ * - `policy` — `static` (first candidate), `heuristic` (deterministic
266
+ * difficulty routing, the default), or `learned` (reward-scoreboard arm
267
+ * selection that improves with usage).
268
+ * - `objective` — weights for the learned reward (quality / cost / latency);
269
+ * defaults to quality-dominant (0.7 / 0.2 / 0.1).
270
+ * - `routing` — difficulty thresholds (shared with `model_tiers`) plus the
271
+ * `strongTag`/`cheapTag` the heuristic prefers.
272
+ * - `learning` — read only for `policy: learned`: the per-arm exploration
273
+ * floor and the cost/latency reward references.
274
+ *
275
+ * Omitted entirely → single-model behaviour, byte-identical bundles.
276
+ */
277
+ const modelPoolBlock = z
278
+ .object({
279
+ candidates: z
280
+ .array(z
281
+ .object({
282
+ model: z.string().min(1),
283
+ tags: z.array(z.string().min(1)).default([]),
284
+ })
285
+ .strict())
286
+ .min(2),
287
+ policy: z.enum(["static", "heuristic", "learned"]).default("heuristic"),
288
+ objective: z
289
+ .object({
290
+ quality: z.number().min(0).optional(),
291
+ cost: z.number().min(0).optional(),
292
+ latency: z.number().min(0).optional(),
293
+ })
294
+ .strict()
295
+ .optional(),
296
+ routing: z
297
+ .object({
298
+ contextTokenThreshold: z.number().int().positive().optional(),
299
+ toolsToDefault: z.boolean().optional(),
300
+ firstTurnToDefault: z.boolean().optional(),
301
+ priorToolDensityThreshold: z.number().int().positive().optional(),
302
+ strongTag: z.string().min(1).optional(),
303
+ cheapTag: z.string().min(1).optional(),
304
+ })
305
+ .strict()
306
+ .optional(),
307
+ learning: z
308
+ .object({
309
+ minSamplesPerArm: z.number().int().positive().optional(),
310
+ costRefUsd: z.number().positive().optional(),
311
+ latencyRefMs: z.number().int().positive().optional(),
312
+ // ε for ε-greedy online exploration once every arm clears the sample
313
+ // floor (fraction of exploit-phase turns that try a non-best model).
314
+ // Default 0 → deterministic explore-then-exploit, no RNG.
315
+ explorationRate: z.number().min(0).max(1).optional(),
316
+ // Fixed exploration seed for reproducible-across-runs behaviour (e.g.
317
+ // tests). Omitted → the runtime seeds from the sessionId, so each run
318
+ // explores differently while still replaying from its own transcript.
319
+ seed: z.string().min(1).optional(),
320
+ // Exploit-phase exploration strategy. "epsilon-greedy" (default) uses
321
+ // explorationRate; "thompson" draws each arm from its reward posterior
322
+ // and self-balances (explorationRate is then ignored).
323
+ bandit: z.enum(["epsilon-greedy", "thompson"]).optional(),
324
+ })
325
+ .strict()
326
+ .optional(),
327
+ })
328
+ .strict()
329
+ .optional();
330
+ /**
331
+ * Mutual-exclusion refine shared by every agent block that carries model
332
+ * routing: `model_pool` is the superset of both the two-tier router and the
333
+ * ordered failover chain, so declaring it alongside either is an error rather
334
+ * than an ambiguous double-route. (Per-candidate failover chains compose in a
335
+ * later release; this release keeps precedence unambiguous.)
336
+ */
337
+ function refineModelSelection(agent, ctx) {
338
+ if (agent.model_pool === undefined)
339
+ return;
340
+ if (agent.model_tiers !== undefined) {
341
+ ctx.addIssue({
342
+ code: z.ZodIssueCode.custom,
343
+ message: "agent.model_pool and agent.model_tiers are mutually exclusive — model_pool is the N-candidate generalisation of the two-tier router",
344
+ path: ["model_tiers"],
345
+ });
346
+ }
347
+ if (agent.model_fallbacks !== undefined) {
348
+ ctx.addIssue({
349
+ code: z.ZodIssueCode.custom,
350
+ message: "agent.model_pool and agent.model_fallbacks are mutually exclusive in this release — per-candidate failover chains are a future addition",
351
+ path: ["model_fallbacks"],
352
+ });
353
+ }
354
+ }
244
355
  /**
245
356
  * Section 17 — optional override for the model used by
246
357
  * `compaction-autocompact` when summarising long conversations. Defaults
@@ -630,9 +741,12 @@ const cliSchema = z
630
741
  circuit_breaker: circuitBreakerBlock,
631
742
  // Item 26 — opt-in two-tier turn-difficulty router.
632
743
  model_tiers: modelTiersBlock,
744
+ // Adaptive model routing — N-candidate pool with a selection policy.
745
+ model_pool: modelPoolBlock,
633
746
  sub_agents: subAgentsBlock,
634
747
  })
635
- .strict(),
748
+ .strict()
749
+ .superRefine(refineModelSelection),
636
750
  tools: z.array(z.string().min(1)).optional(),
637
751
  tool_config: toolConfigBlock,
638
752
  mcp_servers: mcpServersBlock,
@@ -751,11 +865,14 @@ const channelAgentSchema = z
751
865
  circuit_breaker: circuitBreakerBlock,
752
866
  // Item 26 — opt-in two-tier turn-difficulty router.
753
867
  model_tiers: modelTiersBlock,
868
+ // Adaptive model routing — N-candidate pool with a selection policy.
869
+ model_pool: modelPoolBlock,
754
870
  tools: z.array(z.string().min(1)).optional(),
755
871
  tool_config: toolConfigBlock,
756
872
  sub_agents: subAgentsBlock,
757
873
  })
758
- .strict();
874
+ .strict()
875
+ .superRefine(refineModelSelection);
759
876
  const channelSchema = z
760
877
  .object({
761
878
  name: safeName,
@@ -850,8 +967,11 @@ const managedAgentSchema = z
850
967
  circuit_breaker: circuitBreakerBlock,
851
968
  // Item 26 — opt-in two-tier turn-difficulty router.
852
969
  model_tiers: modelTiersBlock,
970
+ // Adaptive model routing — N-candidate pool with a selection policy.
971
+ model_pool: modelPoolBlock,
853
972
  })
854
- .strict();
973
+ .strict()
974
+ .superRefine(refineModelSelection);
855
975
  const managedSchema = z
856
976
  .object({
857
977
  name: safeName,
@@ -885,17 +1005,33 @@ const pipelineDocumentSchema = z
885
1005
  metadata: z.record(z.string(), z.unknown()).optional(),
886
1006
  })
887
1007
  .strict();
1008
+ /**
1009
+ * Adaptive model routing — the minimal single-agent block shared by the
1010
+ * pipeline/research/batch/browser shapes, now carrying the opt-in
1011
+ * `model_pool`. Their emitted runtimes each call `runChatLoop` with a single
1012
+ * primary (exactly the cli shape's execution model), so the pool routes there
1013
+ * with zero runtime changes. The superRefine is trivially satisfied today
1014
+ * (these shapes carry no `model_tiers`/`model_fallbacks`) but keeps the
1015
+ * mutual-exclusion rule uniform if they ever gain them. NOT used by
1016
+ * onchain/onchain-game: their emitted bundles are callable modules whose
1017
+ * agent-loop wiring is still deferred (see target-onchain slice-2 notes), so
1018
+ * a `model_pool` there would be an inert spec field.
1019
+ */
1020
+ const pooledSingleAgentSchema = z
1021
+ .object({
1022
+ model: z.string().min(1),
1023
+ instructions: z.string().min(1),
1024
+ // Adaptive model routing — N-candidate pool with a selection policy.
1025
+ model_pool: modelPoolBlock,
1026
+ })
1027
+ .strict()
1028
+ .superRefine(refineModelSelection);
888
1029
  const pipelineSchema = z
889
1030
  .object({
890
1031
  name: safeName,
891
1032
  version: versionField,
892
1033
  target: z.literal("pipeline"),
893
- agent: z
894
- .object({
895
- model: z.string().min(1),
896
- instructions: z.string().min(1),
897
- })
898
- .strict(),
1034
+ agent: pooledSingleAgentSchema,
899
1035
  retrieve: z
900
1036
  .object({
901
1037
  embedderModel: z.string().min(1),
@@ -991,12 +1127,7 @@ const researchSchema = z
991
1127
  name: safeName,
992
1128
  version: versionField,
993
1129
  target: z.literal("research"),
994
- agent: z
995
- .object({
996
- model: z.string().min(1),
997
- instructions: z.string().min(1),
998
- })
999
- .strict(),
1130
+ agent: pooledSingleAgentSchema,
1000
1131
  goal: z.string().min(1),
1001
1132
  branchingFactor: z.number().int().min(1).max(8).default(3),
1002
1133
  maxDurationMs: z.number().int().positive().default(300_000),
@@ -1032,12 +1163,7 @@ const batchSchema = z
1032
1163
  name: safeName,
1033
1164
  version: versionField,
1034
1165
  target: z.literal("batch"),
1035
- agent: z
1036
- .object({
1037
- model: z.string().min(1),
1038
- instructions: z.string().min(1),
1039
- })
1040
- .strict(),
1166
+ agent: pooledSingleAgentSchema,
1041
1167
  queue: batchQueueSchema,
1042
1168
  concurrency: z.number().int().min(1).max(64).default(4),
1043
1169
  idempotencyWindowMs: z.number().int().positive().default(60_000),
@@ -1108,12 +1234,7 @@ const browserSchema = z
1108
1234
  name: safeName,
1109
1235
  version: versionField,
1110
1236
  target: z.literal("browser"),
1111
- agent: z
1112
- .object({
1113
- model: z.string().min(1),
1114
- instructions: z.string().min(1),
1115
- })
1116
- .strict(),
1237
+ agent: pooledSingleAgentSchema,
1117
1238
  driver: browserDriverSchema.default({}),
1118
1239
  /** Vision-grounding model. Defaults to the agent's primary model. */
1119
1240
  groundingModel: z.string().min(1).optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "User-facing spec schema (Zod) + YAML parser",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.2.0",
18
+ "@crewhaus/errors": "0.2.2",
19
19
  "yaml": "^2.6.0",
20
20
  "zod": "^3.23.8"
21
21
  },