@staix/agent-hub 0.12.7 → 0.12.9
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 +19 -0
- package/LICENSES/Apache-2.0.txt +204 -0
- package/THIRD_PARTY_NOTICES.md +19 -0
- package/docs/cooperbench.md +30 -2
- package/docs/events.md +32 -0
- package/docs/operations.md +26 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +10 -0
- package/docs/specs/2026-09-20-pi-local-models-design.md +1 -1
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +309 -0
- package/package.json +4 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4 -2
- package/src/adapters/acp.ts +26 -9
- package/src/adapters/codex-appserver.ts +2 -2
- package/src/adapters/local-worker.ts +138 -26
- package/src/adapters/pi.ts +8 -0
- package/src/hub/daemon.ts +59 -17
- package/src/hub/events.ts +5 -0
- package/src/hub/inference.ts +20 -0
- package/src/hub/progress.ts +251 -0
- package/src/hub/routing.ts +4 -0
- package/src/local/tools.ts +9 -0
- package/src/models/relay.ts +136 -22
- package/src/models/route/advisor.ts +102 -0
- package/src/models/route/config.ts +41 -0
- package/src/models/route/escalation.ts +82 -0
- package/src/models/route/judge.ts +23 -0
- package/src/models/route/labels.ts +167 -0
- package/src/models/route/normalize.ts +103 -0
- package/src/models/route/plan-execute.ts +52 -0
- package/src/models/route/prompts.ts +8 -0
- package/src/models/route/relay-selector.ts +102 -0
- package/src/models/route/runtime.ts +156 -0
- package/src/models/route/signals.ts +234 -0
- package/src/models/route/stage.ts +93 -0
- package/src/models/route/state.ts +54 -0
- package/src/models/route/text.ts +61 -0
- package/src/omniroute/client.ts +8 -1
- package/templates/routing.toml +28 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ModelBackend, ModelRelayOptions, RelayRequest } from "../relay.ts";
|
|
2
|
+
import { normalizeConversation } from "./normalize.ts";
|
|
3
|
+
import { extractToolSignals } from "./signals.ts";
|
|
4
|
+
import { selectStage, type StageState } from "./stage.ts";
|
|
5
|
+
import { SessionState } from "./state.ts";
|
|
6
|
+
|
|
7
|
+
type RelaySelectorOptions = Pick<ModelRelayOptions, "mlx" | "selectBackend" | "routeSessionKey" | "onRoute" | "allowedDGXmodels">;
|
|
8
|
+
type RouteSource = "override" | "dimensions" | "hold" | "classifier" | "default";
|
|
9
|
+
type RelayRouteEvent = { route: "hub/auto"; tier: string; source: RouteSource; score: number; ms: number };
|
|
10
|
+
type EstimateInputTokens = (messages: RelayRequest["messages"], tools?: unknown[]) => number;
|
|
11
|
+
|
|
12
|
+
/** Stage selection for the relay's virtual `hub/auto` model. */
|
|
13
|
+
export class AutoRouteSelector {
|
|
14
|
+
private readonly states = new SessionState<StageState>(512, 60 * 60_000);
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly options: RelaySelectorOptions,
|
|
18
|
+
private readonly defaultBackend: ModelBackend,
|
|
19
|
+
private readonly mlxAlias: string,
|
|
20
|
+
private readonly estimateInputTokens: EstimateInputTokens,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async select(body: RelayRequest): Promise<ModelBackend> {
|
|
24
|
+
const started = performance.now();
|
|
25
|
+
let sessionKey: string | undefined;
|
|
26
|
+
try {
|
|
27
|
+
const key = this.options.routeSessionKey?.(body);
|
|
28
|
+
if (typeof key === "string" && key.length > 0 && key.length <= 512) sessionKey = key;
|
|
29
|
+
} catch { /* host identity lookup is fail-open */ }
|
|
30
|
+
const priorState = sessionKey ? this.states.get(sessionKey) ?? { capableHoldTurnsRemaining: 0 } : { capableHoldTurnsRemaining: 0 };
|
|
31
|
+
|
|
32
|
+
let backend = this.defaultBackend;
|
|
33
|
+
let tier = this.aliasOf(backend);
|
|
34
|
+
let source: RouteSource = "default";
|
|
35
|
+
let score = 0;
|
|
36
|
+
try {
|
|
37
|
+
const signals = extractToolSignals(normalizeConversation(body));
|
|
38
|
+
const decision = selectStage(signals, { mode: "efficient_first", confidenceThreshold: 0.5, capableHoldTurns: 2 }, priorState);
|
|
39
|
+
if (sessionKey) this.states.set(sessionKey, decision.state);
|
|
40
|
+
score = decision.score;
|
|
41
|
+
source = this.sourceOf(decision.source);
|
|
42
|
+
const selectedTier = decision.tier ?? decision.defaultTier;
|
|
43
|
+
const stagedBackend = this.stageBackend(selectedTier, body);
|
|
44
|
+
if (stagedBackend) {
|
|
45
|
+
backend = stagedBackend;
|
|
46
|
+
tier = this.aliasOf(stagedBackend);
|
|
47
|
+
} else {
|
|
48
|
+
source = "default";
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
source = "default";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.options.selectBackend) {
|
|
55
|
+
try {
|
|
56
|
+
const override = await this.options.selectBackend(body);
|
|
57
|
+
if (this.isAllowed(override)) {
|
|
58
|
+
backend = override;
|
|
59
|
+
tier = this.aliasOf(override);
|
|
60
|
+
source = "override";
|
|
61
|
+
}
|
|
62
|
+
} catch { /* deterministic stage choice is the fail-open route */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.emit({ route: "hub/auto", tier, source, score, ms: Math.max(0, performance.now() - started) });
|
|
66
|
+
return backend;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private stageBackend(tier: "capable" | "efficient", body: RelayRequest): ModelBackend | undefined {
|
|
70
|
+
if (tier === "capable") return "dgx/coding" in this.options.allowedDGXmodels ? { kind: "dgx", alias: "dgx/coding" } : undefined;
|
|
71
|
+
if (this.options.mlx && this.mlxFitsBudget(body)) return { kind: "mlx", alias: this.mlxAlias };
|
|
72
|
+
return "dgx/fast" in this.options.allowedDGXmodels ? { kind: "dgx", alias: "dgx/fast" } : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private mlxFitsBudget(body: RelayRequest): boolean {
|
|
76
|
+
const context = Math.min(8192, this.options.mlx?.contextWindow ?? 8192);
|
|
77
|
+
const maxOutput = this.options.mlx?.maxTokens ?? 2048;
|
|
78
|
+
const output = body.max_tokens ?? maxOutput;
|
|
79
|
+
const input = this.estimateInputTokens(body.messages, body.tools);
|
|
80
|
+
const inputLimit = this.options.mlx?.maxInputTokens ?? (this.options.mlx?.provider === "ollama" ? 6000 : 16_000);
|
|
81
|
+
return Number.isInteger(output) && output > 0 && output <= maxOutput && input <= inputLimit && input + output <= context;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private isAllowed(backend: ModelBackend): boolean {
|
|
85
|
+
return backend.kind === "mlx" ? !!this.options.mlx : backend.kind === "dgx" && backend.alias in this.options.allowedDGXmodels;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private aliasOf(backend: ModelBackend): string {
|
|
89
|
+
return backend.kind === "mlx" ? backend.alias ?? this.mlxAlias : backend.alias;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private sourceOf(source: string): RouteSource {
|
|
93
|
+
if (source === "capable_hold") return "hold";
|
|
94
|
+
if (source === "override" || source === "dimensions") return source;
|
|
95
|
+
if (source === "llm-classifier") return "classifier";
|
|
96
|
+
return "default";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private emit(event: RelayRouteEvent): void {
|
|
100
|
+
try { this.options.onRoute?.(event); } catch { /* observation cannot fail routing */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { ChatMessage, ChatResult } from "../../omniroute/client.ts";
|
|
2
|
+
import { AdvisorGate, buildAdvisorJudgeRequest, redoFeedback } from "./advisor.ts";
|
|
3
|
+
import type { HubRoute } from "./config.ts";
|
|
4
|
+
import { buildEscalationJudgeRequest, parseEscalationVerdict } from "./escalation.ts";
|
|
5
|
+
import { normalizeConversation } from "./normalize.ts";
|
|
6
|
+
import { extractToolSignals } from "./signals.ts";
|
|
7
|
+
import { RouteLabelTracker, type RouteLabelEvent, type RouteTurnContext, type RouteTurnOutcome } from "./labels.ts";
|
|
8
|
+
import { dimensionsFromSignal, selectStage, type StageState } from "./stage.ts";
|
|
9
|
+
import { EscalationState, SessionState } from "./state.ts";
|
|
10
|
+
import { parseAdvisorVerdict } from "./text.ts";
|
|
11
|
+
import { planExecutePhase, type PlanExecuteState } from "./plan-execute.ts";
|
|
12
|
+
|
|
13
|
+
export interface RouteEvent {
|
|
14
|
+
route: string; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default"; score: number; ms: number;
|
|
15
|
+
decision?: string; turn?: string; task?: number; pii?: boolean; severity?: number; spinning?: number; exploring?: number; production?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface AdvisorEvent { route: string; trigger: string; verdict: "approve" | "redo" | "failed"; discardedChars: number }
|
|
18
|
+
interface RouteState { fingerprint: string; stage: StageState; plan: PlanExecuteState; escalation: EscalationState; advisor: AdvisorGate }
|
|
19
|
+
export interface RouteHost {
|
|
20
|
+
execute: (model: string, messages: ChatMessage[], judge: boolean, signal: AbortSignal, maxTokens?: number) => Promise<ChatResult>;
|
|
21
|
+
onCampus: () => Promise<boolean>;
|
|
22
|
+
onRoute?: (event: RouteEvent) => void;
|
|
23
|
+
onRouteOutcome?: (event: RouteLabelEvent) => void;
|
|
24
|
+
onAdvisor?: (event: AdvisorEvent) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Transport stays in the host. Optional judge work has one deadline and five-minute failure backoff. */
|
|
28
|
+
export class HubRouteRuntime {
|
|
29
|
+
private readonly states = new SessionState<RouteState>();
|
|
30
|
+
private readonly labels: RouteLabelTracker;
|
|
31
|
+
private judgeOffUntil = 0;
|
|
32
|
+
constructor(private readonly host: RouteHost, private readonly judgeTimeoutMs = 8000) {
|
|
33
|
+
this.labels = new RouteLabelTracker(event => this.host.onRouteOutcome?.(event));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beginTurn(context: RouteTurnContext): void { try { this.labels.beginTurn(context); } catch { /* labels are optional telemetry */ } }
|
|
37
|
+
|
|
38
|
+
endTurn(turnId: string, outcome: RouteTurnOutcome): void {
|
|
39
|
+
try { this.labels.endTurn(turnId, outcome, event => this.host.onRouteOutcome?.(event)); } catch { /* labels are optional telemetry */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
observeResults(decisionId: string | undefined, assistant: ChatMessage, toolMessages: ChatMessage[]): void {
|
|
43
|
+
try { this.labels.observeResults(decisionId, assistant, toolMessages); } catch { /* model output cannot break a turn through telemetry */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private state(route: string, config: HubRoute, scope: string): RouteState {
|
|
47
|
+
const key = `${scope}:${route}`, fingerprint = JSON.stringify(config);
|
|
48
|
+
const prior = this.states.get(key);
|
|
49
|
+
if (prior?.fingerprint === fingerprint) return prior;
|
|
50
|
+
const value: RouteState = { fingerprint, stage: { capableHoldTurnsRemaining: 0 }, plan: { executingSessions: [] }, escalation: new EscalationState(), advisor: new AdvisorGate({ trigger: config.trigger, pattern: config.pattern, maxReviews: config.max_reviews, gateStallTurns: config.gate_stall_turns, gateMinToolResults: config.gate_min_tool_results, transcriptMaxChars: config.transcript_max_chars }) };
|
|
51
|
+
this.states.set(key, value); return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Executor failures retry the fixed model only in the caller; no tool has run yet. */
|
|
55
|
+
async call(route: string, config: HubRoute, messages: ChatMessage[], scope: string, pii: boolean, signal: AbortSignal): Promise<ChatResult> {
|
|
56
|
+
const labelTurn = this.labels.captureTurn();
|
|
57
|
+
const started = performance.now(), state = this.state(route, config, scope);
|
|
58
|
+
const conversation = normalizeConversation(messages), signals = extractToolSignals(conversation);
|
|
59
|
+
const dimensions = dimensionsFromSignal(signals);
|
|
60
|
+
const efficient = config.efficient ?? "fast", capable = config.capable ?? "coding";
|
|
61
|
+
let model = efficient, source: RouteEvent["source"] = "default", score = 0;
|
|
62
|
+
let sent = messages;
|
|
63
|
+
if (config.type === "stage") {
|
|
64
|
+
const decision = selectStage(signals, { confidenceThreshold: config.confidence_threshold, capableHoldTurns: config.hold_turns }, state.stage);
|
|
65
|
+
state.stage = decision.state;
|
|
66
|
+
model = (decision.tier ?? decision.defaultTier) === "capable" ? capable : efficient;
|
|
67
|
+
score = decision.score;
|
|
68
|
+
source = decision.source === "capable_hold" ? "hold" : decision.source === "override" || decision.source === "dimensions" ? decision.source : "default";
|
|
69
|
+
} else if (config.type === "plan_execute") {
|
|
70
|
+
const decision = planExecutePhase(signals, scope, state.plan);
|
|
71
|
+
model = decision.tier === "capable" ? capable : efficient;
|
|
72
|
+
state.plan = decision.state;
|
|
73
|
+
if (decision.planningPrompt) sent = [{ role: "system", content: decision.planningPrompt }, ...messages];
|
|
74
|
+
} else if (config.type === "escalation" && state.escalation.snapshot().latched) model = capable;
|
|
75
|
+
let decisionId = this.emitRoute({ route, tier: model, source, score, ms: performance.now() - started }, labelTurn, dimensions, pii, messages);
|
|
76
|
+
const result = await this.host.execute(model, sent, false, signal);
|
|
77
|
+
if (config.type !== "escalation" || state.escalation.snapshot().latched) return this.withDecision(result, decisionId);
|
|
78
|
+
const request = buildEscalationJudgeRequest(normalizeConversation([...messages, result.message]), signals.assistantTurnCount + 1);
|
|
79
|
+
const answer = await this.judge(config.judge ?? capable, [{ role: "system", content: request.systemPrompt }, ...request.messages], pii, signal, request.maxOutputTokens);
|
|
80
|
+
const verdict = answer ? parseEscalationVerdict(answer) : undefined;
|
|
81
|
+
if (answer && !verdict) this.judgeOffUntil = Date.now() + 5 * 60_000;
|
|
82
|
+
const snapshot = state.escalation.apply(verdict, config.confirmations ?? 2);
|
|
83
|
+
if (snapshot.latched) { try { this.labels.setLatched(labelTurn, true); } catch { /* optional labels */ } }
|
|
84
|
+
if (!snapshot.latched || signal.aborted) return this.withDecision(result, decisionId);
|
|
85
|
+
// The discarded efficient response is not inserted into history or executed as a tool batch.
|
|
86
|
+
decisionId = this.emitRoute({ route, tier: capable, source: "classifier", score: 0, ms: performance.now() - started }, labelTurn, dimensions, pii, messages);
|
|
87
|
+
return this.withDecision(await this.host.execute(capable, messages, false, signal), decisionId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reviews held no-tool responses. A redo is fed into the same local turn and bounded by its normal step cap. */
|
|
91
|
+
async review(route: string, config: HubRoute, messages: ChatMessage[], scope: string, pii: boolean, signal: AbortSignal, decisionId?: string): Promise<string | undefined> {
|
|
92
|
+
if (config.type !== "advisor") return undefined;
|
|
93
|
+
const state = this.state(route, config, scope), conversation = normalizeConversation(messages);
|
|
94
|
+
const latest = messages.at(-1);
|
|
95
|
+
if (!state.advisor.shouldReview(conversation, { hasToolUse: !!latest?.tool_calls?.length, visibleText: latest?.content ?? "" }, scope) || !state.advisor.reserve(scope)) return undefined;
|
|
96
|
+
const request = buildAdvisorJudgeRequest(conversation, latest?.content ?? undefined, config.transcript_max_chars);
|
|
97
|
+
const answer = await this.judge(config.judge ?? config.capable ?? "coding", [{ role: "system", content: request.systemPrompt }, ...request.messages], pii, signal, request.maxOutputTokens);
|
|
98
|
+
const verdict = answer ? parseAdvisorVerdict(answer) : undefined;
|
|
99
|
+
state.advisor.settle(scope, verdict ? "success" : "failure");
|
|
100
|
+
try { this.labels.observeAdvisor(decisionId, verdict?.kind ?? "failed"); } catch { /* optional labels */ }
|
|
101
|
+
if (answer && !verdict) this.judgeOffUntil = Date.now() + 5 * 60_000;
|
|
102
|
+
try { this.host.onAdvisor?.({ route, trigger: config.trigger ?? "no_tool_call", verdict: verdict?.kind ?? "failed", discardedChars: Math.max(0, [...JSON.stringify(conversation.messages)].length - (config.transcript_max_chars ?? 200_000)) }); } catch { /* optional telemetry */ }
|
|
103
|
+
return verdict?.kind === "redo" ? redoFeedback(verdict, latest?.content ?? "").user : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private withDecision(result: ChatResult, decisionId: string | undefined): ChatResult {
|
|
107
|
+
return decisionId ? { ...result, routeDecision: decisionId } : result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private emitRoute(event: RouteEvent, turn: ReturnType<RouteLabelTracker["captureTurn"]>, dimensions: ReturnType<typeof dimensionsFromSignal>, pii: boolean, priorMessages: ChatMessage[]): string | undefined {
|
|
111
|
+
if (turn && turn.closed) return undefined;
|
|
112
|
+
let decision: string | undefined;
|
|
113
|
+
try {
|
|
114
|
+
decision = this.labels.addDecision(turn, {
|
|
115
|
+
severity: dimensions.severity,
|
|
116
|
+
spinning: dimensions.spinning,
|
|
117
|
+
exploring: dimensions.exploring,
|
|
118
|
+
production: dimensions.productionIntensity,
|
|
119
|
+
}, priorMessages);
|
|
120
|
+
} catch { /* model inputs cannot fail routing through telemetry */ }
|
|
121
|
+
const labeled: RouteEvent = decision ? {
|
|
122
|
+
...event,
|
|
123
|
+
decision,
|
|
124
|
+
turn: turn!.context.turn,
|
|
125
|
+
...(turn!.context.task === undefined ? {} : { task: turn!.context.task }),
|
|
126
|
+
pii: turn!.context.pii,
|
|
127
|
+
severity: dimensions.severity,
|
|
128
|
+
spinning: dimensions.spinning,
|
|
129
|
+
exploring: dimensions.exploring,
|
|
130
|
+
production: dimensions.productionIntensity,
|
|
131
|
+
} : event;
|
|
132
|
+
try { this.host.onRoute?.(labeled); } catch { /* optional telemetry */ }
|
|
133
|
+
return decision;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async judge(model: string, messages: ChatMessage[], pii: boolean, parent: AbortSignal, maxTokens = 2048): Promise<string | undefined> {
|
|
137
|
+
if (parent.aborted || Date.now() < this.judgeOffUntil) return undefined;
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const abort = () => controller.abort();
|
|
140
|
+
parent.addEventListener("abort", abort, { once: true });
|
|
141
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
142
|
+
const work = async (): Promise<string | undefined> => {
|
|
143
|
+
if (pii && !(await this.host.onCampus())) return undefined;
|
|
144
|
+
if (controller.signal.aborted) return undefined;
|
|
145
|
+
const result = await this.host.execute(model, messages, true, controller.signal, maxTokens);
|
|
146
|
+
return result.message.content?.trim() || undefined;
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
const timeout = new Promise<undefined>(resolve => { timer = setTimeout(() => { controller.abort(); resolve(undefined); }, this.judgeTimeoutMs); });
|
|
150
|
+
const result = await Promise.race([work(), timeout]);
|
|
151
|
+
if (!result && !parent.aborted) this.judgeOffUntil = Date.now() + 5 * 60_000;
|
|
152
|
+
return result;
|
|
153
|
+
} catch { if (!parent.aborted) this.judgeOffUntil = Date.now() + 5 * 60_000; return undefined; }
|
|
154
|
+
finally { clearTimeout(timer); parent.removeEventListener("abort", abort); }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Ported to TypeScript from NVIDIA NeMo Switchyard crates/libsy/src/algorithms/util/tool_signals.rs at c8848511, modified.
|
|
4
|
+
|
|
5
|
+
import type { Conversation, NormalizedToolCall } from './normalize.ts';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_RECENT_WINDOW = 3;
|
|
8
|
+
export const SOFT = 0.3;
|
|
9
|
+
export const HARD = 0.7;
|
|
10
|
+
export const CRITICAL = 1.0;
|
|
11
|
+
|
|
12
|
+
const EDIT = new Set(['edit', 'multiedit', 'notebookedit', 'str_replace', 'str_replace_based_edit_tool', 'apply_patch', 'text_editor', 'patch']);
|
|
13
|
+
const EDITOR = new Set(['str_replace_based_edit_tool', 'text_editor']);
|
|
14
|
+
const WRITE = new Set(['write', 'create_file', 'new_file', 'write_file']);
|
|
15
|
+
const READ = new Set(['read', 'view', 'read_file', 'search_files', 'glob', 'grep', 'find', 'ls']);
|
|
16
|
+
const PLAN = new Set(['todowrite', 'todo_write', 'todo', 'update_plan', 'todo_list']);
|
|
17
|
+
const SHELL = new Set(['bash', 'shell_command', 'shell', 'local_shell_call', 'terminal', 'exec_command', 'exec', 'powershell']);
|
|
18
|
+
const BASH_READ_COMMANDS = new Set(['cat', 'rg', 'nl', 'jq', 'pwd', 'tree', 'sed', 'grep', 'ls', 'find', 'head', 'tail', 'wc', 'diff', 'which', 'ps', 'df', 'du', 'stat', 'file', 'less', 'more', 'readlink', 'realpath', 'basename', 'dirname', 'printenv']);
|
|
19
|
+
const GIT_READ = new Set(['status', 'diff', 'log', 'show', 'show-ref', 'rev-parse', 'ls-files', 'ls-remote', 'ls-tree', 'grep', 'blame', 'merge-base', 'check-ignore', 'tag']);
|
|
20
|
+
|
|
21
|
+
export interface ToolObservation {
|
|
22
|
+
name: string;
|
|
23
|
+
command?: string;
|
|
24
|
+
resultText?: string;
|
|
25
|
+
isError?: boolean;
|
|
26
|
+
source?: string;
|
|
27
|
+
/** Actual native assistant/model turn identity, absent when the surface cannot prove it. */
|
|
28
|
+
turn?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolSignals {
|
|
32
|
+
severity: number;
|
|
33
|
+
repeatedFailure: boolean;
|
|
34
|
+
noErrorStreak: number;
|
|
35
|
+
editCount: number;
|
|
36
|
+
writeCount: number;
|
|
37
|
+
readCount: number;
|
|
38
|
+
todowriteCount: number;
|
|
39
|
+
recentEditCount: number;
|
|
40
|
+
recentWriteCount: number;
|
|
41
|
+
recentReadCount: number;
|
|
42
|
+
recentTodowriteCount: number;
|
|
43
|
+
newCount: number;
|
|
44
|
+
recentNewCount: number;
|
|
45
|
+
pureBashStreak: number;
|
|
46
|
+
testsPassed: boolean;
|
|
47
|
+
toolResultCount: number;
|
|
48
|
+
assistantTurnCount: number;
|
|
49
|
+
turnDepth: number;
|
|
50
|
+
compacted: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Semantic = 'write' | 'edit' | 'read' | 'plan' | 'new' | 'unknown';
|
|
54
|
+
const emptySignals = (turnDepth: number): ToolSignals => ({
|
|
55
|
+
severity: 0, repeatedFailure: false, noErrorStreak: 0, editCount: 0, writeCount: 0,
|
|
56
|
+
readCount: 0, todowriteCount: 0, recentEditCount: 0, recentWriteCount: 0,
|
|
57
|
+
recentReadCount: 0, recentTodowriteCount: 0, newCount: 0, recentNewCount: 0,
|
|
58
|
+
pureBashStreak: 0, testsPassed: false, toolResultCount: 0, assistantTurnCount: 0,
|
|
59
|
+
turnDepth, compacted: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function argumentCommand(args: unknown): string | undefined {
|
|
63
|
+
if (typeof args === 'string') {
|
|
64
|
+
const raw = args;
|
|
65
|
+
try { args = JSON.parse(raw) as unknown; } catch { return raw; }
|
|
66
|
+
}
|
|
67
|
+
if (!args || typeof args !== 'object') return undefined;
|
|
68
|
+
const obj = args as Record<string, unknown>;
|
|
69
|
+
for (const key of ['command', 'cmd', 'input']) if (typeof obj[key] === 'string') return obj[key] as string;
|
|
70
|
+
if (typeof obj.command === 'object' && obj.command && typeof (obj.command as Record<string, unknown>).text === 'string') return (obj.command as Record<string, string>).text;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function splitShell(command: string): string[] {
|
|
75
|
+
const parts: string[] = []; let start = 0; let quote = ''; let escaped = false;
|
|
76
|
+
for (let i = 0; i < command.length; i++) {
|
|
77
|
+
const c = command[i]!;
|
|
78
|
+
if (escaped) { escaped = false; continue; }
|
|
79
|
+
if (c === '\\' && quote !== "'") { escaped = true; continue; }
|
|
80
|
+
if (quote === c) quote = '';
|
|
81
|
+
else if (!quote && (c === "'" || c === '"')) quote = c;
|
|
82
|
+
else if (!quote && ['\n', ';', '|', '&'].includes(c)) { const s = command.slice(start, i).trim(); if (s) parts.push(s); start = i + 1; }
|
|
83
|
+
}
|
|
84
|
+
const rest = command.slice(start).trim(); if (rest) parts.push(rest); return parts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shellWords(segment: string): string[] {
|
|
88
|
+
const words = segment.split(/[\t\n\v\f\r ]+/u).filter(Boolean);
|
|
89
|
+
let i = 0;
|
|
90
|
+
if (words[i] === 'env') { i++; while (words[i]?.startsWith('-')) i++; }
|
|
91
|
+
while (words[i]?.includes('=') && !words[i]?.startsWith('=')) i++;
|
|
92
|
+
return words.slice(i);
|
|
93
|
+
}
|
|
94
|
+
function baseProgram(word: string): string { return word.split('/').pop() ?? word; }
|
|
95
|
+
function shellClassification(command: string): Semantic {
|
|
96
|
+
const lower = command.toLowerCase();
|
|
97
|
+
if (['cat >', 'cat >>', 'echo >', 'echo >>', 'tee ', 'printf >', 'printf >>', '> /', '>> /', "<< 'eof'", '<<eof', "<<'eof'", '<< eof'].some((p) => lower.includes(p))) return 'write';
|
|
98
|
+
if (lower.includes('python') && ['write_text(', 'writelines(', '.write('].some((p) => lower.includes(p))) return 'write';
|
|
99
|
+
if (splitShell(lower).some((s) => baseProgram(shellWords(s)[0] ?? '') === 'node') && ['writefilesync(', 'writefile(', 'appendfilesync(', 'appendfile('].some((p) => lower.includes(p))) return 'write';
|
|
100
|
+
const segments = splitShell(lower);
|
|
101
|
+
for (const segment of segments) {
|
|
102
|
+
const words = shellWords(segment); const p = baseProgram(words[0] ?? '');
|
|
103
|
+
if (['cp', 'mkdir', 'touch', 'install'].includes(p)) return 'write';
|
|
104
|
+
const redirectsOutput = words.some((word) => word === '>' || word === '>>' || /^\d*>{1,2}(?!&)\S*$/u.test(word));
|
|
105
|
+
if (redirectsOutput && (['echo', 'printf', 'git'].includes(p) || BASH_READ_COMMANDS.has(p))) return 'write';
|
|
106
|
+
}
|
|
107
|
+
if (['sed -i', 'sed --in-place', 'awk -i inplace', "awk 'inplace=1'", 'patch ', 'patch -p', 'perl -i', 'perl -p -i', 'perl -pi'].some((p) => lower.includes(p))) return 'edit';
|
|
108
|
+
for (const segment of segments) {
|
|
109
|
+
const words = shellWords(segment); const p = baseProgram(words[0] ?? ''); const has = (x: string) => words.includes(x);
|
|
110
|
+
if (p === 'mv' || p === 'rm') return 'edit';
|
|
111
|
+
if (p === 'perl' && words.slice(1).some((w) => w.startsWith('-') && w.slice(1).includes('i'))) return 'edit';
|
|
112
|
+
if (p === 'git' && ['apply', 'am', 'restore'].includes(words[1] ?? '')) return 'edit';
|
|
113
|
+
if (p === 'gofmt' && has('-w')) return 'edit';
|
|
114
|
+
if (p === 'cargo' && words[1] === 'fmt' && !has('--check')) return 'edit';
|
|
115
|
+
if (p === 'ruff' && ((words[1] === 'format' && !has('--check')) || (words[1] === 'check' && has('--fix')))) return 'edit';
|
|
116
|
+
if (p === 'prettier' && has('--write')) return 'edit';
|
|
117
|
+
if (p === 'black' && !has('--check')) return 'edit';
|
|
118
|
+
if (words.some((word) => baseProgram(word) === 'prettier') && has('--write')) return 'edit';
|
|
119
|
+
if (words.some((word) => baseProgram(word) === 'ruff') && ((has('format') && !has('--check')) || (has('check') && has('--fix')))) return 'edit';
|
|
120
|
+
}
|
|
121
|
+
if (segments.some((s) => /\b(?:cat \/|cat \.\/|cat \.\.\/|grep |ls |ls -|find |head |tail |wc |diff |which |ps |df |du |stat |file |less |more )/u.test(s))) return 'read';
|
|
122
|
+
for (const segment of segments) {
|
|
123
|
+
const words = shellWords(segment); const p = baseProgram(words[0] ?? '');
|
|
124
|
+
if (BASH_READ_COMMANDS.has(p) || segment === 'env' || (p === 'command' && words[1] === '-v') || p === 'type') return 'read';
|
|
125
|
+
if (p === 'git') {
|
|
126
|
+
const sub = words[1];
|
|
127
|
+
if (GIT_READ.has(sub ?? '') || (sub === 'branch' && (!words[2] || words[2]!.startsWith('-'))) || (sub === 'remote' && (!words[2] || words[2]!.startsWith('-') || words[2] === 'get-url')) || (sub === 'config' && ['--get', '--get-all', '--list', '-l'].includes(words[2] ?? ''))) return 'read';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return 'unknown';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function semantic(name: string, command?: string, source?: string): Semantic {
|
|
134
|
+
if (source?.toLowerCase() === 'filechange') return 'edit';
|
|
135
|
+
const lower = name.toLowerCase();
|
|
136
|
+
if (source === 'codex' && lower === 'filechange') return 'edit';
|
|
137
|
+
if (WRITE.has(lower)) return 'write';
|
|
138
|
+
if (EDITOR.has(lower) && command === 'view') return 'read';
|
|
139
|
+
if (EDIT.has(lower)) return 'edit';
|
|
140
|
+
if (READ.has(lower)) return 'read';
|
|
141
|
+
if (PLAN.has(lower)) return 'plan';
|
|
142
|
+
if (SHELL.has(lower) && command !== undefined) return shellClassification(command);
|
|
143
|
+
return 'unknown';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface Result { text: string; isError: boolean; }
|
|
147
|
+
function lower(text: string): string { return text.toLowerCase(); }
|
|
148
|
+
const patterns: Array<[string, number, string[]]> = [
|
|
149
|
+
['oom', CRITICAL, ['out of memory', 'memoryerror', 'cannot allocate memory']],
|
|
150
|
+
['connection_refused', HARD, ['connection refused', 'connectionrefusederror', 'econnrefused']],
|
|
151
|
+
['traceback', HARD, ['traceback (most recent call last)']],
|
|
152
|
+
['import_error', HARD, ['modulenotfounderror:', 'importerror:', 'no module named ']],
|
|
153
|
+
['cmd_not_found', HARD, ['command not found', 'not found\n', '/usr/bin/env: ']],
|
|
154
|
+
['assertion', HARD, ['assertionerror']], ['value_error', HARD, ['valueerror:']], ['syntax_error', HARD, ['syntaxerror:']],
|
|
155
|
+
['timeout', HARD, ['timed out', 'timeouterror', 'timeout expired', 'deadline exceeded']],
|
|
156
|
+
['no_such_file', HARD, ['filenotfounderror:', 'no such file or directory', 'file does not exist']],
|
|
157
|
+
['exit_nonzero', SOFT, ['returned non-zero']],
|
|
158
|
+
];
|
|
159
|
+
function failureSeverity(text: string, isError: boolean): { severity: number; names: string[] } {
|
|
160
|
+
const l = lower(text); let severity = 0; const names: string[] = [];
|
|
161
|
+
for (const [name, value, needles] of patterns) if (needles.some((needle) => l.includes(needle))) { severity = Math.max(severity, value); names.push(name); }
|
|
162
|
+
const exitPhrases = ['exit code', 'exit status', 'exited with code', 'exited with status'];
|
|
163
|
+
for (const phrase of exitPhrases) {
|
|
164
|
+
let cursor = 0;
|
|
165
|
+
while (cursor < l.length) {
|
|
166
|
+
const at = l.indexOf(phrase, cursor);
|
|
167
|
+
if (at < 0) break;
|
|
168
|
+
const digits = l.slice(at + phrase.length).replace(/^[\s:='"`]*/u, '').match(/^\d+/u)?.[0];
|
|
169
|
+
if (digits && Number(digits) !== 0) { severity = Math.max(severity, SOFT); break; }
|
|
170
|
+
cursor = at + phrase.length;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (l.split('\n').some((line) => {
|
|
174
|
+
const trimmed = line.trimStart();
|
|
175
|
+
if (['compilation failed', 'error: compilation failed', 'error: could not compile'].includes(trimmed) || trimmed.startsWith('error: could not compile ')) return true;
|
|
176
|
+
const match = trimmed.match(/^error\[e(\d+)\]:/u);
|
|
177
|
+
return match !== null && match[1]!.length > 0;
|
|
178
|
+
})) { severity = Math.max(severity, HARD); names.push('compiler'); }
|
|
179
|
+
if (/(?:typeerror|referenceerror|rangeerror|runtimeerror|keyerror|attributeerror):/u.test(l) && /\n\s{2,}at\s/u.test(l)) { severity = Math.max(severity, HARD); names.push('runtime_exception'); }
|
|
180
|
+
if (/panic: runtime error:/u.test(l) && /\ngoroutine |\[signal sig/u.test(l)) { severity = Math.max(severity, HARD); names.push('runtime_panic'); }
|
|
181
|
+
if (/^(?:error: patch failed:|patch failed:|invalid context)|: patch does not apply/mu.test(l.split('\n').map(line => line.trimStart()).join('\n'))) { severity = Math.max(severity, HARD); names.push('patch_failure'); }
|
|
182
|
+
return { severity: isError ? Math.max(severity, HARD) : severity, names };
|
|
183
|
+
}
|
|
184
|
+
export function fingerprint(text: string, isError: boolean): string | undefined {
|
|
185
|
+
const detected = failureSeverity(text, isError); if (detected.severity < HARD && !isError) return undefined;
|
|
186
|
+
const lines = lower(text).split('\n');
|
|
187
|
+
const diagnostic = lines.find((line) => /error|exception|panic|failed|timed out|timeout|connection refused|cannot allocate memory|out of memory|not found/u.test(line.trim())) ?? lines.find((line) => line.trim()) ?? '';
|
|
188
|
+
const normalized = [...diagnostic.split(/\s+/u).map((word) => word.startsWith('/') || word.includes('/src/') || word.includes('/tmp/') ? '<path>' : word.replace(/\d+/gu, '#')).join(' ')].slice(0, 240).join('');
|
|
189
|
+
return `${detected.names.join(',')}|${normalized}`;
|
|
190
|
+
}
|
|
191
|
+
function nonzeroFailureCount(text: string): boolean {
|
|
192
|
+
for (const m of text.matchAll(/\b(\d+)\s+(failed|failure|failures|errors|error)\b/gu)) if (Number(m[1]) !== 0) return true;
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
function testPass(text: string): boolean {
|
|
196
|
+
const l = lower(text);
|
|
197
|
+
const pass = [' passed', 'passed in', 'tests passed', 'all tests passed', 'test ok', 'test result: ok', 'passed.\n', 'tests pass', '\nok ', '✓ '].some((p) => l.includes(p));
|
|
198
|
+
return pass && !['✗ ', 'fatal:', 'assertionerror', 'error:'].some((p) => l.includes(p)) && !nonzeroFailureCount(l);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildSignals(calls: Array<{name:string;command?:string;source?:string}>, results: Result[], turnDepth:number, assistantTurns:number, compacted:boolean, recentWindow:number): ToolSignals {
|
|
202
|
+
const signal = emptySignals(turnDepth); signal.assistantTurnCount = assistantTurns; signal.toolResultCount = results.length; signal.compacted = compacted;
|
|
203
|
+
const window = Math.max(1, recentWindow); const recentResults = results.slice(-window);
|
|
204
|
+
signal.severity = recentResults.reduce((max, result) => Math.max(max, failureSeverity(result.text, result.isError).severity), 0);
|
|
205
|
+
const fingerprints = recentResults.map((r) => fingerprint(r.text,r.isError)).filter((v):v is string=>v!==undefined);
|
|
206
|
+
signal.repeatedFailure = new Set(fingerprints).size < fingerprints.length;
|
|
207
|
+
for (const result of [...results].reverse()) { if (result.isError || failureSeverity(result.text,false).severity > 0) break; signal.noErrorStreak++; }
|
|
208
|
+
signal.testsPassed = recentResults.some((result,index) => {
|
|
209
|
+
const latestFailure = recentResults.map((r,i)=>failureSeverity(r.text,r.isError).severity>0||r.isError?i:-1).filter(i=>i>=0).at(-1) ?? -1;
|
|
210
|
+
return index > latestFailure && testPass(result.text);
|
|
211
|
+
});
|
|
212
|
+
const recentCalls = calls.slice(-window); const count = (list:typeof calls) => list.map((call)=>semantic(call.name,call.command,call.source));
|
|
213
|
+
const all = count(calls); const recent = count(recentCalls);
|
|
214
|
+
signal.writeCount = all.filter((s)=>s==='write').length; signal.editCount=all.filter((s)=>s==='edit').length; signal.readCount=all.filter((s)=>s==='read').length; signal.todowriteCount=all.filter((s)=>s==='plan').length;
|
|
215
|
+
signal.recentWriteCount=recent.filter((s)=>s==='write').length; signal.recentEditCount=recent.filter((s)=>s==='edit').length; signal.recentReadCount=recent.filter((s)=>s==='read').length; signal.recentTodowriteCount=recent.filter((s)=>s==='plan').length; signal.newCount=all.filter((s)=>s==='new').length; signal.recentNewCount=recent.filter((s)=>s==='new').length;
|
|
216
|
+
for(const s of [...all].reverse()){if(s!=='unknown')break;signal.pureBashStreak++;}
|
|
217
|
+
return signal;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function extractToolSignals(conversation: Conversation, recentWindow = DEFAULT_RECENT_WINDOW): ToolSignals {
|
|
221
|
+
const calls: Array<{name:string;command?:string}> = []; const results: Result[]=[]; const ids=new Map<string,boolean>(); let assistants=0; let compacted=false;
|
|
222
|
+
for(const message of conversation.messages){if(message.role==='assistant')assistants++;
|
|
223
|
+
if(message.content.toLowerCase().includes('session is being continued'))compacted=true;
|
|
224
|
+
for(const call of message.toolCalls){const command=argumentCommand(call.arguments);calls.push({name:call.name,...(command!==undefined?{command}:{})}); ids.set(call.id,semantic(call.name,command)==='read');}
|
|
225
|
+
for(const result of message.toolResults){const retrieval=ids.get(result.toolCallId)===true&&!result.isError;results.push({text:retrieval?'':result.content,isError:result.isError===true});}
|
|
226
|
+
}
|
|
227
|
+
return buildSignals(calls,results,conversation.messages.length,assistants,compacted,recentWindow);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function extractToolSignalsFromObservations(observations: readonly ToolObservation[], turnDepth = observations.length, recentWindow = DEFAULT_RECENT_WINDOW): ToolSignals {
|
|
231
|
+
const calls=observations.map((o)=>({name:o.name, ...(o.command!==undefined?{command:o.command}:{}), ...(o.source!==undefined?{source:o.source}:{})}));
|
|
232
|
+
const results=observations.filter((o)=>o.resultText!==undefined||o.isError===true).map((o)=>({text:!o.isError && semantic(o.name,o.command,o.source)==='read' ? '' : o.resultText??'',isError:o.isError===true}));
|
|
233
|
+
return buildSignals(calls,results,turnDepth,turnDepth,false,recentWindow);
|
|
234
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Ported to TypeScript from NVIDIA NeMo Switchyard crates/libsy/src/algorithms/util/stage.rs at c8848511, modified.
|
|
4
|
+
|
|
5
|
+
import type { ToolSignals } from './signals.ts';
|
|
6
|
+
|
|
7
|
+
export type Tier = 'capable' | 'efficient';
|
|
8
|
+
export type PickerMode = 'capable_first' | 'efficient_first';
|
|
9
|
+
export type DecisionSource = 'override' | 'capable_hold' | 'dimensions' | 'ambiguous' | 'llm-classifier' | 'fall_open';
|
|
10
|
+
export interface CodingAgentDimensions { severity:number; spinning:number; exploring:number; productionIntensity:number }
|
|
11
|
+
export interface ScoreResult { score:number; confidence:number }
|
|
12
|
+
export type PickOutcome =
|
|
13
|
+
| {kind:'resolved';tier:Tier;source:DecisionSource;probability:number;confidence:number|null}
|
|
14
|
+
| {kind:'consult_classifier';probability:number;confidence:number;defaultTier:Tier};
|
|
15
|
+
export interface StageState { capableHoldTurnsRemaining:number }
|
|
16
|
+
export interface HandoffNoteConfig {
|
|
17
|
+
escalationNote: string;
|
|
18
|
+
deescalationNote?: string;
|
|
19
|
+
onlyOnWrongSignalEscalation?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface StageOptions { mode?:PickerMode; confidenceThreshold?:number; capableHoldTurns?:number; handoffNotes?:HandoffNoteConfig }
|
|
22
|
+
export interface StageDecision { tier:Tier|undefined; defaultTier:Tier; source:DecisionSource; probability:number; confidence:number; score:number; dimensions:CodingAgentDimensions; note?:string; state:StageState }
|
|
23
|
+
|
|
24
|
+
const STALL_MIN_TURN_DEPTH=8;
|
|
25
|
+
const SCORE_GAIN=5;
|
|
26
|
+
const HARD_SEVERITY=0.7;
|
|
27
|
+
const SIGNAL_UNIT=0.1;
|
|
28
|
+
const SEVERITY_CRITICAL=1;
|
|
29
|
+
|
|
30
|
+
export function dimensionsFromSignal(signal:ToolSignals):CodingAgentDimensions {
|
|
31
|
+
const recentOps=signal.recentWriteCount+signal.recentEditCount+signal.recentReadCount+signal.recentTodowriteCount;
|
|
32
|
+
const deep=signal.turnDepth>=STALL_MIN_TURN_DEPTH;
|
|
33
|
+
const noProduction=signal.recentWriteCount===0&&signal.recentEditCount===0;
|
|
34
|
+
const investigating=signal.recentReadCount>=1||signal.recentTodowriteCount>=1;
|
|
35
|
+
const newActivity=signal.recentNewCount>=1;
|
|
36
|
+
const spinning=deep&&noProduction&&!investigating&&!newActivity;
|
|
37
|
+
const exploring=deep&&noProduction&&investigating&&!newActivity;
|
|
38
|
+
const production=recentOps===0?0:(signal.recentWriteCount+signal.recentEditCount)/recentOps;
|
|
39
|
+
return {severity:signal.severity,spinning:spinning?1:0,exploring:exploring?1:0,productionIntensity:production};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function scoreSignal(signal:ToolSignals):ScoreResult {
|
|
43
|
+
const d=dimensionsFromSignal(signal);
|
|
44
|
+
const raw=SIGNAL_UNIT*(d.severity/HARD_SEVERITY+d.spinning+d.exploring-d.productionIntensity);
|
|
45
|
+
const score=Math.tanh(SCORE_GAIN*raw);
|
|
46
|
+
return {score,confidence:Math.abs(score)};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function pickTier(signal:ToolSignals, mode:PickerMode='efficient_first', confidenceThreshold=0.5):PickOutcome {
|
|
50
|
+
const defaultTier:Tier=mode==='capable_first'?'capable':'efficient';
|
|
51
|
+
if(signal.compacted||signal.severity>=SEVERITY_CRITICAL||signal.repeatedFailure)
|
|
52
|
+
return {kind:'resolved',tier:'capable',source:'override',probability:0.5,confidence:1};
|
|
53
|
+
const scored=scoreSignal(signal); const probability=(scored.score+1)/2; const half=confidenceThreshold/2;
|
|
54
|
+
if(probability>0.5+half||probability<0.5-half)
|
|
55
|
+
return {kind:'resolved',tier:probability>0.5?'capable':'efficient',source:'dimensions',probability,confidence:scored.confidence};
|
|
56
|
+
return {kind:'consult_classifier',probability,confidence:scored.confidence,defaultTier};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function handoffNoteFor(tier:Tier, source:DecisionSource, config:HandoffNoteConfig):string|undefined {
|
|
60
|
+
if(tier==='capable') {
|
|
61
|
+
const signalDriven=source==='override'||source==='dimensions';
|
|
62
|
+
return (config.onlyOnWrongSignalEscalation??true)&&!signalDriven ? undefined : config.escalationNote;
|
|
63
|
+
}
|
|
64
|
+
return config.deescalationNote;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Apply stage hard rules, dimension scoring, and the capable recovery hold. */
|
|
68
|
+
export function selectStage(signal:ToolSignals, options:StageOptions={}, state:StageState={capableHoldTurnsRemaining:0}):StageDecision {
|
|
69
|
+
const mode=options.mode??'efficient_first'; const defaultTier:Tier=mode==='capable_first'?'capable':'efficient';
|
|
70
|
+
const holdTurns=Math.max(0,Math.trunc(options.capableHoldTurns??2));
|
|
71
|
+
let remaining=Math.max(0,Math.trunc(state.capableHoldTurnsRemaining));
|
|
72
|
+
const cleanTestPass=signal.testsPassed&&signal.noErrorStreak>0;
|
|
73
|
+
if(cleanTestPass)remaining=0;
|
|
74
|
+
let outcome:PickOutcome;
|
|
75
|
+
if(!cleanTestPass&&remaining>0){remaining--;outcome={kind:'resolved',tier:'capable',source:'capable_hold',probability:0.5,confidence:1};}
|
|
76
|
+
else outcome=pickTier(signal,mode,options.confidenceThreshold??0.5);
|
|
77
|
+
if(outcome.kind==='resolved'&&outcome.tier==='capable'&&(outcome.source==='override'||outcome.source==='dimensions'))remaining=holdTurns;
|
|
78
|
+
const dimensions=dimensionsFromSignal(signal); const score=scoreSignal(signal).score;
|
|
79
|
+
const note = outcome.kind === 'resolved' && options.handoffNotes
|
|
80
|
+
? handoffNoteFor(outcome.tier, outcome.source, options.handoffNotes)
|
|
81
|
+
: undefined;
|
|
82
|
+
return {
|
|
83
|
+
tier: outcome.kind === 'resolved' ? outcome.tier : undefined,
|
|
84
|
+
defaultTier,
|
|
85
|
+
source: outcome.kind === 'consult_classifier' ? 'ambiguous' : outcome.source,
|
|
86
|
+
probability: outcome.probability,
|
|
87
|
+
confidence: outcome.confidence ?? 0,
|
|
88
|
+
score,
|
|
89
|
+
dimensions,
|
|
90
|
+
...(note === undefined ? {} : { note }),
|
|
91
|
+
state: { capableHoldTurnsRemaining: remaining },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// Ported to TypeScript from NVIDIA NeMo Switchyard crates/libsy/src/algorithms/escalation.rs at commit c8848511, modified.
|
|
4
|
+
|
|
5
|
+
import type { EscalationCategory, EscalationVerdict } from "./escalation.ts";
|
|
6
|
+
|
|
7
|
+
export interface EscalationStateSnapshot { latched: boolean; category?: EscalationCategory; streak: number }
|
|
8
|
+
|
|
9
|
+
/** Confirmation streak and session latch used by the trajectory escalation policy. */
|
|
10
|
+
export class EscalationState {
|
|
11
|
+
private category?: EscalationCategory;
|
|
12
|
+
private streak = 0;
|
|
13
|
+
private latched = false;
|
|
14
|
+
apply(verdict: EscalationVerdict | undefined, confirmations = 2): EscalationStateSnapshot {
|
|
15
|
+
if (this.latched) return this.snapshot();
|
|
16
|
+
if (verdict && verdict.escalate && verdict.category !== "none" && verdict.newEvidence) {
|
|
17
|
+
this.streak = this.category === verdict.category ? this.streak + 1 : 1;
|
|
18
|
+
this.category = verdict.category;
|
|
19
|
+
if (this.streak >= confirmations) this.latched = true;
|
|
20
|
+
} else if (verdict) {
|
|
21
|
+
this.streak = 0;
|
|
22
|
+
this.category = undefined;
|
|
23
|
+
}
|
|
24
|
+
return this.snapshot();
|
|
25
|
+
}
|
|
26
|
+
reset(): void { this.category = undefined; this.streak = 0; this.latched = false; }
|
|
27
|
+
snapshot(): EscalationStateSnapshot { return { latched: this.latched, ...(this.category ? { category: this.category } : {}), streak: this.streak }; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Bounded keyed state helper with one-hour idle expiry for independent sessions. */
|
|
31
|
+
export class SessionState<T> {
|
|
32
|
+
private readonly values = new Map<string, { value: T; touchedAt: number }>();
|
|
33
|
+
constructor(private readonly maxEntries = 1024, private readonly ttlMs = 60 * 60 * 1000) {}
|
|
34
|
+
get(key: string, now = Date.now()): T | undefined {
|
|
35
|
+
this.expire(now);
|
|
36
|
+
const entry = this.values.get(key);
|
|
37
|
+
if (entry) entry.touchedAt = now;
|
|
38
|
+
return entry?.value;
|
|
39
|
+
}
|
|
40
|
+
set(key: string, value: T, now = Date.now()): void {
|
|
41
|
+
this.expire(now);
|
|
42
|
+
this.values.delete(key);
|
|
43
|
+
while (this.values.size >= this.maxEntries) {
|
|
44
|
+
const oldest = this.values.keys().next().value;
|
|
45
|
+
if (oldest === undefined) break;
|
|
46
|
+
this.values.delete(oldest);
|
|
47
|
+
}
|
|
48
|
+
this.values.set(key, { value, touchedAt: now });
|
|
49
|
+
}
|
|
50
|
+
delete(key: string): void { this.values.delete(key); }
|
|
51
|
+
private expire(now: number): void {
|
|
52
|
+
for (const [key, entry] of this.values) if (now - entry.touchedAt >= this.ttlMs) this.values.delete(key);
|
|
53
|
+
}
|
|
54
|
+
}
|