agents-relay 2.0.17 → 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.
@@ -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
@@ -57,7 +57,7 @@ async function eventHistory(jobId, taskId, limit) {
57
57
  }
58
58
  export { dashboardHtml, dashboardVersion } from './dashboard-html.js';
59
59
  import { dashboardHtml, dashboardVersion } from './dashboard-html.js';
60
- 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) {
61
61
  const legacyActions = typeof exactJobProvider === 'object' && exactJobProvider !== null ? exactJobProvider : undefined;
62
62
  const legacyExactJobProvider = !exactJobProvider && githubUsageProvider ? githubUsageProvider : undefined;
63
63
  const resolvedExactJobProvider = typeof exactJobProvider === 'function' ? exactJobProvider : legacyExactJobProvider;
@@ -442,6 +442,50 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
442
442
  res.end('Method not allowed');
443
443
  return;
444
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
+ }
445
489
  if (req.url === '/api/runtime-summary') {
446
490
  try {
447
491
  const jobs = await discoveredJobs();
@@ -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.17",
3
+ "version": "2.0.18",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {