agents-relay 2.0.17 → 2.0.19
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/dist/dashboard-html.js +12 -15
- package/dist/dashboard.js +45 -1
- package/dist/execution-routing.js +35 -20
- package/dist/relayd.js +38 -1
- package/dist/router-evals.js +70 -0
- package/package.json +1 -1
package/dist/dashboard-html.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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('Calling router with current status…')));try{const response=await fetch('/api/router-test',{method:'POST'});if(!response.ok)throw new Error(response.status+' '+await response.text());const result=await response.json();const selected=result.selected||{};const current=result.current||{};routerTestResults.replaceChildren(element('strong','',text((selected.worker||'—')+' · '+(selected.model||'—'))));routerTestResults.append(element('div','subtle',text('route: '+(selected.route||'—'))),element('div','subtle',text('fallback: '+String(Boolean(result.fallback)))),element('pre','',text('probabilities: '+JSON.stringify(result.probabilities||{},null,2)+String.fromCharCode(10)+'browser workspace: '+JSON.stringify(current.browserWorkspace||null,null,2)+String.fromCharCode(10)+'chatgpt running: '+String(current.chatgptRunning??0))));}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, routerTestProvider, 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-test') {
|
|
446
|
+
if (req.method !== 'POST') {
|
|
447
|
+
res.writeHead(405, { allow: 'POST' });
|
|
448
|
+
res.end('Method not allowed');
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!routerTestProvider) {
|
|
452
|
+
res.writeHead(503);
|
|
453
|
+
res.end('Router test unavailable');
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const result = await routerTestProvider();
|
|
458
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
459
|
+
res.end(JSON.stringify(result));
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
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,8 @@ 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 { workerCandidates } from './worker-routing.js';
|
|
28
|
+
import { routeExecution } from './execution-routing.js';
|
|
27
29
|
export async function overlayOperationalOverviews(overviews, storeFor) {
|
|
28
30
|
return await Promise.all(overviews.map(async (item) => {
|
|
29
31
|
const store = storeFor(item.job.repository, item.job.prNumber);
|
|
@@ -346,7 +348,42 @@ export async function runDaemon(argv) {
|
|
|
346
348
|
taskCancel: workspaceTaskAction('cancel'),
|
|
347
349
|
taskComplete: workspaceTaskComplete,
|
|
348
350
|
taskSetState: workspaceTaskSetState,
|
|
349
|
-
})
|
|
351
|
+
}, async () => {
|
|
352
|
+
const usage = await usageRegistry.snapshot();
|
|
353
|
+
const runtime = summarizeRuntime(cachedOverviews.map(item => item.job));
|
|
354
|
+
const workers = workerCandidates([
|
|
355
|
+
{ name: 'codex', id: 'codex', capabilities: ['model', 'codex'] },
|
|
356
|
+
{ name: 'chatgpt', id: 'chatgpt/browser-worker', capabilities: ['model', 'chatgpt', 'browser-harness'] },
|
|
357
|
+
], [], usage);
|
|
358
|
+
const result = await routeExecution(workers, { capabilities: [] }, {
|
|
359
|
+
decisionEngine: new LayaCliDecisionEngine(),
|
|
360
|
+
decisionMode: 'active',
|
|
361
|
+
taskFacts: { storySize: 'M', role: 'router-test' },
|
|
362
|
+
usage,
|
|
363
|
+
chatgptRunning: runtime.workers.chatgpt,
|
|
364
|
+
});
|
|
365
|
+
return {
|
|
366
|
+
selected: {
|
|
367
|
+
route: result.audit?.selected?.id,
|
|
368
|
+
worker: result.selection.workerId,
|
|
369
|
+
adapter: result.selection.adapter,
|
|
370
|
+
provider: result.candidate.routing.provider,
|
|
371
|
+
model: result.candidate.routing.model,
|
|
372
|
+
profile: result.candidate.routing.profile,
|
|
373
|
+
reasoning: result.candidate.routing.reasoning,
|
|
374
|
+
},
|
|
375
|
+
probabilities: result.candidate.routing.decisionEvidence?.probabilities ?? {},
|
|
376
|
+
fallback: result.candidate.routing.decisionEvidence?.fallback ?? false,
|
|
377
|
+
current: {
|
|
378
|
+
chatgptRunning: runtime.workers.chatgpt,
|
|
379
|
+
browserWorkspace: usage.browserWorkspace ?? null,
|
|
380
|
+
providers: usage.providers,
|
|
381
|
+
credits: usage.credits,
|
|
382
|
+
},
|
|
383
|
+
decisionInput: result.audit?.decision_input ?? null,
|
|
384
|
+
gates: result.audit?.gate_results ?? [],
|
|
385
|
+
};
|
|
386
|
+
}, async () => (await decisionAuditStore.read()).filter((record) => record.record_type === 'decision' && record.schema_version === 5));
|
|
350
387
|
await once(server, 'listening');
|
|
351
388
|
const reconcileSafely = async () => {
|
|
352
389
|
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
|
+
}
|