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.
- package/CHANGELOG.md +63 -0
- package/dist/cli/cli.js +8 -1
- package/dist/cli/commands/cleanup.js +11 -3
- package/dist/cli/commands/doctor.js +1 -1
- package/dist/cli/commands/run.js +6 -0
- package/dist/cli/commands/skills.js +9 -2
- package/dist/cli/interactive/repl.js +12 -2
- package/dist/cli/interactive/session.d.ts +2 -0
- package/dist/cli/interactive/session.js +6 -2
- package/dist/config/schema.js +19 -1
- package/dist/conversations/conversations.d.ts +6 -1
- package/dist/conversations/conversations.js +15 -8
- package/dist/core/fallback/fallback.d.ts +7 -0
- package/dist/core/fallback/fallback.js +15 -2
- package/dist/core/health/monitor.d.ts +6 -0
- package/dist/core/health/monitor.js +15 -2
- package/dist/core/router/confidence.js +10 -5
- package/dist/core/router/dimensions.d.ts +3 -1
- package/dist/core/router/dimensions.js +15 -5
- package/dist/core/router/filter.js +25 -6
- package/dist/core/router/normalize.js +2 -0
- package/dist/core/router/router.js +16 -2
- package/dist/core/router/scorer.d.ts +3 -0
- package/dist/core/router/scorer.js +17 -2
- package/dist/discovery/openapi.js +3 -2
- package/dist/executions/agentTasks.d.ts +4 -4
- package/dist/generation/generateAdapter.js +3 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/mcp/protocol.js +4 -1
- package/dist/memory/bm25.d.ts +7 -0
- package/dist/memory/bm25.js +17 -1
- package/dist/memory/memory.d.ts +7 -1
- package/dist/memory/memory.js +18 -4
- package/dist/plugin/ai.d.ts +6 -0
- package/dist/plugin/ai.js +17 -2
- package/dist/providers/estimate.d.ts +25 -0
- package/dist/providers/estimate.js +55 -0
- package/dist/providers/factory.d.ts +3 -0
- package/dist/providers/factory.js +26 -5
- package/dist/providers/httpClient.js +4 -0
- package/dist/providers/httpProvider.js +4 -3
- package/dist/providers/mock/mockProvider.js +4 -3
- package/dist/runtime/config.d.ts +4 -3
- package/dist/runtime/config.js +13 -22
- package/dist/runtime/events.d.ts +6 -0
- package/dist/runtime/runtime.d.ts +3 -2
- package/dist/runtime/runtime.js +17 -7
- package/dist/runtime/types.d.ts +2 -1
- package/dist/store/area.d.ts +1 -1
- package/dist/store/area.js +34 -10
- package/dist/store/crypto.d.ts +27 -13
- package/dist/store/crypto.js +101 -23
- package/dist/store/errors.d.ts +11 -0
- package/dist/store/errors.js +14 -0
- package/dist/store/store.d.ts +21 -1
- package/dist/store/store.js +74 -19
- package/dist/telemetry/sinks/file.js +4 -2
- package/dist/telemetry/sinks/otlp.d.ts +12 -2
- package/dist/telemetry/sinks/otlp.js +39 -24
- package/dist/telemetry/telemetry.d.ts +5 -0
- package/dist/telemetry/telemetry.js +4 -0
- package/dist/tools/builtins/shell.d.ts +30 -3
- package/dist/tools/builtins/shell.js +218 -7
- package/dist/tools/untrusted.d.ts +1 -1
- package/dist/tools/untrusted.js +5 -3
- package/dist/types.d.ts +14 -0
- package/dist/verification/verify.js +10 -3
- package/docs/GUIDE.md +66 -1
- package/docs/README.md +1 -1
- package/docs/architecture.md +5 -1
- package/docs/router.md +1 -1
- package/docs/security.md +26 -7
- 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.
|
|
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
|
-
|
|
78
|
-
|
|
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)
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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): ${
|
|
206
|
-
`cost budget (AI_MAX_COST_USD): ${
|
|
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
|
};
|
package/dist/config/schema.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
meta.
|
|
45
|
-
|
|
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,
|
|
39
|
-
: await executeOnce(provider, request,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
+
continue; // pinned/unknown-capability model: penalize
|
|
14
19
|
const cap = getCapability(model.capabilities, r.group, r.key);
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
evFactor = sum /
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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[];
|