ai-runtime-engine 2.9.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 +108 -0
- package/README.md +30 -0
- package/dist/agents/admit.d.ts +9 -1
- package/dist/agents/admit.js +10 -2
- package/dist/agents/envelope.d.ts +21 -0
- package/dist/agents/envelope.js +39 -5
- package/dist/agents/finding.d.ts +9 -3
- package/dist/agents/finding.js +14 -3
- package/dist/agents/worker.d.ts +3 -0
- package/dist/agents/worker.js +4 -1
- 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/orchestration/orchestrator.d.ts +2 -1
- package/dist/orchestration/planner.d.ts +2 -1
- 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 +14 -23
- package/dist/runtime/events.d.ts +6 -0
- package/dist/runtime/runtime.d.ts +43 -5
- package/dist/runtime/runtime.js +133 -25
- package/dist/runtime/types.d.ts +8 -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
|
@@ -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[];
|
|
@@ -13,10 +13,25 @@ function preferenceValue(candidate, options) {
|
|
|
13
13
|
return options.preferences?.[candidate.providerId];
|
|
14
14
|
}
|
|
15
15
|
export function scoreCandidates(candidates, task, baseWeights, strategy, options = {}) {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// Resolve strategy-adjusted weights per PROVIDER: a provider with a weightOverrides block scores its
|
|
17
|
+
// candidates under (base ⊕ override); everyone else shares the base. Memoized so it is computed once
|
|
18
|
+
// per provider, not once per candidate.
|
|
19
|
+
const overrides = options.weightOverridesByProvider;
|
|
20
|
+
const weightCache = new Map();
|
|
21
|
+
const weightsFor = (providerId) => {
|
|
22
|
+
let entry = weightCache.get(providerId);
|
|
23
|
+
if (!entry) {
|
|
24
|
+
const override = overrides?.[providerId];
|
|
25
|
+
const merged = override ? { ...baseWeights, ...override } : baseWeights;
|
|
26
|
+
const w = strategyWeights(strategy, merged);
|
|
27
|
+
entry = { w, total: weightSum(w) || 1 };
|
|
28
|
+
weightCache.set(providerId, entry);
|
|
29
|
+
}
|
|
30
|
+
return entry;
|
|
31
|
+
};
|
|
18
32
|
const scored = candidates.map((candidate) => {
|
|
19
33
|
const m = candidate.model;
|
|
34
|
+
const { w, total } = weightsFor(candidate.providerId);
|
|
20
35
|
const observed = options.history?.(candidate.providerId, m.id);
|
|
21
36
|
const breakdown = {
|
|
22
37
|
capabilityFit: dim.capabilityFit(m, task),
|
|
@@ -57,15 +57,16 @@ export function analyzeOpenApi(spec) {
|
|
|
57
57
|
// Suggest a config only when we found an OpenAI-compatible chat endpoint and a base URL.
|
|
58
58
|
if (wireShape === 'openai' && baseUrl) {
|
|
59
59
|
const base = baseUrl.replace(/\/chat\/completions$/i, '').replace(/\/+$/, '');
|
|
60
|
+
// No `models` is suggested: 'openai-compatible' has no built-in model catalog, so `models: 'auto'`
|
|
61
|
+
// would resolve to zero models (a load-time CONFIG error). The user must list models explicitly.
|
|
60
62
|
analysis.suggestedProviderConfig = {
|
|
61
63
|
id: 'openapi-provider',
|
|
62
64
|
kind: 'openai-compatible',
|
|
63
65
|
baseUrl: base,
|
|
64
66
|
apiKeyEnv: 'OPENAPI_PROVIDER_API_KEY',
|
|
65
67
|
wireShape: 'openai',
|
|
66
|
-
models: 'auto',
|
|
67
68
|
};
|
|
68
|
-
notes.push('Suggested an openai-compatible provider config — review it, set models, and supply the key via OPENAPI_PROVIDER_API_KEY.');
|
|
69
|
+
notes.push('Suggested an openai-compatible provider config — review it, set `models` (this kind has no built-in catalog), and supply the key via OPENAPI_PROVIDER_API_KEY.');
|
|
69
70
|
}
|
|
70
71
|
return analysis;
|
|
71
72
|
}
|
|
@@ -213,11 +213,11 @@ export declare const persistedAgentTask: z.ZodObject<{
|
|
|
213
213
|
total: z.ZodNumber;
|
|
214
214
|
succeeded: z.ZodNumber;
|
|
215
215
|
}, "strict", z.ZodTypeAny, {
|
|
216
|
-
succeeded: number;
|
|
217
216
|
total: number;
|
|
218
|
-
}, {
|
|
219
217
|
succeeded: number;
|
|
218
|
+
}, {
|
|
220
219
|
total: number;
|
|
220
|
+
succeeded: number;
|
|
221
221
|
}>;
|
|
222
222
|
callsReserved: z.ZodNumber;
|
|
223
223
|
callsUsed: z.ZodNumber;
|
|
@@ -366,8 +366,8 @@ export declare const persistedAgentTask: z.ZodObject<{
|
|
|
366
366
|
callsUsed: number;
|
|
367
367
|
agentId: string;
|
|
368
368
|
innerSteps: {
|
|
369
|
-
succeeded: number;
|
|
370
369
|
total: number;
|
|
370
|
+
succeeded: number;
|
|
371
371
|
};
|
|
372
372
|
toolCallsUsed: number;
|
|
373
373
|
findings: z.objectOutputType<{
|
|
@@ -493,8 +493,8 @@ export declare const persistedAgentTask: z.ZodObject<{
|
|
|
493
493
|
callsUsed: number;
|
|
494
494
|
agentId: string;
|
|
495
495
|
innerSteps: {
|
|
496
|
-
succeeded: number;
|
|
497
496
|
total: number;
|
|
497
|
+
succeeded: number;
|
|
498
498
|
};
|
|
499
499
|
toolCallsUsed: number;
|
|
500
500
|
findings: z.objectInputType<{
|
|
@@ -22,7 +22,9 @@ export function generateProviderConfig(analysis, overrides = {}) {
|
|
|
22
22
|
...(overrides.models ? { models: overrides.models } : {}),
|
|
23
23
|
};
|
|
24
24
|
if (config.models === 'auto' && !overrides.models) {
|
|
25
|
-
//
|
|
25
|
+
// `models: 'auto'` resolves to the kind's built-in catalog, which is empty for baseUrl-based kinds
|
|
26
|
+
// (openai-compatible/custom) — so for a generated adapter it would fail at load. Drop it and require
|
|
27
|
+
// an explicit model list (buildProvider raises a clear CONFIG error if the user leaves it unset).
|
|
26
28
|
delete config.models;
|
|
27
29
|
}
|
|
28
30
|
return config;
|
package/dist/index.d.ts
CHANGED
|
@@ -85,7 +85,9 @@ export { RuntimeStore, STORE_VERSION } from './store/store.js';
|
|
|
85
85
|
export type { RuntimeStoreOptions, StorePaths } from './store/store.js';
|
|
86
86
|
export { FileArea, NullArea } from './store/area.js';
|
|
87
87
|
export type { Area, IntegrityIssue, ContentCodec } from './store/area.js';
|
|
88
|
-
export { makeCodec, deriveKey, encryptString, decryptString, ENVELOPE_PREFIX } from './store/crypto.js';
|
|
88
|
+
export { makeCodec, deriveKey, encryptString, decryptString, encryptStringV2, ENVELOPE_PREFIX, ENVELOPE_PREFIX_V2 } from './store/crypto.js';
|
|
89
|
+
export { StoreDecryptError } from './store/errors.js';
|
|
90
|
+
export type { StoreDecryptCode } from './store/errors.js';
|
|
89
91
|
export { resolveHome, projectId, repositoryId, organizationId, findRepoRoot } from './store/paths.js';
|
|
90
92
|
export { deriveCapabilities, deriveCapabilitiesOffline, candidatesFrom, candidateSlate, DERIVE_MAX_CANDIDATES, DERIVE_MAX_IDS } from './runtime/planning/deriveCapabilities.js';
|
|
91
93
|
export type { CapabilityCandidate, DeriveCapabilitiesInput, DeriveCapabilitiesResult } from './runtime/planning/deriveCapabilities.js';
|
|
@@ -145,7 +147,7 @@ export { resolveInJail, JailError } from './tools/jail.js';
|
|
|
145
147
|
export { defaultRunner, safeEnv } from './tools/runner.js';
|
|
146
148
|
export type { CommandRunner, RunOptions, RunResult } from './tools/runner.js';
|
|
147
149
|
export { filesystemTool } from './tools/builtins/filesystem.js';
|
|
148
|
-
export { shellTool, createShellTool, isDestructive } from './tools/builtins/shell.js';
|
|
150
|
+
export { shellTool, createShellTool, isDestructive, isEvalCapable } from './tools/builtins/shell.js';
|
|
149
151
|
export { gitTool, createGitTool } from './tools/builtins/git.js';
|
|
150
152
|
export { wrapUntrusted, looksLikeInjection } from './tools/untrusted.js';
|
|
151
153
|
export { SkillRegistry } from './skills/registry.js';
|
package/dist/index.js
CHANGED
|
@@ -66,7 +66,8 @@ export { deriveAccessState, buildProviderViews, ProviderViewCache } from './runt
|
|
|
66
66
|
// ── Runtime (Phase 3) — local store, conversations, memory ──
|
|
67
67
|
export { RuntimeStore, STORE_VERSION } from './store/store.js';
|
|
68
68
|
export { FileArea, NullArea } from './store/area.js';
|
|
69
|
-
export { makeCodec, deriveKey, encryptString, decryptString, ENVELOPE_PREFIX } from './store/crypto.js';
|
|
69
|
+
export { makeCodec, deriveKey, encryptString, decryptString, encryptStringV2, ENVELOPE_PREFIX, ENVELOPE_PREFIX_V2 } from './store/crypto.js';
|
|
70
|
+
export { StoreDecryptError } from './store/errors.js';
|
|
70
71
|
export { resolveHome, projectId, repositoryId, organizationId, findRepoRoot } from './store/paths.js';
|
|
71
72
|
// ── Capability-first planning (Phase 3.3) — offline BM25 derivation, one model rung under it ──
|
|
72
73
|
export { deriveCapabilities, deriveCapabilitiesOffline, candidatesFrom, candidateSlate, DERIVE_MAX_CANDIDATES, DERIVE_MAX_IDS } from './runtime/planning/deriveCapabilities.js';
|
|
@@ -107,7 +108,7 @@ export { resolvePermissions } from './tools/permissions.js';
|
|
|
107
108
|
export { resolveInJail, JailError } from './tools/jail.js';
|
|
108
109
|
export { defaultRunner, safeEnv } from './tools/runner.js';
|
|
109
110
|
export { filesystemTool } from './tools/builtins/filesystem.js';
|
|
110
|
-
export { shellTool, createShellTool, isDestructive } from './tools/builtins/shell.js';
|
|
111
|
+
export { shellTool, createShellTool, isDestructive, isEvalCapable } from './tools/builtins/shell.js';
|
|
111
112
|
export { gitTool, createGitTool } from './tools/builtins/git.js';
|
|
112
113
|
export { wrapUntrusted, looksLikeInjection } from './tools/untrusted.js';
|
|
113
114
|
export { SkillRegistry } from './skills/registry.js';
|
package/dist/mcp/protocol.js
CHANGED
|
@@ -100,7 +100,10 @@ export function sanitizeSchema(raw, budget = { nodes: MCP_SCHEMA_MAX_NODES }, de
|
|
|
100
100
|
}
|
|
101
101
|
if (typeof raw !== 'object')
|
|
102
102
|
return undefined;
|
|
103
|
-
|
|
103
|
+
// Null-prototype so a server key literally named `__proto__` (or `constructor`/`prototype`) becomes an
|
|
104
|
+
// OWN data property instead of hitting the inherited setter — which would silently drop it or mutate the
|
|
105
|
+
// object's prototype. JSON.stringify of a null-proto object is unaffected.
|
|
106
|
+
const out = Object.create(null);
|
|
104
107
|
for (const [k, v] of Object.entries(raw).slice(0, 64)) {
|
|
105
108
|
const key = clampText(k, 64);
|
|
106
109
|
if (!key)
|
package/dist/memory/bm25.d.ts
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* deterministically. An `EmbeddingProvider` seam (memory/memory.ts) lets real embeddings replace this
|
|
4
4
|
* later without changing the retrieval API.
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* Unicode-aware tokenizer. Matches letter/number runs across ALL scripts (`\p{L}\p{N}`), so accented
|
|
8
|
+
* Latin, Cyrillic, Arabic, Korean, etc. are searchable — the old `[a-z0-9]` dropped every non-ASCII
|
|
9
|
+
* character silently. A run containing an unsegmented-CJK character is split into single characters
|
|
10
|
+
* (length-1 allowed, since one Han character is meaningful); all other tokens keep the ≥2-length filter
|
|
11
|
+
* and the English stopword list. Pure-ASCII input tokenizes byte-identically to the previous behavior.
|
|
12
|
+
*/
|
|
6
13
|
export declare function tokenize(text: string): string[];
|
|
7
14
|
export interface Bm25Doc {
|
|
8
15
|
id: string;
|
package/dist/memory/bm25.js
CHANGED
|
@@ -6,10 +6,26 @@
|
|
|
6
6
|
const STOPWORDS = new Set([
|
|
7
7
|
'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be', 'been', 'to', 'of', 'in', 'on', 'for', 'with', 'as', 'by', 'at', 'this', 'that', 'these', 'those', 'it', 'its', 'i', 'you', 'we', 'they', 'do', 'does', 'did', 'has', 'have', 'had', 'will', 'would', 'should', 'can', 'could',
|
|
8
8
|
]);
|
|
9
|
+
// Chinese/Japanese scripts (Han/Hiragana/Katakana) are NOT space-delimited, so a whole clause matches as
|
|
10
|
+
// one run; splitting it into single characters gives BM25 something to overlap on. Korean (Hangul) and
|
|
11
|
+
// every other script ARE space-delimited, so their words tokenize whole (kept out of this set).
|
|
12
|
+
const CJK_UNSEGMENTED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
|
|
13
|
+
/**
|
|
14
|
+
* Unicode-aware tokenizer. Matches letter/number runs across ALL scripts (`\p{L}\p{N}`), so accented
|
|
15
|
+
* Latin, Cyrillic, Arabic, Korean, etc. are searchable — the old `[a-z0-9]` dropped every non-ASCII
|
|
16
|
+
* character silently. A run containing an unsegmented-CJK character is split into single characters
|
|
17
|
+
* (length-1 allowed, since one Han character is meaningful); all other tokens keep the ≥2-length filter
|
|
18
|
+
* and the English stopword list. Pure-ASCII input tokenizes byte-identically to the previous behavior.
|
|
19
|
+
*/
|
|
9
20
|
export function tokenize(text) {
|
|
10
21
|
const out = [];
|
|
11
|
-
for (const m of text.toLowerCase().matchAll(/[
|
|
22
|
+
for (const m of text.toLowerCase().matchAll(/[\p{L}\p{N}]+/gu)) {
|
|
12
23
|
const tok = m[0];
|
|
24
|
+
if (CJK_UNSEGMENTED.test(tok)) {
|
|
25
|
+
for (const ch of tok)
|
|
26
|
+
out.push(ch); // one meaningful unit per character
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
13
29
|
if (tok.length >= 2 && !STOPWORDS.has(tok))
|
|
14
30
|
out.push(tok);
|
|
15
31
|
}
|
package/dist/memory/memory.d.ts
CHANGED
|
@@ -59,8 +59,14 @@ export declare class MemoryStore {
|
|
|
59
59
|
private readonly embedder?;
|
|
60
60
|
private readonly clock;
|
|
61
61
|
private counter;
|
|
62
|
-
/** Per-instance cache of fact-text → vector, so a stable fact isn't re-embedded on every query.
|
|
62
|
+
/** Per-instance cache of fact-text → vector, so a stable fact isn't re-embedded on every query. Bounded
|
|
63
|
+
* (see cacheVec) so a long-lived host process cannot grow it without limit. */
|
|
63
64
|
private readonly vecCache;
|
|
65
|
+
/** Cap on distinct cached vectors. On overflow the cache is cleared wholesale — the only cost is
|
|
66
|
+
* re-embedding, never correctness. A session touching more than this many distinct facts is rare. */
|
|
67
|
+
private static readonly VEC_CACHE_MAX;
|
|
68
|
+
/** Store a vector, clearing the cache first if it is at capacity (bounded growth). */
|
|
69
|
+
private cacheVec;
|
|
64
70
|
constructor(store: RuntimeStore, clock?: Clock, embedder?: EmbeddingProvider | undefined);
|
|
65
71
|
get enabled(): boolean;
|
|
66
72
|
private area;
|
package/dist/memory/memory.js
CHANGED
|
@@ -25,8 +25,18 @@ export class MemoryStore {
|
|
|
25
25
|
embedder;
|
|
26
26
|
clock;
|
|
27
27
|
counter = 0;
|
|
28
|
-
/** Per-instance cache of fact-text → vector, so a stable fact isn't re-embedded on every query.
|
|
28
|
+
/** Per-instance cache of fact-text → vector, so a stable fact isn't re-embedded on every query. Bounded
|
|
29
|
+
* (see cacheVec) so a long-lived host process cannot grow it without limit. */
|
|
29
30
|
vecCache = new Map();
|
|
31
|
+
/** Cap on distinct cached vectors. On overflow the cache is cleared wholesale — the only cost is
|
|
32
|
+
* re-embedding, never correctness. A session touching more than this many distinct facts is rare. */
|
|
33
|
+
static VEC_CACHE_MAX = 2000;
|
|
34
|
+
/** Store a vector, clearing the cache first if it is at capacity (bounded growth). */
|
|
35
|
+
cacheVec(text, vec) {
|
|
36
|
+
if (this.vecCache.size >= MemoryStore.VEC_CACHE_MAX && !this.vecCache.has(text))
|
|
37
|
+
this.vecCache.clear();
|
|
38
|
+
this.vecCache.set(text, vec);
|
|
39
|
+
}
|
|
30
40
|
constructor(store, clock = systemClock, embedder) {
|
|
31
41
|
this.store = store;
|
|
32
42
|
this.embedder = embedder;
|
|
@@ -172,7 +182,7 @@ export class MemoryStore {
|
|
|
172
182
|
// Cache only USABLE vectors — a degenerate ([] or non-finite) embedding must never be treated as
|
|
173
183
|
// valid nor poison the cache (a poisoned entry silently excludes that fact for the rest of the session).
|
|
174
184
|
need.forEach((t, i) => { if (isUsableVec(vecs[i]))
|
|
175
|
-
this.
|
|
185
|
+
this.cacheVec(t, vecs[i]); });
|
|
176
186
|
}
|
|
177
187
|
const [queryVec] = await this.embedder.embed([query]);
|
|
178
188
|
const docs = candidates.filter((c) => this.vecCache.has(c.text)).map((c) => ({ id: c.id, vec: this.vecCache.get(c.text) }));
|
|
@@ -197,6 +207,8 @@ export class MemoryStore {
|
|
|
197
207
|
removeCascade(id) {
|
|
198
208
|
const target = this.get(id);
|
|
199
209
|
const inheritor = target?.supersededBy; // if the removed record was itself superseded, heirs re-link to its head
|
|
210
|
+
// Invalidate the cached vector for the removed record's text (if any) — and report whether we did.
|
|
211
|
+
const cacheEvicted = target ? this.vecCache.delete(target.text) : false;
|
|
200
212
|
let primary = false;
|
|
201
213
|
let relationships = 0;
|
|
202
214
|
for (const scope of this.physicalScopes()) {
|
|
@@ -232,8 +244,10 @@ export class MemoryStore {
|
|
|
232
244
|
}
|
|
233
245
|
}
|
|
234
246
|
}
|
|
235
|
-
//
|
|
236
|
-
|
|
247
|
+
// Honest audit: `indexes` reflects the removal only when the primary was actually removed (indexes are
|
|
248
|
+
// query-time-derived, so they change iff the record did); `cache` reports whether a vector was evicted.
|
|
249
|
+
// A delete of a nonexistent id now reports all-false rather than a hardcoded success.
|
|
250
|
+
return { id, primary, indexes: primary, relationships, cache: cacheEvicted };
|
|
237
251
|
}
|
|
238
252
|
/** Remove expired records with full cascade, under the lock. Returns removed ids. */
|
|
239
253
|
purgeExpired(now = this.clock.now()) {
|
|
@@ -43,7 +43,8 @@ export interface OrchestrateInput {
|
|
|
43
43
|
routing?: RoutingPreferences;
|
|
44
44
|
/** Phase 22: run the phases that fit the call budget and pause resumably (vs. the default notify-and-wait). */
|
|
45
45
|
partial?: boolean;
|
|
46
|
-
/** Phase 3.1: pre-rendered action-capability snapshot for the planner prompt
|
|
46
|
+
/** Phase 3.1: pre-rendered, fenced action-capability snapshot for the planner prompt. Present by
|
|
47
|
+
* default from 3.0.0; `runtime.capabilities.catalog: false` removes it. */
|
|
47
48
|
capabilityCatalog?: string;
|
|
48
49
|
/** Phase 3.3: pre-rendered "Required capabilities" block for the planner prompt (opt-in). */
|
|
49
50
|
requiredCapabilities?: string;
|
|
@@ -18,7 +18,8 @@ export interface PlannerInput {
|
|
|
18
18
|
reason?: string;
|
|
19
19
|
/** Observations from a prior attempt, to inform a replan. */
|
|
20
20
|
priorObservations?: string[];
|
|
21
|
-
/** Phase 3.1: a pre-rendered, capped, fenced action-capability snapshot
|
|
21
|
+
/** Phase 3.1: a pre-rendered, capped, fenced action-capability snapshot (fenced for real as of
|
|
22
|
+
* 3.0.0, and present by default from it). Absent ⇒ the prompt is
|
|
22
23
|
* byte-identical to 2.3.0 (the catalog flag is off by default). */
|
|
23
24
|
capabilityCatalog?: string;
|
|
24
25
|
/** Phase 3.3: a pre-rendered, clamped block naming the capabilities the goal was derived to need and
|
package/dist/plugin/ai.d.ts
CHANGED
|
@@ -70,6 +70,12 @@ export declare class AI {
|
|
|
70
70
|
providers(): ProviderInfo[];
|
|
71
71
|
tasksList(): TaskDefinition[];
|
|
72
72
|
telemetryEvents(): TelemetryEvent[];
|
|
73
|
+
/**
|
|
74
|
+
* Release AI-level resources. Today: flush any batching telemetry sink (e.g. OTLP) so a short-lived
|
|
75
|
+
* process does not drop events buffered below the batch threshold. Safe to call more than once, and a
|
|
76
|
+
* failing flush never throws (telemetry must not fail shutdown). Runtime.close() calls this.
|
|
77
|
+
*/
|
|
78
|
+
close(): Promise<void>;
|
|
73
79
|
/** Run each provider's healthCheck(), seed the monitor, and return the statuses (for `doctor`). */
|
|
74
80
|
checkHealth(): Promise<HealthStatus[]>;
|
|
75
81
|
/** Discover provider identity, models, and capabilities (for `providers`/`models`/`capabilities`). */
|