ai-runtime-engine 3.0.0 → 3.0.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli/cli.js +8 -1
  3. package/dist/cli/commands/cleanup.js +11 -3
  4. package/dist/cli/commands/doctor.js +1 -1
  5. package/dist/cli/commands/run.js +6 -0
  6. package/dist/cli/commands/skills.js +9 -2
  7. package/dist/cli/interactive/repl.js +12 -2
  8. package/dist/cli/interactive/session.d.ts +2 -0
  9. package/dist/cli/interactive/session.js +6 -2
  10. package/dist/config/schema.js +19 -1
  11. package/dist/conversations/conversations.d.ts +6 -1
  12. package/dist/conversations/conversations.js +15 -8
  13. package/dist/core/fallback/fallback.d.ts +7 -0
  14. package/dist/core/fallback/fallback.js +15 -2
  15. package/dist/core/health/monitor.d.ts +6 -0
  16. package/dist/core/health/monitor.js +15 -2
  17. package/dist/core/router/confidence.js +10 -5
  18. package/dist/core/router/dimensions.d.ts +3 -1
  19. package/dist/core/router/dimensions.js +15 -5
  20. package/dist/core/router/filter.js +25 -6
  21. package/dist/core/router/normalize.js +2 -0
  22. package/dist/core/router/router.js +16 -2
  23. package/dist/core/router/scorer.d.ts +3 -0
  24. package/dist/core/router/scorer.js +17 -2
  25. package/dist/discovery/openapi.js +3 -2
  26. package/dist/executions/agentTasks.d.ts +4 -4
  27. package/dist/generation/generateAdapter.js +3 -1
  28. package/dist/index.d.ts +4 -2
  29. package/dist/index.js +3 -2
  30. package/dist/mcp/protocol.js +4 -1
  31. package/dist/memory/bm25.d.ts +7 -0
  32. package/dist/memory/bm25.js +17 -1
  33. package/dist/memory/memory.d.ts +7 -1
  34. package/dist/memory/memory.js +18 -4
  35. package/dist/plugin/ai.d.ts +6 -0
  36. package/dist/plugin/ai.js +17 -2
  37. package/dist/providers/estimate.d.ts +25 -0
  38. package/dist/providers/estimate.js +55 -0
  39. package/dist/providers/factory.d.ts +3 -0
  40. package/dist/providers/factory.js +26 -5
  41. package/dist/providers/httpClient.js +4 -0
  42. package/dist/providers/httpProvider.js +4 -3
  43. package/dist/providers/mock/mockProvider.js +4 -3
  44. package/dist/runtime/config.d.ts +4 -3
  45. package/dist/runtime/config.js +13 -22
  46. package/dist/runtime/events.d.ts +6 -0
  47. package/dist/runtime/runtime.d.ts +3 -2
  48. package/dist/runtime/runtime.js +17 -7
  49. package/dist/runtime/types.d.ts +2 -1
  50. package/dist/store/area.d.ts +1 -1
  51. package/dist/store/area.js +34 -10
  52. package/dist/store/crypto.d.ts +27 -13
  53. package/dist/store/crypto.js +101 -23
  54. package/dist/store/errors.d.ts +11 -0
  55. package/dist/store/errors.js +14 -0
  56. package/dist/store/store.d.ts +21 -1
  57. package/dist/store/store.js +74 -19
  58. package/dist/telemetry/sinks/file.js +4 -2
  59. package/dist/telemetry/sinks/otlp.d.ts +12 -2
  60. package/dist/telemetry/sinks/otlp.js +39 -24
  61. package/dist/telemetry/telemetry.d.ts +5 -0
  62. package/dist/telemetry/telemetry.js +4 -0
  63. package/dist/tools/builtins/shell.d.ts +30 -3
  64. package/dist/tools/builtins/shell.js +218 -7
  65. package/dist/tools/untrusted.d.ts +1 -1
  66. package/dist/tools/untrusted.js +5 -3
  67. package/dist/types.d.ts +14 -0
  68. package/dist/verification/verify.js +10 -3
  69. package/docs/GUIDE.md +66 -1
  70. package/docs/README.md +1 -1
  71. package/docs/architecture.md +5 -1
  72. package/docs/router.md +1 -1
  73. package/docs/security.md +26 -7
  74. package/package.json +4 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ All notable changes to `ai-runtime` are documented here. The format follows
