agents-relay 2.0.16 → 2.0.18

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.
@@ -4,6 +4,7 @@ import { githubAuthContext } from './github-auth.js';
4
4
  import { NatsEventBus, eventFor } from './events.js';
5
5
  import { parseJobSchedule } from './scheduler.js';
6
6
  import { parseJobTagJson } from './task-tag.js';
7
+ import { resolveEventsBusRuntime } from './events-bus-runtime.js';
7
8
  export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 300000, dashboardRefreshMs: 300000, projectionFlushMs: 1000 };
8
9
  export class ServiceEventBus {
9
10
  upstream;
@@ -101,19 +102,21 @@ export async function relayTaskCommand(args, body) {
101
102
  export async function resolveEventBus(args, probe) {
102
103
  const explicit = hasArg(args, '--events');
103
104
  const requested = arg(args, '--events');
104
- const natsUrl = arg(args, '--nats-url', process.env.NEO_NATS_URL || 'nats://127.0.0.1:4222');
105
+ const runtime = resolveEventsBusRuntime();
106
+ const natsUrl = arg(args, '--nats-url', runtime.natsUrl);
105
107
  if (!explicit && hasArg(args, '--file'))
106
108
  return { bus: undefined, transport: 'none', source: 'local', natsUrl };
107
109
  if (explicit && requested !== 'nats')
108
110
  return { bus: undefined, transport: 'none', source: 'explicit', natsUrl };
109
- const bus = new NatsEventBus(natsUrl, arg(args, '--subject-prefix', 'neo.events.job'));
111
+ const subjectPrefix = arg(args, '--subject-prefix', runtime.subjectPrefix);
112
+ const bus = new NatsEventBus(natsUrl, subjectPrefix);
110
113
  if (explicit)
111
114
  return { bus, transport: 'nats', source: 'explicit', natsUrl };
112
115
  const reachable = probe ? await probe(bus) : await bus.ping().then(() => true, () => false);
113
116
  await bus.close().catch(() => undefined);
114
117
  if (!reachable)
115
118
  return { bus: undefined, transport: 'none', source: 'unavailable', natsUrl };
116
- return { bus: new NatsEventBus(natsUrl, arg(args, '--subject-prefix', 'neo.events.job')), transport: 'nats', source: 'auto', natsUrl };
119
+ return { bus: new NatsEventBus(natsUrl, subjectPrefix), transport: 'nats', source: 'auto', natsUrl };
117
120
  }
118
121
  export function eventBusResolutionMessage(resolution) {
119
122
  if (resolution.transport === 'nats')
@@ -176,7 +176,9 @@ export const dashboardHtml = `<!doctype html>
176
176
  .tagline { display:none; }
177
177
  }
178
178
  @media (prefers-reduced-motion:reduce) { .skeleton { animation:none; } .usage-fill { transition:none; } }
179
- </style>
179
+
180
+ .router-rules-workspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;align-items:start}.router-rules-pane{min-width:0}.audit-log{display:flex;flex-direction:column;gap:8px;max-height:560px;overflow:auto}.audit-entry{border:1px solid var(--border);border-radius:10px;padding:10px;background:var(--surface)}.audit-entry-head{display:flex;justify-content:space-between;gap:8px;align-items:center}.audit-entry pre{white-space:pre-wrap;word-break:break-word;margin:8px 0 0;font-size:11px}.router-test-results{margin-top:12px;display:flex;flex-direction:column;gap:6px}.router-test-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:8px;padding:7px 0;border-bottom:1px solid var(--border)}@media(max-width:900px){.router-rules-workspace{grid-template-columns:1fr}}
181
+ </style>
180
182
  </head>
181
183
  <body>
182
184
  <header>
@@ -233,18 +235,7 @@ export const dashboardHtml = `<!doctype html>
233
235
  </div>
234
236
  </div>
235
237
  </section>
236
- <section id="rules-view" role="tabpanel" aria-labelledby="rules-tab" hidden>
237
- <div class="panel">
238
- <div class="panel-head"><h2>Router Rules</h2><span id="rules-meta" class="subtle"></span></div>
239
- <div class="panel-body">
240
- <div class="rules-editor">
241
- <p class="rules-help">Free-text soft policy loaded by the Execution Router for every worker+model routing decision. Impossible routes remain excluded by code; Codex remains the deterministic fallback if decision fails.</p>
242
- <textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Execution router rules" placeholder="Example: Prefer ZAI glm-5.3-flash high while quota is available. Use ChatGPT for research when appropriate. Codex is fallback only."></textarea>
243
- <div class="rules-footer"><span id="rules-status" class="subtle" aria-live="polite"></span><button id="rules-save" class="rules-save" type="button">Save rules</button></div>
244
- </div>
245
- </div>
246
- </div>
247
- </section>
238
+ <section id="rules-view" role="tabpanel" aria-labelledby="rules-tab" hidden><div class="panel"><div class="panel-head"><h2>Router Rules</h2><span id="rules-meta" class="subtle"></span></div><div class="panel-body"><div class="router-rules-workspace"><div class="router-rules-pane rules-editor"><h3>Rules Editor</h3><p class="rules-help">Free-text soft policy loaded by the Execution Router for every worker+model routing decision. Impossible routes remain excluded by code; Codex remains the deterministic fallback if decision fails.</p><textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Execution router rules" placeholder="Example: Prefer ZAI glm-5.3-flash high while quota is available. Use ChatGPT for research when appropriate. Codex is fallback only."></textarea><div class="rules-footer"><span id="rules-status" class="subtle" aria-live="polite"></span><div><button id="rules-test" class="rules-save" type="button">Test Router</button> <button id="rules-save" class="rules-save" type="button">Save rules</button></div></div><div id="router-test-results" class="router-test-results" aria-live="polite"></div></div><div class="router-rules-pane"><div class="panel-head"><h3>Audit Log</h3><button id="audit-refresh" type="button">Refresh</button></div><div id="audit-log" class="audit-log" aria-live="polite"><p class="empty">Loading latest routing decisions…</p></div></div></div></div></div></section>
248
239
  <section id="shortcuts-view" role="tabpanel" aria-labelledby="shortcuts-tab" hidden>
249
240
  <div class="panel">
250
241
  <div class="panel-head"><h2>ChatGPT Shortcuts</h2><span class="subtle">One per line · %1 = current repo/pr</span></div>
@@ -315,7 +306,7 @@ const graphSource=document.querySelector('#graph-source');
315
306
  const rulesText=document.querySelector('#rules-text');
316
307
  const rulesMeta=document.querySelector('#rules-meta');
317
308
  const rulesStatus=document.querySelector('#rules-status');
318
- const rulesSave=document.querySelector('#rules-save');
309
+ const rulesSave=document.querySelector('#rules-save');const rulesTest=document.querySelector('#rules-test');const routerTestResults=document.querySelector('#router-test-results');const auditLog=document.querySelector('#audit-log');const auditRefresh=document.querySelector('#audit-refresh');
319
310
  const shortcutsText=document.querySelector('#shortcuts-text');
320
311
  const shortcutsStatus=document.querySelector('#shortcuts-status');
321
312
  const shortcutsSave=document.querySelector('#shortcuts-save');
@@ -456,7 +447,11 @@ function selectedWorkflowGraph(){return workflowGraphs.find(graph=>graph.id===se
456
447
  function renderGraphList(){graphList.replaceChildren();if(!workflowGraphs.length){graphList.append(element('p','empty',text('No workflow graphs found.')));return;}for(const graph of workflowGraphs){const button=document.createElement('button');button.type='button';button.className='graph-button';button.setAttribute('aria-current',String(graph.id===selectedGraphId));button.append(element('strong','',text(graph.name)),element('span','',text(graph.path)));button.addEventListener('click',()=>{selectedGraphId=graph.id;sessionStorage.setItem('agents-relay:selected-graph',selectedGraphId);renderGraphList();void renderSelectedGraph();});graphList.append(button);}}
457
448
  async function renderSelectedGraph(){const graph=selectedWorkflowGraph();if(!graph){graphTitle.textContent='Select a graph';graphPath.textContent='';graphSource.textContent='';graphCanvas.replaceChildren(element('p','empty',text('No workflow graphs found.')));return;}selectedGraphId=graph.id;sessionStorage.setItem('agents-relay:selected-graph',selectedGraphId);graphTitle.textContent=graph.name;graphPath.textContent=graph.path;graphSource.textContent=graph.source;graphCanvas.replaceChildren(element('p','subtle',text('Rendering…')));try{if(!window.mermaid)throw new Error('Mermaid renderer unavailable');if(!window.__relayMermaidReady){window.mermaid.initialize({startOnLoad:false,securityLevel:'strict'});window.__relayMermaidReady=true;}const rendered=await window.mermaid.render('relay-graph-'+graph.id.replace(/[^A-Za-z0-9_-]/g,'-'),graph.source);const parsed=new DOMParser().parseFromString(rendered.svg,'image/svg+xml');const svg=parsed.documentElement;if(svg.nodeName.toLowerCase()!=='svg')throw new Error('Mermaid did not return SVG');graphCanvas.replaceChildren(document.importNode(svg,true));}catch(error){graphCanvas.replaceChildren(element('p','error-state',text('Visual preview unavailable: '+error.message)));}}
458
449
  async function drawGraphs(){try{const graphs=await loadJSON('/api/graphs');workflowGraphs=Array.isArray(graphs)?graphs:[];if(!workflowGraphs.some(graph=>graph.id===selectedGraphId))selectedGraphId=workflowGraphs[0]?.id||'';graphsMeta.textContent=workflowGraphs.length+' graph'+(workflowGraphs.length===1?'':'s')+' · workflow/*.mmd';renderGraphList();await renderSelectedGraph();}catch(error){graphsMeta.textContent='Unavailable';workflowGraphs=[];renderGraphList();graphCanvas.replaceChildren(element('p','error-state',text('Graphs unavailable: '+error.message)));}}
459
- async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/execution-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}}
450
+ function routeWorker(record){const id=record?.selected?.worker?.id||record?.worker?.id||'';return String(id).startsWith('chatgpt')?'ChatGPT':String(id).startsWith('codex')?'Codex':value(id);}
451
+ function renderAudit(records){auditLog.replaceChildren();if(!Array.isArray(records)||!records.length){auditLog.append(element('p','empty',text('No schema v5 routing decisions yet.')));return;}for(const record of records){const card=element('article','audit-entry');const head=element('div','audit-entry-head');head.append(element('strong','',text(routeWorker(record)+' · '+value(record?.selected?.model?.model))),element('span','subtle',text(new Date(record.timestamp).toLocaleString())));card.append(head);const correlation=record.correlation||{};card.append(element('div','subtle',text([correlation.job_id,correlation.task_id].filter(Boolean).join(' / ')||'uncorrelated')));const probabilities=record.decision_evidence?.probabilities||{};const gates=(record.gate_results||[]).filter(item=>!item.eligible).map(item=>item.candidate_id+': '+item.rejection_reasons.join(', '));const nl=String.fromCharCode(10);const details=document.createElement('pre');details.textContent='fallback: '+String(Boolean(record.decision_evidence?.fallback))+(Object.keys(probabilities).length?nl+'probabilities: '+JSON.stringify(probabilities,null,2):'')+(gates.length?nl+'rejected: '+gates.join(nl):'');card.append(details);auditLog.append(card);}}
452
+ async function drawAudit(){auditRefresh.disabled=true;try{renderAudit(await loadJSON('/api/decision-audit'));}catch(error){auditLog.replaceChildren(element('p','error-state',text('Audit unavailable: '+error.message)));}finally{auditRefresh.disabled=false;}}
453
+ async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/execution-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}void drawAudit();}
454
+ async function testRouter(){rulesTest.disabled=true;routerTestResults.replaceChildren(element('p','subtle',text('Running 10 Laya evals…')));try{const response=await fetch('/api/router-evals',{method:'POST'});if(!response.ok)throw new Error(response.status+' '+await response.text());const report=await response.json();routerTestResults.replaceChildren(element('strong','',text(report.passed+' / '+report.total+' passed')));for(const item of report.results||[]){const row=element('div','router-test-row');row.append(element('span','',text(item.id)),element('span',item.pass?'ok-text':'error-text',text(item.selectedWorker+' → expected '+item.expectedWorker)),element('span','subtle',text(item.pass?'PASS':'FAIL')));routerTestResults.append(row);}}catch(error){routerTestResults.replaceChildren(element('p','error-state',text('Router test failed: '+error.message)));}finally{rulesTest.disabled=false;}}
460
455
  async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/execution-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:rulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();rulesMeta.textContent='Saved · '+rules.hash.slice(0,12);rulesStatus.textContent='Saved. New routing decisions will load these rules.';}catch(error){rulesStatus.textContent='Save failed: '+error.message;}finally{rulesSave.disabled=false;}}
