@staix/agent-hub 0.12.7 → 0.12.8
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 +9 -0
- package/LICENSES/Apache-2.0.txt +204 -0
- package/THIRD_PARTY_NOTICES.md +19 -0
- 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-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/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 +55 -14
- 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 +10 -1
- 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,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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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/advisor_gate/transcript.rs and algorithms/util/{escalation,prompts,llm_judge}.rs at commit c8848511, modified.
|
|
4
|
+
|
|
5
|
+
import type { ChatMessage } from "../../omniroute/client.ts";
|
|
6
|
+
|
|
7
|
+
/** Unicode code-point aware middle truncation used by Switchyard's advisor. */
|
|
8
|
+
export function middleDrop(text: string, cap: number): string {
|
|
9
|
+
const chars = Array.from(text);
|
|
10
|
+
if (chars.length <= cap) return text;
|
|
11
|
+
const headCount = Math.max(0, Math.floor(cap / 4));
|
|
12
|
+
const tailCount = Math.max(0, cap - headCount);
|
|
13
|
+
return `${chars.slice(0, headCount).join("")}\n...<middle of the conversation truncated>...\n${chars.slice(-tailCount || chars.length).join("")}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Bounded head/tail truncation for escalation summaries, measured in code points. */
|
|
17
|
+
export function truncateCodepoints(text: string, limit: number): string {
|
|
18
|
+
const chars = Array.from(text);
|
|
19
|
+
if (chars.length <= limit) return text;
|
|
20
|
+
const marker = " ...[trimmed] ";
|
|
21
|
+
const keep = Math.min(chars.length, Math.max(20, limit - Array.from(marker).length));
|
|
22
|
+
const head = Math.floor(keep * 2 / 3);
|
|
23
|
+
const tail = keep - head;
|
|
24
|
+
return `${chars.slice(0, head).join("")}${marker}${chars.slice(-tail).join("")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const verdictPattern = /^[\s*_#>"'(\[`]*(?:(?:final\s+)?verdict\s*:\s*[\s*_#>"'(\[`]*)?(APPROVE|REDO)(?![\p{Alphabetic}\p{M}\p{Nd}\p{Pc}\p{Join_Control}])/iu;
|
|
28
|
+
|
|
29
|
+
export type AdvisorVerdict = { kind: "approve" } | { kind: "redo"; plan: string };
|
|
30
|
+
|
|
31
|
+
/** Only an anchored first-word verdict is trusted. */
|
|
32
|
+
export function parseAdvisorVerdict(reply: string): AdvisorVerdict | undefined {
|
|
33
|
+
const trimmed = reply.trim();
|
|
34
|
+
const match = verdictPattern.exec(trimmed);
|
|
35
|
+
if (!match) return undefined;
|
|
36
|
+
if (match[1]?.toUpperCase() === "APPROVE") return { kind: "approve" };
|
|
37
|
+
const tail = trimmed.slice(match[0].length).trimStart().replace(/^[ *_:\n-]+/u, "").trim();
|
|
38
|
+
return { kind: "redo", plan: tail || trimmed };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Removes only the JSON fences supported by Switchyard's typed judge parser. */
|
|
42
|
+
export function stripJsonFence(text: string): string {
|
|
43
|
+
let value = text.trim();
|
|
44
|
+
if (!value.startsWith("```")) return value;
|
|
45
|
+
value = value.slice(3);
|
|
46
|
+
if (value.startsWith("json")) value = value.slice(4);
|
|
47
|
+
value = value.replace(/^[\n\r]+/u, "");
|
|
48
|
+
return value.endsWith("```") ? value.slice(0, -3).trim() : value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Add a routing note without creating consecutive user turns. */
|
|
52
|
+
export function appendNote(messages: readonly ChatMessage[], note: string): ChatMessage[] {
|
|
53
|
+
const output = messages.map(message => ({ ...message }));
|
|
54
|
+
const last = output.at(-1);
|
|
55
|
+
if (last?.role === "user") {
|
|
56
|
+
last.content = last.content ? `${last.content}\n${note}` : note;
|
|
57
|
+
} else {
|
|
58
|
+
output.push({ role: "user", content: note });
|
|
59
|
+
}
|
|
60
|
+
return output;
|
|
61
|
+
}
|
package/src/omniroute/client.ts
CHANGED
|
@@ -24,8 +24,12 @@ export interface ChatMessage {
|
|
|
24
24
|
content: string | null;
|
|
25
25
|
tool_calls?: ToolCall[];
|
|
26
26
|
tool_call_id?: string;
|
|
27
|
+
/** Internal local-tool outcome, consumed by route normalization and omitted from provider transport. */
|
|
28
|
+
is_error?: boolean;
|
|
27
29
|
}
|
|
28
30
|
export interface ChatResult {
|
|
31
|
+
/** Internal routing telemetry id, never part of a provider payload. */
|
|
32
|
+
routeDecision?: string;
|
|
29
33
|
message: ChatMessage;
|
|
30
34
|
/** Usage counters returned by the provider, when valid. No missing counter is inferred as zero. */
|
|
31
35
|
usage?: NormalizedUsage;
|
|
@@ -37,6 +41,8 @@ export interface ChatResult {
|
|
|
37
41
|
selectedModel?: string;
|
|
38
42
|
}
|
|
39
43
|
export interface ChatOptions {
|
|
44
|
+
/** Refuse a changed/off-campus gateway immediately before transport. */
|
|
45
|
+
onCampusOnly?: boolean;
|
|
40
46
|
signal?: AbortSignal;
|
|
41
47
|
/** Switchyard base (`http://127.0.0.1:<port>/v1`). The sidecar holds the key, so none is sent. */
|
|
42
48
|
via?: string;
|
|
@@ -155,6 +161,7 @@ export class OmniRoute {
|
|
|
155
161
|
async chat(body: { model: string; messages: ChatMessage[]; tools?: unknown[]; max_tokens?: number }, opts: ChatOptions = {}): Promise<ChatResult> {
|
|
156
162
|
const base = opts.via ?? (await this.base());
|
|
157
163
|
if (!base) throw new Error("no model gateway is configured or reachable (omniroute.urls in .agenthub/config.json; see ahub doctor)");
|
|
164
|
+
if (opts.onCampusOnly && (opts.via || this.isAccessHost(base))) throw new Error("PII model calls require the confirmed campus gateway");
|
|
158
165
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
159
166
|
if (opts.via) {
|
|
160
167
|
if (opts.sessionId) headers["x-switchyard-session-id"] = opts.sessionId;
|
|
@@ -168,7 +175,7 @@ export class OmniRoute {
|
|
|
168
175
|
res = await fetch(`${base}/chat/completions`, {
|
|
169
176
|
method: "POST",
|
|
170
177
|
headers,
|
|
171
|
-
body: JSON.stringify({ ...body, stream: false }),
|
|
178
|
+
body: JSON.stringify({ ...body, messages: body.messages.map(({ is_error: _internalError, ...message }) => message), stream: false }),
|
|
172
179
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
173
180
|
});
|
|
174
181
|
} catch (e) {
|
package/templates/routing.toml
CHANGED
|
@@ -93,3 +93,31 @@ peers = ["pi", "local", "kimi"]
|
|
|
93
93
|
route = "sy/fast"
|
|
94
94
|
pi_backend = "mlx"
|
|
95
95
|
escalate_to = ["codex", "kimi"]
|
|
96
|
+
|
|
97
|
+
# In-process L2 routes are opt-in: set local.route or a class route to hub/<id>.
|
|
98
|
+
# They never enter the optional Switchyard sidecar TOML. Defaults are efficient fast, capable coding.
|
|
99
|
+
[hub_routes."hub/stage"]
|
|
100
|
+
type = "stage"
|
|
101
|
+
efficient = "fast"
|
|
102
|
+
capable = "coding"
|
|
103
|
+
confidence_threshold = 0.5
|
|
104
|
+
hold_turns = 2
|
|
105
|
+
|
|
106
|
+
[hub_routes."hub/plan"]
|
|
107
|
+
type = "plan_execute"
|
|
108
|
+
efficient = "fast"
|
|
109
|
+
capable = "coding"
|
|
110
|
+
|
|
111
|
+
[hub_routes."hub/advisor"]
|
|
112
|
+
type = "advisor"
|
|
113
|
+
efficient = "fast"
|
|
114
|
+
capable = "coding"
|
|
115
|
+
max_reviews = 1
|
|
116
|
+
trigger = "no_tool_call"
|
|
117
|
+
gate_min_tool_results = 1
|
|
118
|
+
|
|
119
|
+
[hub_routes."hub/escalation"]
|
|
120
|
+
type = "escalation"
|
|
121
|
+
efficient = "fast"
|
|
122
|
+
capable = "coding"
|
|
123
|
+
confirmations = 2
|