5
5
  Versioning](https://semver.org/). Development history and rationale live in
6
6
  [docs/DECISIONS.md](docs/DECISIONS.md) and [docs/PROGRESS.md](docs/PROGRESS.md).
7
7
 
8
+ ## [3.0.1] — 2026-09-07
9
+
10
+ **Hardening pass.** A correctness, security, test, documentation, and release-cleanup pass over a
11
+ full-repo audit of 3.0. The architecture is unchanged — every change extends an existing seam and is
12
+ backward compatible (with one documented downgrade caveat on the encryption format). The test suite grew
13
+ from 892 to 971 and CI now runs a Node 22 + 24 matrix with a tarball-contents audit, a doc-link/anchor
14
+ check, and a fail-closed test-count integrity floor.
15
+
16
+ ### Security
17
+
18
+ - **Shell tool: eval-capable invocations now require approval, even when allowlisted.** Allowlisting a
19
+ binary like `npm` or `node` no longer silently permits arbitrary code through its arguments —
20
+ interpreters run with inline-code/preload flags (`node -e`/`-r`, `python -c/-m`, `perl -M`, the `awk`
21
+ family, …), package/script runners (`npx`, `npm exec`/`run`/`init`, `deno task`, …), container
22
+ `run`/`exec`, argv-indirection wrappers (`env`, `xargs`, `nice`, `timeout`, `find -exec`, …), and a
23
+ non-default `make -f` are escalated to interactive approval (headless runs fail closed). The allowlist
24
+ matches an absolute path by basename but never a relative or workspace-internal one (realpath-checked).
25
+ This backstop is best-effort; `docs/security.md` documents its limits and accepted residuals.
26
+ - **Store files are created owner-only** (`0600` files, `0700` directories) on POSIX.
27
+ - **Encryption at rest v2:** the AES-256-GCM key is now derived with **scrypt** over a per-record salt
28
+ (the `aienc2` envelope) instead of a bare SHA-256, so a passphrase key resists brute force. Legacy
29
+ `aienc1` and plaintext records still read and upgrade on rewrite. A decryption failure is a typed
30
+ `StoreDecryptError` — honestly "wrong key OR tampering OR corruption" — and now surfaces instead of
31
+ reading as a silently empty store. *Downgrade caveat: a pre-3.0.1 runtime cannot read `aienc2` records.*
32
+ - Untrusted-content fencing neutralizes any fence label (not just its own); the verifier's model-authored
33
+ `reason` is flattened/clamped and its prompt inputs are fenced; the MCP schema sanitizer uses a
34
+ null-prototype object so a `__proto__` key cannot pollute.
35
+
36
+ ### Fixed
37
+
38
+ - **Config schema accepted four documented keys it used to reject.** `parseConfig` (the public export) now
39
+ accepts `learning`, `verification`, `budget`, and `policy` at the root; before this a config using any of
40
+ them failed to load.
41
+ - **Routing now always enforces context-fit**, not only when a cost/latency constraint is set; and
42
+ cost/context **estimates account for multimodal input parts** (an image is no longer estimated as zero).
43
+ - **`models: 'auto'`** resolves deterministically offline (defaultModel → kind catalog); a zero-model
44
+ result is a loud CONFIG error, not a silently unroutable provider.
45
+ - **Streaming fallback no longer garbles output**: a failed streamed attempt is explicitly abandoned via a
46
+ new `response.stream_abandoned` runtime event, and `response.streamed` is accurate under fallback.
47
+ - **OTLP telemetry is flushed on close**, so short-lived runs no longer drop sub-batch events.
48
+ - **Store lock** stale-steal is atomic (ownership token); **conversation metadata** writes serialize
49
+ through it; the **HTTP client** no longer sleeps out a backoff after the final retry; a stale provider
50
+ **cooldown** now reads as routable again; provider `listModels()`/`discover()` return copies.
51
+ - **CLI `--json --yes`** on `cleanup` and `skills --scaffold` now applies the operation and reports the
52
+ result, instead of printing a preview and ignoring `--yes`. `ai-runtime mcp show <id>` was added.
53
+
54
+ ### Changed
55
+
56
+ - **The four validated-but-ignored config fields are now enforced:** `ProviderConfig.weightOverrides`
57
+ (per-provider score weights; keys restricted to the scoring dimensions), `TaskDefinition.qualityFloor`
58
+ (excludes lower-tier models; a model with no tier is not excluded), `CapabilityRequirement.weight`
59
+ (weights capability fit and confidence alike), and `constraints.minimumConfidence` (raises the effective
60
+ confidence floor; it flags, it does not fail the run).
61
+ - **The memory tokenizer is Unicode-aware** — accented Latin, Cyrillic, Arabic, and CJK are now
62
+ searchable; pure-ASCII tokenization is byte-identical. *Note: learned goal keys for non-ASCII goals
63
+ re-key (old degenerate records are orphaned, never mixed); session-cached embedding vectors shift.*
64
+
65
+ ### Notes for consumers
66
+
67
+ - The `RuntimeEvent` union gained a `response.stream_abandoned` arm. New arms are additive at runtime but
68
+ break an exhaustive `switch` at compile time — carry a default case.
69
+
8
70
  ## [3.0.0] — 2026-09-05
9
71
 
10
72
  **AI Runtime 3.0.** The 3.x arc set out to make the runtime reason about *what it can do*, talk to tools
@@ -841,6 +903,7 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
841
903
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
842
904
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
843
905
 
906
+ [3.0.1]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v3.0.1
844
907
  [3.0.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v3.0.0
845
908
  [2.9.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.9.0
846
909
  [2.8.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.8.0
package/dist/cli/cli.js CHANGED
@@ -21,7 +21,7 @@ import { mcpCommand, mcpAddCommand, mcpRemoveCommand, mcpEnableCommand, mcpTestC
21
21
  import { startRepl } from './interactive/repl.js';
22
22
  import { printError } from './render.js';
23
23
  const program = new Command();
24
- program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('3.0.0');
24
+ program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('3.0.1');
25
25
  const configOpt = ['-c, --config <path>', 'path to an ai-runtime config file'];
26
26
  // Bare `ai-runtime` (no subcommand) opens the interactive terminal. `allowExcessArguments(false)` keeps
27
27
  // a mistyped subcommand (e.g. `ai-runtime porviders`) failing fast instead of silently opening the REPL.
@@ -77,6 +77,13 @@ mcp
77
77
  .option(...configOpt)
78
78
  .option('--json', 'print as JSON')
79
79
  .action((id, o) => mcpCommand(id, o));
80
+ mcp
81
+ .command('show')
82
+ .argument('<id>', 'server id')
83
+ .description('Show one server in detail — the unambiguous form for an id that collides with a subcommand name (add/remove/enable/disable/test)')
84
+ .option(...configOpt)
85
+ .option('--json', 'print as JSON')
86
+ .action((id, o) => mcpCommand(id, o));
80
87
  mcp
81
88
  .command('add')
82
89
  .argument('<id>', 'a short id for the server (lowercase, kebab/snake)')
@@ -74,11 +74,19 @@ export async function cleanupCommand(opts) {
74
74
  return;
75
75
  }
76
76
  const plan = planCleanup(rt, Date.now());
77
- if (opts.json)
78
- return print(JSON.stringify(plan, null, 2));
77
+ const removable = toRemove(plan);
78
+ // `--json` changes the OUTPUT FORMAT only; it does not suppress `--yes`. Without `--yes` (or with
79
+ // `--dry-run`) it emits a plan preview; with `--yes` it applies and reports the result. It never
80
+ // prompts (machine-facing). The JSON always carries `mode` so a caller can tell plan from applied.
81
+ if (opts.json) {
82
+ if (opts.yes && !opts.dryRun && removable > 0) {
83
+ const applied = applyCleanup(rt, plan);
84
+ return print(JSON.stringify({ mode: 'applied', plan, applied }, null, 2));
85
+ }
86
+ return print(JSON.stringify({ mode: 'plan', plan }, null, 2));
87
+ }
79
88
  for (const line of renderPlan(plan))
80
89
  print(line);
81
- const removable = toRemove(plan);
82
90
  if (removable === 0) {
83
91
  print('\nNothing to remove.');
84
92
  return;
@@ -75,7 +75,7 @@ export function renderDoctor(r) {
75
75
  if (!r.providers.length)
76
76
  lines.push(' (none configured — run `ai-runtime setup`)');
77
77
  for (const p of r.providers)
78
- lines.push(` ${p.authenticated ? '✓' : '✗'} ${p.id.padEnd(14)} ${p.authenticated ? 'authenticated' : 'not configured'} [${p.state}] ${p.models} models`);
78
+ lines.push(` ${p.authenticated ? '✓' : '✗'} ${p.id.padEnd(14)} ${p.authenticated ? 'authenticated' : 'not configured'} [${p.state}] ${p.models} models${p.models === 0 ? ' ⚠ no routable models — list models explicitly or set defaultModel' : ''}`);
79
79
  lines.push(`Models: ${r.models.total} known, ${r.models.usableProviders} provider(s) usable`);
80
80
  lines.push('', 'Store:');
81
81
  if (!r.store.enabled)
@@ -32,6 +32,12 @@ async function runWith(rt, input, options) {
32
32
  printChunk(e.text);
33
33
  streamedAny = true;
34
34
  }
35
+ else if (e.type === 'response.stream_abandoned') {
36
+ // Mark a boundary so the discarded partial is not read as part of what follows. Do not promise a
37
+ // retry — abandonment also fires when the failed attempt was the last candidate.
38
+ printChunk('\n↩ discarded incomplete response\n');
39
+ streamedAny = false;
40
+ }
35
41
  });
36
42
  }
37
43
  if (options.mode) {
@@ -51,8 +51,15 @@ export async function skillsCommand(opts) {
51
51
  return print(JSON.stringify({ ok: false, error: draft.error }, null, 2));
52
52
  return print(`Could not scaffold a skill: ${draft.error}`);
53
53
  }
54
- if (opts.json)
55
- return print(JSON.stringify({ ok: true, manifest: draft.manifest, yaml: draft.yaml }, null, 2));
54
+ // `--json` changes the OUTPUT FORMAT only; with `--yes` it SAVES and reports the path, without it
55
+ // returns the draft. It never prompts (machine-facing). `mode` distinguishes draft from saved.
56
+ if (opts.json) {
57
+ if (opts.yes) {
58
+ const savedPath = rt.saveScaffoldedSkill(draft.manifest);
59
+ return print(JSON.stringify({ ok: true, mode: 'saved', path: savedPath, manifest: draft.manifest }, null, 2));
60
+ }
61
+ return print(JSON.stringify({ ok: true, mode: 'draft', manifest: draft.manifest, yaml: draft.yaml }, null, 2));
62
+ }
56
63
  print(`Drafted skill "${draft.manifest.id}":\n`);
57
64
  print(draft.yaml);
58
65
  const proceed = opts.yes || (await confirm(`Save to .ai-runtime/skills/${draft.manifest.id}.skill.yaml?`));
@@ -88,8 +88,10 @@ export async function startRepl(configPath) {
88
88
  if (!line.trim())
89
89
  return;
90
90
  try {
91
- mkdirSync(dirname(historyPath), { recursive: true });
92
- appendFileSync(historyPath, `${line}\n`);
91
+ // Owner-only: history holds raw prompt lines under the store home (0700 dir / 0600 file, matching
92
+ // the store's own record hardening). Mode applies only on creation; POSIX-only, a no-op on Windows.
93
+ mkdirSync(dirname(historyPath), { recursive: true, mode: 0o700 });
94
+ appendFileSync(historyPath, `${line}\n`, { mode: 0o600 });
93
95
  }
94
96
  catch {
95
97
  /* history is a convenience; a write failure must never break the REPL */
@@ -121,6 +123,14 @@ export async function startRepl(configPath) {
121
123
  streamedThisRun = true;
122
124
  return;
123
125
  }
126
+ if (e.type === 'response.stream_abandoned') {
127
+ // The partial answer just streamed from this provider is being discarded; mark a clear boundary so
128
+ // whatever follows is not read as a seamless continuation of it. Not necessarily a retry — this also
129
+ // fires when the failed attempt was the last candidate.
130
+ printChunk('\n↩ discarded incomplete response\n');
131
+ streamedThisRun = false;
132
+ return;
133
+ }
124
134
  const lane = lanes.observe(e);
125
135
  if (lane) {
126
136
  if (!runInFlight)
@@ -22,8 +22,10 @@ export declare class ReplSession {
22
22
  private conversationId?;
23
23
  private dryRunMode;
24
24
  private streaming;
25
+ private readonly env;
25
26
  constructor(runtime: Runtime, opts?: {
26
27
  streaming?: boolean;
28
+ env?: NodeJS.ProcessEnv;
27
29
  });
28
30
  currentMode(): RuntimeMode;
29
31
  private views;
@@ -76,9 +76,13 @@ export class ReplSession {
76
76
  conversationId;
77
77
  dryRunMode = false;
78
78
  streaming;
79
+ env;
79
80
  constructor(runtime, opts = {}) {
80
81
  this.runtime = runtime;
81
82
  this.streaming = opts.streaming ?? false;
83
+ // The env the /budget display reads. Injectable so tests need not mutate the real process.env; the
84
+ // enforcement path already resolves budgets from the runtime's injected env.
85
+ this.env = opts.env ?? process.env;
82
86
  }
83
87
  currentMode() {
84
88
  return this.mode;
@@ -202,8 +206,8 @@ export class ReplSession {
202
206
  case 'budget':
203
207
  return {
204
208
  lines: [
205
- `call budget (AI_MAX_CALLS): ${process.env.AI_MAX_CALLS ?? '(unset — no limit)'}`,
206
- `cost budget (AI_MAX_COST_USD): ${process.env.AI_MAX_COST_USD ?? '(unset — no limit)'}`,
209
+ `call budget (AI_MAX_CALLS): ${this.env.AI_MAX_CALLS ?? '(unset — no limit)'}`,
210
+ `cost budget (AI_MAX_COST_USD): ${this.env.AI_MAX_COST_USD ?? '(unset — no limit)'}`,
207
211
  'over budget: notify-and-wait by default; add --partial (one-shot) to run the phases that fit and pause.',
208
212
  ],
209
213
  };
@@ -11,6 +11,8 @@ const EVIDENCE = ['unsupported', 'unknown', 'inferred', 'documented', 'verified'
11
11
  const GROUPS = ['input', 'output', 'intelligence', 'agent'];
12
12
  const KINDS = ['openai-compatible', 'gemini', 'groq', 'anthropic', 'ollama', 'custom', 'mock'];
13
13
  export const KEY_LIKE = /^(sk-|gsk_|Bearer\s|[A-Za-z0-9_-]{40,}$)/;
14
+ /** The seven scoring-weight dimensions. A `weightOverrides` block may only name these keys. */
15
+ const SCORE_WEIGHT_KEYS = ['capabilityFit', 'quality', 'reliability', 'historicalSuccess', 'latency', 'cost', 'userPreference'];
14
16
  const capabilityRequirement = z.object({
15
17
  group: z.enum(GROUPS),
16
18
  key: z.string().min(1),
@@ -50,10 +52,22 @@ const providerConfig = z
50
52
  privacyClass: z.enum(['local', 'cloud']).optional(),
51
53
  wireShape: z.enum(['openai', 'anthropic']).optional(),
52
54
  headers: z.record(z.string()).optional(),
53
- weightOverrides: z.record(z.number()).optional(),
55
+ weightOverrides: z.record(z.enum(SCORE_WEIGHT_KEYS), z.number()).optional(),
54
56
  })
55
57
  .strict()
56
58
  .refine((p) => !(['openai-compatible', 'custom'].includes(p.kind) && !p.baseUrl), { message: 'openai-compatible/custom providers require a baseUrl' });
59
+ /**
60
+ * The four router-level blocks that Runtime settings also accept. Defined once here — the single source
61
+ * of truth used by `routerConfig` below — so `parseConfig` (the public export) accepts them. Before 3.0.1
62
+ * the root schema rejected them and they were only tolerated via parseRuntimeConfig's fold-back; that
63
+ * fold-back is gone, and the runtime-config layer now receives them transitively through parseConfig.
64
+ */
65
+ const learningConfig = z.object({ enabled: z.boolean().optional() }).strict();
66
+ const verificationConfig = z.object({ enabled: z.boolean().optional() }).strict();
67
+ const budgetConfig = z.object({ maxCostUsd: z.number().optional(), maxCalls: z.number().optional() }).strict();
68
+ const policyConfig = z
69
+ .object({ allowProviders: z.array(z.string()).optional(), denyProviders: z.array(z.string()).optional(), requireLocal: z.boolean().optional(), maxCostUsd: z.number().optional(), strategy: z.enum(STRATEGIES).optional() })
70
+ .strict();
57
71
  const routerConfig = z
58
72
  .object({
59
73
  providers: z.array(providerConfig),
@@ -65,6 +79,10 @@ const routerConfig = z
65
79
  .strict()
66
80
  .optional(),
67
81
  telemetry: z.object({ enabled: z.boolean().optional(), sink: z.enum(['memory', 'file', 'otlp']).optional(), storePrompts: z.literal(false).optional(), path: z.string().optional(), endpoint: z.string().optional(), headersEnv: z.string().refine((v) => v === undefined || !KEY_LIKE.test(v), { message: 'headersEnv must be an env-var NAME, not a header/token value' }).optional() }).strict().optional(),
82
+ learning: learningConfig.optional(),
83
+ verification: verificationConfig.optional(),
84
+ budget: budgetConfig.optional(),
85
+ policy: policyConfig.optional(),
68
86
  tasks: z.array(taskDefinition).optional(),
69
87
  })
70
88
  .strict();
@@ -20,11 +20,16 @@ export interface ConversationMeta {
20
20
  updatedAt: number;
21
21
  turns: number;
22
22
  }
23
+ /** Serializes a critical section. Injected from the RuntimeStore's advisory lock; defaults to a no-op. */
24
+ export type WithLock = <T>(fn: () => T) => T;
23
25
  export declare class ConversationStore {
24
26
  private readonly area;
25
27
  private readonly clock;
28
+ private readonly withLock;
26
29
  private counter;
27
- constructor(area: Area, clock?: Clock);
30
+ constructor(area: Area, clock?: Clock, opts?: {
31
+ withLock?: WithLock;
32
+ });
28
33
  get enabled(): boolean;
29
34
  /** Start a conversation; returns its id. */
30
35
  start(id?: string): string;
@@ -16,10 +16,13 @@ function deriveTitle(text) {
16
16
  export class ConversationStore {
17
17
  area;
18
18
  clock;
19
+ withLock;
19
20
  counter = 0;
20
- constructor(area, clock = systemClock) {
21
+ constructor(area, clock = systemClock, opts = {}) {
21
22
  this.area = area;
22
23
  this.clock = clock;
24
+ // Default is a plain pass-through, so a ConversationStore built without a lock behaves exactly as before.
25
+ this.withLock = opts.withLock ?? ((fn) => fn());
23
26
  }
24
27
  get enabled() {
25
28
  return this.area.enabled;
@@ -36,13 +39,17 @@ export class ConversationStore {
36
39
  append(id, role, text, runId) {
37
40
  const safe = redactString(text);
38
41
  const turn = { role, text: safe, ts: this.clock.now(), ...(runId ? { runId } : {}) };
39
- this.area.appendLine(id, turn);
40
- const meta = this.area.tryReadJson(id) ?? { id, title: 'Untitled', createdAt: turn.ts, updatedAt: turn.ts, turns: 0 };
41
- meta.turns += 1;
42
- meta.updatedAt = turn.ts;
43
- if (meta.title === 'Untitled' && role === 'user')
44
- meta.title = deriveTitle(safe);
45
- this.area.writeJson(id, meta);
42
+ this.area.appendLine(id, turn); // JSONL append is atomic on its own
43
+ // Serialize the meta read-modify-write: two concurrent appends must not both read the same turn count
44
+ // and each write back turns+1 (losing one). The JSONL lines above are safe either way.
45
+ this.withLock(() => {
46
+ const meta = this.area.tryReadJson(id) ?? { id, title: 'Untitled', createdAt: turn.ts, updatedAt: turn.ts, turns: 0 };
47
+ meta.turns += 1;
48
+ meta.updatedAt = turn.ts;
49
+ if (meta.title === 'Untitled' && role === 'user')
50
+ meta.title = deriveTitle(safe);
51
+ this.area.writeJson(id, meta);
52
+ });
46
53
  }
47
54
  turns(id) {
48
55
  return this.area.readLines(id);
@@ -23,6 +23,13 @@ export interface FallbackInput {
23
23
  /** Phase-13 streaming: forwarded to each attempt's `executeOnce`; deltas ride this callback, the final
24
24
  * aggregate rides the return value. Only meaningful when `template.stream` is set. */
25
25
  onDelta?: (chunk: string) => void;
26
+ /** Fired when an attempt that already emitted deltas fails (or is dropped by validation) and fallback
27
+ * moves on: the partial stream just sent must be discarded, since the next attempt streams anew. This
28
+ * keeps the live stream honest — provider B's answer is never a seamless continuation of provider A's. */
29
+ onStreamAbandoned?: (info: {
30
+ providerId: string;
31
+ model: string;
32
+ }) => void;
26
33
  /** Optional Phase-6 validation. A failing report drops this candidate and continues (no poisoning). */
27
34
  validate?: (response: AIResponse, model: string) => ValidationReport;
28
35
  /** Optional spend guardrail. When it cannot afford the next call, the run STOPS with BUDGET. */
@@ -34,9 +34,18 @@ export async function runWithFallback(input) {
34
34
  tried += 1;
35
35
  const started = clock.now();
36
36
  const request = buildRequest(input.template, model.id, input.signal);
37
+ // Wrap onDelta per attempt so we know whether THIS attempt streamed anything. If it did and the
38
+ // attempt then fails or is dropped, we signal abandonment so the consumer discards the partial.
39
+ let sawDelta = false;
40
+ const onDelta = input.onDelta
41
+ ? (chunk) => {
42
+ sawDelta = true;
43
+ input.onDelta(chunk);
44
+ }
45
+ : undefined;
37
46
  const outcome = input.providerLimiter
38
- ? await input.providerLimiter.run(providerId, () => executeOnce(provider, request, input.onDelta))
39
- : await executeOnce(provider, request, input.onDelta);
47
+ ? await input.providerLimiter.run(providerId, () => executeOnce(provider, request, onDelta))
48
+ : await executeOnce(provider, request, onDelta);
40
49
  const latencyMs = clock.now() - started;
41
50
  input.budget?.recordCall(estCost);
42
51
  if (outcome.ok) {
@@ -47,6 +56,8 @@ export async function runWithFallback(input) {
47
56
  const record = { providerId, model: model.id, outcome: 'non-retryable', category: 'RESPONSE_VALIDATION', latencyMs };
48
57
  attempts.push(record);
49
58
  input.onAttempt?.(record);
59
+ if (sawDelta)
60
+ input.onStreamAbandoned?.({ providerId, model: model.id });
50
61
  lastError = new AIError(`response failed validation: ${why}`, { category: 'RESPONSE_VALIDATION', retryable: false, providerId, model: model.id });
51
62
  continue;
52
63
  }
@@ -63,6 +74,8 @@ export async function runWithFallback(input) {
63
74
  };
64
75
  attempts.push(record);
65
76
  input.onAttempt?.(record);
77
+ if (sawDelta)
78
+ input.onStreamAbandoned?.({ providerId, model: model.id });
66
79
  lastError = outcome.error;
67
80
  if (record.outcome === 'non-retryable')
68
81
  poisoned.add(providerId);
@@ -15,6 +15,12 @@ export declare class HealthMonitor {
15
15
  constructor(telemetry?: TelemetrySink | undefined, clock?: Clock, cooldownMs?: number);
16
16
  get(providerId: string): HealthStatus | undefined;
17
17
  all(): HealthStatus[];
18
+ /**
19
+ * Recompute liveness on READ so an inspector (doctor/status) agrees with what `routable()` — and thus
20
+ * the router — will actually do: once a cooldown has elapsed the provider reads as routable again with
21
+ * the stale `cooldownUntil` cleared, instead of showing a long-expired cooldown as still down.
22
+ */
23
+ private withLiveness;
18
24
  /** Optimistic: an unseen provider is routable; a cooled-down one becomes routable again once elapsed. */
19
25
  routable(providerId: string): boolean;
20
26
  seed(status: HealthStatus): void;
@@ -38,10 +38,23 @@ export class HealthMonitor {
38
38
  this.clock = clock;
39
39
  }
40
40
  get(providerId) {
41
- return this.state.get(providerId);
41
+ const s = this.state.get(providerId);
42
+ return s ? this.withLiveness(s) : undefined;
42
43
  }
43
44
  all() {
44
- return [...this.state.values()];
45
+ return [...this.state.values()].map((s) => this.withLiveness(s));
46
+ }
47
+ /**
48
+ * Recompute liveness on READ so an inspector (doctor/status) agrees with what `routable()` — and thus
49
+ * the router — will actually do: once a cooldown has elapsed the provider reads as routable again with
50
+ * the stale `cooldownUntil` cleared, instead of showing a long-expired cooldown as still down.
51
+ */
52
+ withLiveness(s) {
53
+ if (s.cooldownUntil !== undefined && this.clock.now() >= s.cooldownUntil) {
54
+ const { cooldownUntil: _elapsed, ...rest } = s;
55
+ return { ...rest, routable: true };
56
+ }
57
+ return s;
45
58
  }
46
59
  /** Optimistic: an unseen provider is routable; a cooled-down one becomes routable again once elapsed. */
47
60
  routable(providerId) {
@@ -8,13 +8,18 @@ const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
8
8
  export function deriveConfidence(topScore, model, task) {
9
9
  let evFactor = 1;
10
10
  if (task.required.length > 0) {
11
- const sum = task.required.reduce((acc, r) => {
11
+ // Same per-requirement weighting (weight ?? 1) as capabilityFit, so fit and confidence stay consistent.
12
+ let wsum = 0;
13
+ let sum = 0;
14
+ for (const r of task.required) {
15
+ const w = r.weight ?? 1;
16
+ wsum += w;
12
17
  if (!capabilitySatisfies(model.capabilities, r))
13
- return acc; // pinned/unknown-capability model: penalize
18
+ continue; // pinned/unknown-capability model: penalize
14
19
  const cap = getCapability(model.capabilities, r.group, r.key);
15
- return acc + rankOf(cap.evidence) / 4;
16
- }, 0);
17
- evFactor = sum / task.required.length;
20
+ sum += w * (rankOf(cap.evidence) / 4);
21
+ }
22
+ evFactor = wsum > 0 ? sum / wsum : 1;
18
23
  }
19
24
  return clamp01(topScore * (0.5 + 0.5 * evFactor) * task.confidence);
20
25
  }
@@ -4,8 +4,10 @@
4
4
  * so `core/scoring` never imports vendor data. `historicalSuccess` is a static placeholder here and
5
5
  * is wired to telemetry in a later stage.
6
6
  */
7
- import type { ModelMetadata, NormalizedTask } from '../../types.js';
7
+ import type { ModelMetadata, NormalizedTask, QualityTier } from '../../types.js';
8
8
  import type { ObservedPerf } from '../../learning/performanceStore.js';
9
+ /** Quality-tier ordering, exported so the filter can enforce a task's qualityFloor with the same scale. */
10
+ export declare const TIER_SCORE: Record<QualityTier, number>;
9
11
  /** How well the model meets required capabilities (evidence-weighted) plus a preferred-coverage bonus. */
10
12
  export declare function capabilityFit(model: ModelMetadata, task: NormalizedTask): number;
11
13
  export declare function quality(model: ModelMetadata): number;
@@ -7,17 +7,27 @@
7
7
  import { rankOf } from '../capabilities/evidence.js';
8
8
  import { capabilitySatisfies, getCapability } from '../capabilities/evidence.js';
9
9
  const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
10
- const TIER_SCORE = { frontier: 1, strong: 0.8, mid: 0.6, small: 0.4 };
10
+ /** Quality-tier ordering, exported so the filter can enforce a task's qualityFloor with the same scale. */
11
+ export const TIER_SCORE = { frontier: 1, strong: 0.8, mid: 0.6, small: 0.4 };
11
12
  /** How well the model meets required capabilities (evidence-weighted) plus a preferred-coverage bonus. */
12
13
  export function capabilityFit(model, task) {
13
14
  const evFactor = (key, group) => {
14
15
  const cap = getCapability(model.capabilities, group, key);
15
16
  return cap.value ? rankOf(cap.evidence) / 4 : 0;
16
17
  };
17
- const requiredScore = task.required.length === 0
18
- ? 1
19
- : task.required.reduce((acc, r) => acc + (capabilitySatisfies(model.capabilities, r) ? evFactor(r.key, r.group) : 0), 0) /
20
- task.required.length;
18
+ // Per-requirement weighting (CapabilityRequirement.weight, default 1): a requirement's contribution to
19
+ // the mean is proportional to its weight. With all weights defaulting to 1 this is the plain mean.
20
+ let requiredScore = 1;
21
+ if (task.required.length > 0) {
22
+ let wsum = 0;
23
+ let acc = 0;
24
+ for (const r of task.required) {
25
+ const w = r.weight ?? 1;
26
+ wsum += w;
27
+ acc += w * (capabilitySatisfies(model.capabilities, r) ? evFactor(r.key, r.group) : 0);
28
+ }
29
+ requiredScore = wsum > 0 ? acc / wsum : 1;
30
+ }
21
31
  if (task.preferred.length === 0)
22
32
  return clamp01(requiredScore);
23
33
  const preferredScore = task.preferred.filter((r) => capabilitySatisfies(model.capabilities, r)).length / task.preferred.length;
@@ -8,6 +8,7 @@
8
8
  * it, so high-sensitivity input can never reach a cloud provider without an explicit allowance.
9
9
  */
10
10
  import { capabilitySatisfies } from '../capabilities/evidence.js';
11
+ import { TIER_SCORE } from './dimensions.js';
11
12
  import { buildRequest } from './request.js';
12
13
  import { isExcluded } from './routingPrefs.js';
13
14
  function privacyFloor(task, p) {
@@ -78,6 +79,13 @@ export async function filterCandidates(input) {
78
79
  exclude(`context window ${candidate.model.contextWindow} < required ${task.minContextWindow}`);
79
80
  continue;
80
81
  }
82
+ // quality floor — exclude a model whose declared tier is below the task's floor. A model with NO
83
+ // quality tier is NOT excluded (metadata coverage is sparse; a floor is a preference gate, not a
84
+ // safety gate — excluding unrated models would silently empty a custom provider's pool).
85
+ if (task.qualityFloor !== undefined && candidate.model.quality?.tier !== undefined && TIER_SCORE[candidate.model.quality.tier] < TIER_SCORE[task.qualityFloor]) {
86
+ exclude(`model quality tier '${candidate.model.quality.tier}' is below the required floor '${task.qualityFloor}'`);
87
+ continue;
88
+ }
81
89
  // privacy (unconditional)
82
90
  if (requireLocal && provider.privacyClass !== 'local') {
83
91
  exclude(`privacy: high/undeclared sensitivity may not use cloud provider (privacyClass=${provider.privacyClass})`);
@@ -91,9 +99,24 @@ export async function filterCandidates(input) {
91
99
  exclude(`local providers disabled by policy`);
92
100
  continue;
93
101
  }
94
- // hard cost / latency constraints
102
+ // estimate a cheap, local (no-network) computation for built-in providers. Context-fit is ALWAYS
103
+ // enforced: an input that cannot fit a model's window must never be routed there merely because no
104
+ // cost/latency constraint was supplied. Cost/latency remain conditional on declared constraints. A
105
+ // third-party provider's estimate() could still throw; contain it so one bad candidate is excluded
106
+ // with a reason, not the whole route.
107
+ let estimate;
108
+ try {
109
+ estimate = await provider.estimate(buildRequest(input.template, modelId));
110
+ }
111
+ catch {
112
+ exclude(`estimate failed`);
113
+ continue;
114
+ }
115
+ if (!estimate.contextFits) {
116
+ exclude(`input does not fit model context window`);
117
+ continue;
118
+ }
95
119
  if (wantCostLatency) {
96
- const estimate = await provider.estimate(buildRequest(input.template, modelId));
97
120
  if (constraints?.maxCostUsd !== undefined && estimate.estCost && estimate.estCost.amount > constraints.maxCostUsd) {
98
121
  exclude(`estimated cost ${estimate.estCost.amount} > maxCostUsd ${constraints.maxCostUsd}`);
99
122
  continue;
@@ -102,10 +125,6 @@ export async function filterCandidates(input) {
102
125
  exclude(`estimated latency ${estimate.estLatencyMs.p50}ms > maxLatencyMs ${constraints.maxLatencyMs}`);
103
126
  continue;
104
127
  }
105
- if (!estimate.contextFits) {
106
- exclude(`input does not fit model context window`);
107
- continue;
108
- }
109
128
  }
110
129
  eligible.push(candidate);
111
130
  }
@@ -113,6 +113,8 @@ export function normalize(req, tasks, config) {
113
113
  };
114
114
  if (minContextWindow !== undefined)
115
115
  task.minContextWindow = minContextWindow;
116
+ if (def?.qualityFloor !== undefined)
117
+ task.qualityFloor = def.qualityFloor;
116
118
  if (output !== undefined)
117
119
  task.output = output;
118
120
  return { task, template };
@@ -80,7 +80,18 @@ export class Router {
80
80
  const history = this.deps.performance
81
81
  ? (pid, mid) => this.deps.performance.forTask(task.id, pid, mid)
82
82
  : undefined;
83
- const scored = scoreCandidates(eligible, task, config.weights, task.strategy, { ...(history ? { history } : {}), ...(req.routing ? { prefer: req.routing } : {}) });
83
+ // Per-provider score-weight overrides (ProviderConfig.weightOverrides), applied to that provider's
84
+ // candidates only. Built once here; empty ⇒ every provider scores under the base weights.
85
+ const weightOverridesByProvider = {};
86
+ for (const p of config.providers)
87
+ if (p.weightOverrides)
88
+ weightOverridesByProvider[p.id] = p.weightOverrides;
89
+ const hasOverrides = Object.keys(weightOverridesByProvider).length > 0;
90
+ const scored = scoreCandidates(eligible, task, config.weights, task.strategy, {
91
+ ...(history ? { history } : {}),
92
+ ...(req.routing ? { prefer: req.routing } : {}),
93
+ ...(hasOverrides ? { weightOverridesByProvider } : {}),
94
+ });
84
95
  const ranked = scored.map((s) => ({
85
96
  providerId: s.candidate.providerId,
86
97
  model: s.candidate.model.id,
@@ -142,6 +153,7 @@ export class Router {
142
153
  clock: this.clock,
143
154
  onAttempt,
144
155
  ...(template.stream && req.onDelta ? { onDelta: req.onDelta } : {}),
156
+ ...(template.stream && req.onStreamAbandoned ? { onStreamAbandoned: req.onStreamAbandoned } : {}),
145
157
  validate: (response) => validateResponse({ response, ...(template.output ? { output: template.output } : {}), ...(template.tools ? { tools: template.tools } : {}) }),
146
158
  ...(budget ? { budget, costOf } : {}),
147
159
  ...(this.deps.providerLimiter ? { providerLimiter: this.deps.providerLimiter } : {}),
@@ -176,7 +188,9 @@ export class Router {
176
188
  confidence = Math.min(1, confidence + 0.05);
177
189
  }
178
190
  }
179
- baseReport.belowConfidenceThreshold = confidence < config.minConfidence;
191
+ // The effective floor honors BOTH the config default and a stricter per-run constraints.minimumConfidence.
192
+ const effectiveMinConfidence = Math.max(config.minConfidence, req.constraints?.minimumConfidence ?? 0);
193
+ baseReport.belowConfidenceThreshold = confidence < effectiveMinConfidence;
180
194
  telemetry.emit({ type: 'route.result', ts: this.clock.now(), taskId: task.id, ok: true, confidence, totalLatencyMs: this.clock.now() - runStarted, fallbackCount });
181
195
  return { ok: true, response: fb.response, confidence, routing: baseReport };
182
196
  }
@@ -15,5 +15,8 @@ export interface ScoreOptions {
15
15
  /** User prefer routing — a SOFT nudge to the userPreference dimension (never a hard override). */
16
16
  prefer?: RoutingPreferences;
17
17
  history?: HistoryLookup;
18
+ /** Per-provider score-weight overrides (ProviderConfig.weightOverrides), merged over the base weights
19
+ * for that provider's candidates only. Absent → every provider uses the base weights. */
20
+ weightOverridesByProvider?: Record<string, Partial<ScoreWeights>>;
18
21
  }
19
22
  export declare function scoreCandidates(candidates: Candidate[], task: NormalizedTask, baseWeights: ScoreWeights, strategy: Strategy, options?: ScoreOptions): ScoredCandidate[];