461
456
  async function drawShortcuts(){shortcutsStatus.textContent='';shortcutsText.value=chatShortcutText();}
462
457
  function saveShortcuts(){const value=shortcutsText.value.trim();if(value)localStorage.setItem(CHAT_SHORTCUTS_KEY,value);else localStorage.removeItem(CHAT_SHORTCUTS_KEY);shortcutsText.value=chatShortcutText();shortcutsStatus.textContent='Saved.';}
@@ -478,6 +473,8 @@ graphsTab.addEventListener('click',()=>{location.hash='#/graphs';showView('graph
478
473
  rulesTab.addEventListener('click',()=>{location.hash='#/execution-router-rules';showView('rules');});
479
474
  shortcutsTab.addEventListener('click',()=>{location.hash='#/shortcuts';showView('shortcuts');});
480
475
  rulesSave.addEventListener('click',()=>void saveRules());
476
+ rulesTest.addEventListener('click',()=>void testRouter());
477
+ auditRefresh.addEventListener('click',()=>void drawAudit());
481
478
  shortcutsSave.addEventListener('click',saveShortcuts);
482
479
  window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();const views=['jobs','usage','graphs','rules','shortcuts'];const current=location.hash==='#/usage'?'usage':location.hash==='#/graphs'?'graphs':location.hash==='#/execution-router-rules'?'rules':location.hash==='#/shortcuts'?'shortcuts':'jobs';const next=views[(views.indexOf(current)+1)%views.length];location.hash=next==='usage'?'#/usage':next==='graphs'?'#/graphs':next==='rules'?'#/execution-router-rules':next==='shortcuts'?'#/shortcuts':(selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs');showView(next);}});
483
480
  function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
package/dist/dashboard.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { readFile, readdir } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
3
  import { join } from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import { readUsageSnapshot } from './usage.js';
@@ -8,9 +7,9 @@ import { deriveJobRuntimeStatus } from './runtime-status.js';
8
7
  import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
9
8
  import { loadExecutionRouterRules, saveExecutionRouterRules } from './routing-policy.js';
10
9
  import { summarizeRuntime } from './runtime-summary.js';
10
+ import { eventsBusHistory } from './events-bus-runtime.js';
11
11
  const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
12
12
  const SSE_HEARTBEAT_MS = 15000;
13
- const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
14
13
  const WORKFLOW_DIR = fileURLToPath(new URL('../workflow/', import.meta.url));
15
14
  export async function dashboardWorkflowGraphs() {
16
15
  const names = (await readdir(WORKFLOW_DIR)).filter(name => name.endsWith('.mmd')).sort();
@@ -49,34 +48,16 @@ export function githubPrUrl(repository, prNumber) {
49
48
  return `https://github.com/${owner}/${repo}/pull/${prNumber}`;
50
49
  }
51
50
  async function eventHistory(jobId, taskId, limit) {
52
- const boundedLimit = Math.max(1, Math.min(60, limit));
53
- let content = '';
54
51
  try {
55
- content = await readFile(EVENTS_HISTORY_FILE, 'utf8');
52
+ return await eventsBusHistory(jobId, taskId, limit);
56
53
  }
57
54
  catch {
58
55
  return [];
59
56
  }
60
- const output = [];
61
- const lines = content.split(/\r?\n/);
62
- for (let index = lines.length - 1; index >= 0 && output.length < boundedLimit; index -= 1) {
63
- const line = lines[index];
64
- if (!line)
65
- continue;
66
- try {
67
- const row = JSON.parse(line);
68
- const event = row.event;
69
- if (!event || event.job_id !== jobId || (taskId && event.task_id !== taskId))
70
- continue;
71
- output.push(event);
72
- }
73
- catch { /* ignore malformed historical rows */ }
74
- }
75
- return output;
76
57
  }
77
58
  export { dashboardHtml, dashboardVersion } from './dashboard-html.js';
78
59
  import { dashboardHtml, dashboardVersion } from './dashboard-html.js';
79
- export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobId, usageRegistry, overviewProvider, webhook, statusProvider, githubUsageProvider, exactJobProvider, actions) {
60
+ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobId, usageRegistry, overviewProvider, webhook, statusProvider, githubUsageProvider, exactJobProvider, actions, routerEvalProvider, decisionAuditProvider) {
80
61
  const legacyActions = typeof exactJobProvider === 'object' && exactJobProvider !== null ? exactJobProvider : undefined;
81
62
  const legacyExactJobProvider = !exactJobProvider && githubUsageProvider ? githubUsageProvider : undefined;
82
63
  const resolvedExactJobProvider = typeof exactJobProvider === 'function' ? exactJobProvider : legacyExactJobProvider;
@@ -461,6 +442,50 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
461
442
  res.end('Method not allowed');
462
443
  return;
463
444
  }
445
+ if (req.url === '/api/router-evals') {
446
+ if (req.method !== 'POST') {
447
+ res.writeHead(405, { allow: 'POST' });
448
+ res.end('Method not allowed');
449
+ return;
450
+ }
451
+ if (!routerEvalProvider) {
452
+ res.writeHead(503);
453
+ res.end('Router evals unavailable');
454
+ return;
455
+ }
456
+ try {
457
+ const report = await routerEvalProvider();
458
+ res.writeHead(200, { 'content-type': 'application/json' });
459
+ res.end(JSON.stringify(report));
460
+ }
461
+ catch (error) {
462
+ res.writeHead(500);
463
+ res.end(error instanceof Error ? error.message : String(error));
464
+ }
465
+ return;
466
+ }
467
+ if (req.url === '/api/decision-audit') {
468
+ if (req.method !== 'GET') {
469
+ res.writeHead(405, { allow: 'GET' });
470
+ res.end('Method not allowed');
471
+ return;
472
+ }
473
+ if (!decisionAuditProvider) {
474
+ res.writeHead(200, { 'content-type': 'application/json' });
475
+ res.end('[]');
476
+ return;
477
+ }
478
+ try {
479
+ const records = await decisionAuditProvider();
480
+ res.writeHead(200, { 'content-type': 'application/json' });
481
+ res.end(JSON.stringify(records.slice(-30).reverse()));
482
+ }
483
+ catch (error) {
484
+ res.writeHead(500);
485
+ res.end(error instanceof Error ? error.message : String(error));
486
+ }
487
+ return;
488
+ }
464
489
  if (req.url === '/api/runtime-summary') {
465
490
  try {
466
491
  const jobs = await discoveredJobs();
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ export function eventsBusConfigPath(env = process.env) {
5
+ if (env.NEO_EVENTS_BUS_CONFIG)
6
+ return env.NEO_EVENTS_BUS_CONFIG;
7
+ const dataDir = env.EVENTS_BUS_DATA_DIR || join(homedir(), 'Library', 'Application Support', 'EventsBus');
8
+ return join(dataDir, 'runtime.json');
9
+ }
10
+ export function resolveEventsBusRuntime(env = process.env) {
11
+ const configPath = eventsBusConfigPath(env);
12
+ let saved = {};
13
+ try {
14
+ saved = JSON.parse(readFileSync(configPath, 'utf8'));
15
+ }
16
+ catch { /* fall through to protocol defaults */ }
17
+ return {
18
+ configPath,
19
+ natsUrl: env.NEO_NATS_URL || String(saved.natsUrl || 'nats://127.0.0.1:4222'),
20
+ apiUrl: env.NEO_EVENTS_API_URL || String(saved.apiUrl || 'http://127.0.0.1:4223'),
21
+ subjectPrefix: env.NEO_EVENTS_SUBJECT_PREFIX || String(saved.subjectPrefix || 'neo.events.job'),
22
+ };
23
+ }
24
+ export async function eventsBusHistory(jobId, taskId, limit, env = process.env) {
25
+ const runtime = resolveEventsBusRuntime(env);
26
+ const url = new URL('/events', runtime.apiUrl);
27
+ url.searchParams.set('job', jobId);
28
+ if (taskId)
29
+ url.searchParams.set('task', taskId);
30
+ const boundedLimit = Math.max(1, Math.min(60, limit));
31
+ url.searchParams.set('limit', String(boundedLimit));
32
+ url.searchParams.set('order', 'desc');
33
+ const response = await fetch(url);
34
+ if (!response.ok)
35
+ throw new Error(`events-bus history unavailable: HTTP ${response.status}`);
36
+ const body = await response.json();
37
+ const rows = Array.isArray(body.events) ? body.events : [];
38
+ return rows.map(row => row.event).filter((event) => Boolean(event));
39
+ }
@@ -3,6 +3,27 @@ import { makeExecutionDecisionAudit } from './decision-audit.js';
3
3
  import { discoverModelCandidates } from './model-routing.js';
4
4
  import { loadExecutionRouterRules, loadRoutingPolicy, policyAllows } from './routing-policy.js';
5
5
  import { DEFAULT_CHATGPT_PREFERRED_CONCURRENCY } from './worker-routing.js';
6
+ export function executionTaskShape(taskFacts = {}) {
7
+ const raw = taskFacts.storySize;
8
+ const storySize = raw === 'S' || raw === 'L' ? raw : 'M';
9
+ const sustainedWork = storySize === 'L';
10
+ return {
11
+ storySize,
12
+ sustainedWork,
13
+ preferredWorker: sustainedWork ? 'codex' : 'chatgpt',
14
+ reason: sustainedWork
15
+ ? 'Large/sustained tasks favor Codex.'
16
+ : 'Small/medium tasks favor ChatGPT when Browser Workspace capacity is available.',
17
+ };
18
+ }
19
+ export const EXECUTION_ROUTING_PRINCIPLES = [
20
+ 'Choose one complete executable route: worker plus provider/model/profile/reasoning.',
21
+ 'ChatGPT with MacBridge and Codex have broadly overlapping execution capabilities.',
22
+ 'preferred_routes is the primary soft worker-fit preference derived from task size: S/M prefer ChatGPT, L prefers Codex.',
23
+ "Browser Workspace exhaustion is ChatGPT's primary hard execution constraint.",
24
+ 'Concrete user rules, resource health, quota, or load facts may override preferred_routes.',
25
+ 'Codex is only the deterministic fallback when the decision engine fails.',
26
+ ];
6
27
  function sameModelRoute(candidate, route) {
7
28
  return (!route.provider || candidate.provider === route.provider)
8
29
  && (!route.model || candidate.model === route.model)
@@ -94,35 +115,29 @@ export async function routeExecution(workers, requirements, options = {}) {
94
115
  const environmentRules = rules.text;
95
116
  const preferredChatgptConcurrency = options.preferredChatgptConcurrency ?? DEFAULT_CHATGPT_PREFERRED_CONCURRENCY;
96
117
  const chatgptRunning = options.chatgptRunning ?? 0;
118
+ const taskFacts = options.taskFacts ?? {};
119
+ const taskShape = executionTaskShape(taskFacts);
97
120
  const facts = JSON.parse(JSON.stringify({
98
- task: options.taskFacts ?? {},
99
- worker_requirements: requirements,
100
- explicit_routing_constraints: options.routingConstraints ?? {},
121
+ task_shape: {
122
+ story_size: taskShape.storySize,
123
+ sustained_work: taskShape.sustainedWork,
124
+ preferred_worker: taskShape.preferredWorker,
125
+ reason: taskShape.reason,
126
+ },
101
127
  environment_rules: environmentRules,
102
- structured_policy: policy.snapshot,
103
- usage_snapshot: options.usage ?? null,
128
+ reviewer_context: taskFacts.reviewer_diversity ? {
129
+ role: taskFacts.role ?? null,
130
+ reviewer_diversity: taskFacts.reviewer_diversity,
131
+ } : null,
104
132
  runtime_load: {
105
133
  chatgpt_running: chatgptRunning,
106
134
  preferred_chatgpt_concurrency: preferredChatgptConcurrency,
107
135
  },
108
- routing_principles: [
109
- 'Choose one complete executable route: worker plus provider/model/profile/reasoning.',
110
- 'Only authoritative impossibility constraints are hard gates; all other task fit, capability, trust, region, labels, quota, cost, latency, load, reviewer diversity, and quality signals are soft facts.',
111
- 'User free-text environment rules are soft routing guidance and should be followed when they express a clear preference.',
112
- 'Codex is only the deterministic fallback when the decision engine fails; Codex is not a normal preference when decide() succeeds.',
113
- ],
136
+ preferred_routes: routes.filter(route => route.worker.adapter === taskShape.preferredWorker).map(route => route.id),
114
137
  candidates: routes.map(route => ({
115
138
  id: route.id,
116
139
  worker: {
117
- id: route.worker.workerId,
118
140
  adapter: route.worker.adapter,
119
- runtime: route.worker.runtime ?? null,
120
- availability: route.worker.availability ?? null,
121
- capabilities: [...route.worker.capabilities],
122
- trustDomains: route.worker.trustDomains ?? [],
123
- region: route.worker.region ?? null,
124
- labels: route.worker.labels ?? {},
125
- browserWorkspaceCapacity: route.worker.browserWorkspaceCapacity ?? null,
126
141
  },
127
142
  model: {
128
143
  provider: route.model.provider,
@@ -141,7 +156,7 @@ export async function routeExecution(workers, requirements, options = {}) {
141
156
  const decisionRequest = { facts };
142
157
  const decisionContract = {
143
158
  type: 'choice',
144
- question: 'Choose the best complete execution route for this task. The listed candidates are already executable. Treat all non-impossibility signals as soft facts, follow free-text user guidance, and choose worker+model together in this one decision.',
159
+ question: 'Choose the best complete execution route. If preferred_routes is non-empty, choose from preferred_routes unless environment_rules explicitly override it or a preferred route is unhealthy/unavailable. Choose worker+model together in one decision.',
145
160
  choices: routes.map(route => route.id),
146
161
  };
147
162
  const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', decisionRequest, decisionContract, () => fallback.id);
package/dist/relayd.js CHANGED
@@ -24,6 +24,7 @@ import { decisionMode, LayaCliDecisionEngine } from './decision.js';
24
24
  import { JsonlDecisionAuditStore } from './decision-audit.js';
25
25
  import { summarizeRuntime } from './runtime-summary.js';
26
26
  import { executionRouterRulesPath } from './routing-policy.js';
27
+ import { runRouterEvals } from './router-evals.js';
27
28
  export async function overlayOperationalOverviews(overviews, storeFor) {
28
29
  return await Promise.all(overviews.map(async (item) => {
29
30
  const store = storeFor(item.job.repository, item.job.prNumber);
@@ -346,7 +347,7 @@ export async function runDaemon(argv) {
346
347
  taskCancel: workspaceTaskAction('cancel'),
347
348
  taskComplete: workspaceTaskComplete,
348
349
  taskSetState: workspaceTaskSetState,
349
- });
350
+ }, async () => await runRouterEvals(new LayaCliDecisionEngine()), async () => (await decisionAuditStore.read()).filter((record) => record.record_type === 'decision' && record.schema_version === 5));
350
351
  await once(server, 'listening');
351
352
  const reconcileSafely = async () => {
352
353
  try {
@@ -0,0 +1,70 @@
1
+ import { executionTaskShape } from './execution-routing.js';
2
+ import { loadExecutionRouterRules } from './routing-policy.js';
3
+ export const ROUTER_EVAL_CASES = [
4
+ { id: 'small-review', storySize: 'S', task: 'Review a focused PR and explain any correctness issue.', chatgptAvailable: true, expectedWorker: 'chatgpt' },
5
+ { id: 'medium-investigation', storySize: 'M', task: 'Investigate why a Relay job is blocked and propose a focused fix.', chatgptAvailable: true, expectedWorker: 'chatgpt' },
6
+ { id: 'small-code-fix', storySize: 'S', task: 'Apply a small configuration or one-file code fix and verify it.', chatgptAvailable: true, expectedWorker: 'chatgpt' },
7
+ { id: 'medium-browser-operation', storySize: 'M', task: 'Use browser and MacBridge tools to inspect state and make a bounded change.', chatgptAvailable: true, expectedWorker: 'chatgpt' },
8
+ { id: 'medium-near-capacity', storySize: 'M', task: 'Complete a normal engineering task while other ChatGPT work is active.', chatgptAvailable: true, chatgptRunning: 8, expectedWorker: 'chatgpt' },
9
+ { id: 'large-refactor', storySize: 'L', task: 'Implement a broad multi-file repository refactor with tests.', chatgptAvailable: true, expectedWorker: 'codex' },
10
+ { id: 'large-test-fix-loop', storySize: 'L', task: 'Run a sustained code-test-debug loop across a large change.', chatgptAvailable: true, expectedWorker: 'codex' },
11
+ { id: 'small-one-slot-left', storySize: 'S', task: 'Perform a short reasoning and operational task.', chatgptAvailable: true, chatgptSlots: 1, expectedWorker: 'chatgpt' },
12
+ { id: 'workspace-exhausted', storySize: 'S', task: 'Review a focused issue while Browser Workspace has no free slot.', chatgptAvailable: false, chatgptSlots: 0, expectedWorker: 'codex' },
13
+ { id: 'large-workspace-available', storySize: 'L', task: 'Implement a large feature even though ChatGPT workspace capacity is healthy.', chatgptAvailable: true, expectedWorker: 'codex' },
14
+ ];
15
+ const CODEX_ROUTE = 'codex::default:openai/gpt-5.6-sol';
16
+ const CHATGPT_ROUTE = 'chatgpt/browser-worker::chatgpt/account-defaults';
17
+ function workerForRoute(route) {
18
+ if (route.startsWith('chatgpt/'))
19
+ return 'chatgpt';
20
+ if (route.startsWith('codex::'))
21
+ return 'codex';
22
+ return 'unknown';
23
+ }
24
+ export async function runRouterEvals(engine, rulesPath) {
25
+ const rules = await loadExecutionRouterRules(rulesPath);
26
+ const results = [];
27
+ for (const testCase of ROUTER_EVAL_CASES) {
28
+ const taskShape = executionTaskShape({ storySize: testCase.storySize });
29
+ const available = testCase.chatgptAvailable;
30
+ const slots = testCase.chatgptSlots ?? (available ? 5 : 0);
31
+ const choices = available ? [CODEX_ROUTE, CHATGPT_ROUTE] : [CODEX_ROUTE];
32
+ const facts = {
33
+ task_shape: {
34
+ story_size: taskShape.storySize,
35
+ sustained_work: taskShape.sustainedWork,
36
+ preferred_worker: taskShape.preferredWorker,
37
+ reason: taskShape.reason,
38
+ },
39
+ environment_rules: rules.text,
40
+ runtime_load: { chatgpt_running: testCase.chatgptRunning ?? 0, preferred_chatgpt_concurrency: 10 },
41
+ preferred_routes: choices.filter(route => taskShape.preferredWorker === 'chatgpt' ? route.startsWith('chatgpt/') : route.startsWith('codex::')),
42
+ candidates: [
43
+ {
44
+ id: CODEX_ROUTE,
45
+ worker: { adapter: 'codex' },
46
+ model: { provider: 'openai', model: 'gpt-5.6-sol', profile: 'default', resourceHealthy: true },
47
+ },
48
+ ...(available ? [{
49
+ id: CHATGPT_ROUTE,
50
+ worker: { adapter: 'chatgpt' },
51
+ model: { provider: 'chatgpt', model: 'account-defaults', profile: 'account-defaults', resourceHealthy: true },
52
+ }] : []),
53
+ ],
54
+ };
55
+ const decision = await engine.decide({ facts }, {
56
+ type: 'choice',
57
+ question: 'Choose the best complete execution route. If preferred_routes is non-empty, choose from preferred_routes unless environment_rules explicitly override it or a preferred route is unhealthy/unavailable.',
58
+ choices,
59
+ });
60
+ const selectedWorker = workerForRoute(decision.choice);
61
+ results.push({
62
+ ...testCase,
63
+ selectedWorker,
64
+ selectedRoute: decision.choice,
65
+ pass: selectedWorker === testCase.expectedWorker,
66
+ probabilities: decision.probabilities,
67
+ });
68
+ }
69
+ return { passed: results.filter(item => item.pass).length, total: results.length, results };
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@ A running agent MUST use the canonical Neo `events-bus` protocol and publish exa
41
41
  - `task.failed` when the task cannot complete;
42
42
  - `task.blocked` when external or human action is required.
43
43
 
44
- Event publishing is not an Agents Relay CLI responsibility. Sandboxed Codex and hosted ChatGPT workers use the federated `events__publish` MCP tool from `events-bus`; direct non-sandbox local workers may use the caller-provided `NEO_EVENTS_EMIT`. Agents Relay subscribes to the canonical `neo.events.job.<job_id>.>` stream and maps terminal events into durable task state.
44
+ Event publishing is not an Agents Relay CLI responsibility. Sandboxed Codex and hosted ChatGPT workers use the federated `events__publish` MCP tool from `events-bus`; direct non-sandbox local workers may use the caller-provided `NEO_EVENTS_EMIT`. Agents Relay subscribes to the canonical `neo.events.job.<job_id>.>` stream and maps terminal events into durable task state. Relay resolves its native NATS endpoint and subject prefix from the canonical events-bus runtime configuration (with explicit Relay CLI flags remaining authoritative). The same runtime configuration supplies the events-bus HTTP API used for replay/history. Relay and its dashboard must not read the events-bus private history file or maintain a second event-history backend.
45
45
 
46
46
  The event must carry the exact durable `job_id` and `task_id`. Progress events are optional.
47
47