agents-relay 2.0.27 → 2.0.28

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.
@@ -329,6 +329,8 @@ const displayedEventIds=new Set();
329
329
  function value(input){return input===null||input===undefined||input===''?'—':String(input);}
330
330
  function text(input){return document.createTextNode(value(input));}
331
331
  function element(name,className,child){const node=document.createElement(name);if(className)node.className=className;if(child)node.append(child);return node;}
332
+ function yamlScalar(input){if(input===null)return 'null';if(typeof input==='boolean'||typeof input==='number')return String(input);return JSON.stringify(String(input));}
333
+ function yamlDump(input,indent=0){const pad=' '.repeat(indent);if(Array.isArray(input)){if(!input.length)return pad+'[]';return input.map(item=>{if(item&&typeof item==='object'){return pad+'-\\n'+yamlDump(item,indent+2);}return pad+'- '+yamlScalar(item);}).join('\\n');}if(input&&typeof input==='object'){const entries=Object.entries(input);if(!entries.length)return pad+'{}';const lines=[];for(const [key,item] of entries){if(typeof item==='string'&&item.includes('\\n')){lines.push(pad+key+': |');for(const line of item.replace(/\\n$/,'').split('\\n'))lines.push(' '.repeat(indent+2)+line);continue;}if(item&&typeof item==='object'){lines.push(pad+key+':');lines.push(yamlDump(item,indent+2));}else{lines.push(pad+key+': '+yamlScalar(item));}}return lines.join('\\n');}return pad+yamlScalar(input);}
332
334
  function field(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),element('p','detail-value',text(input)));return box;}
333
335
  function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
334
336
  function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
@@ -451,7 +453,7 @@ function routeWorker(record){const id=record?.selected?.worker?.id||record?.work
451
453
  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
454
  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
455
  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{if(!selectedJob||!selectedTaskId)throw new Error('Select a Job and Task first');const response=await fetch('/api/router-test',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({jobId:selectedJob,taskId:selectedTaskId})});if(!response.ok)throw new Error(response.status+' '+await response.text());const result=await response.json();const selected=result.selected||{};const payload={selected,probabilities:result.probabilities||{},fallback:Boolean(result.fallback),hard_gates:result.gates||[],laya_input:result.decisionInput||null};routerTestResults.replaceChildren(element('strong','',text((selected.worker||'—')+' · '+(selected.model||'—'))));routerTestResults.append(element('div','subtle',text('route: '+(selected.route||'—'))),element('pre','',text(JSON.stringify(payload,null,2))));}catch(error){routerTestResults.replaceChildren(element('p','error-state',text('Router test failed: '+error.message)));}finally{rulesTest.disabled=false;}}
456
+ async function testRouter(){rulesTest.disabled=true;routerTestResults.replaceChildren(element('p','subtle',text('Calling router with current status…')));try{if(!selectedJob||!selectedTaskId)throw new Error('Select a Job and Task first');const response=await fetch('/api/router-test',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({jobId:selectedJob,taskId:selectedTaskId})});if(!response.ok)throw new Error(response.status+' '+await response.text());const result=await response.json();const selected=result.selected||{};const payload={selected,proposed_fields:result.proposedFields||{},probabilities:result.probabilities||{},fallback:Boolean(result.fallback),hard_gates:result.gates||[],laya_input:result.decisionInput||null};routerTestResults.replaceChildren(element('strong','',text((selected.worker||'—')+' · '+(selected.model||'—'))));routerTestResults.append(element('div','subtle',text('route: '+(selected.route||'—'))),element('pre','',text(yamlDump(payload))));}catch(error){routerTestResults.replaceChildren(element('p','error-state',text('Router test failed: '+error.message)));}finally{rulesTest.disabled=false;}}
455
457
  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;}}
456
458
  async function drawShortcuts(){shortcutsStatus.textContent='';shortcutsText.value=chatShortcutText();}
457
459
  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.';}
@@ -1,4 +1,3 @@
1
- import { chooseWithDecision } from './decision.js';
2
1
  import { makeExecutionDecisionAudit } from './decision-audit.js';
3
2
  import { discoverModelCandidates } from './model-routing.js';
4
3
  import { loadExecutionRouterRules, loadRoutingPolicy, policyAllows } from './routing-policy.js';
@@ -60,6 +59,115 @@ function modelGateReasons(candidate, constraints, excludedRoutes, policy) {
60
59
  reasons.push('reasoning_constraint');
61
60
  return reasons;
62
61
  }
62
+ const REASONING_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
63
+ function setupName(worker, model) {
64
+ if (worker.adapter === 'chatgpt')
65
+ return 'account-defaults';
66
+ if (model.id.startsWith('default:') || model.id.startsWith('catalog:'))
67
+ return 'default';
68
+ return model.profile ?? model.provider;
69
+ }
70
+ function setupKey(worker, model) {
71
+ return worker.workerId + '/' + setupName(worker, model);
72
+ }
73
+ function configuredDefault(models, setup) {
74
+ if (setup === 'account-defaults')
75
+ return models[0];
76
+ if (setup === 'default')
77
+ return models.find(model => model.id.startsWith('default:'));
78
+ return models.find(model => model.profile === setup && model.id.startsWith(setup + ':'));
79
+ }
80
+ function resolveModelAlias(raw, models, familyHint) {
81
+ const normalized = raw.trim().toLowerCase();
82
+ const exact = models.find(model => model.model.toLowerCase() === normalized);
83
+ if (exact)
84
+ return exact.model;
85
+ if (familyHint) {
86
+ const sameFamily = models.find(model => model.model.toLowerCase() === familyHint.toLowerCase() + '-' + normalized);
87
+ if (sameFamily)
88
+ return sameFamily.model;
89
+ }
90
+ const suffix = [...new Set(models.filter(model => model.model.toLowerCase().endsWith('-' + normalized)).map(model => model.model))];
91
+ return suffix.length === 1 ? suffix[0] : raw.trim();
92
+ }
93
+ function userModelOverrides(rulesText, defaults, models) {
94
+ const result = [];
95
+ for (const line of rulesText.split(/\r?\n/)) {
96
+ const match = line.match(/^\s*([a-z0-9._-]+)\s*:\s*(.+)$/i);
97
+ if (!match)
98
+ continue;
99
+ const label = match[1].toLowerCase();
100
+ const base = defaults.find(model => (model.profile ?? (model.provider === 'openai' ? 'default' : model.provider)).toLowerCase() === label || model.provider.toLowerCase() === label);
101
+ if (!base)
102
+ continue;
103
+ const sameSetupModels = models.filter(model => model.provider === base.provider && (model.profile ?? null) === (base.profile ?? null));
104
+ const familyHint = match[2].match(/\b(gpt-\d+(?:\.\d+)?)-[a-z0-9.-]+/i)?.[1];
105
+ for (const segment of match[2].split(',')) {
106
+ const item = segment.trim().match(/^(.+?)\s+(low|medium|high|xhigh|max|ultra)(?:\s|$)/i);
107
+ if (!item)
108
+ continue;
109
+ const model = resolveModelAlias(item[1].trim(), sameSetupModels, familyHint);
110
+ const reasoning = item[2].toLowerCase();
111
+ if (result.some(candidate => candidate.provider === base.provider && candidate.model === model && (candidate.profile ?? null) === (base.profile ?? null) && candidate.reasoning === reasoning))
112
+ continue;
113
+ result.push({
114
+ ...base,
115
+ id: (base.profile ?? 'default') + ':user/' + model,
116
+ model,
117
+ reasoning,
118
+ });
119
+ }
120
+ }
121
+ return result;
122
+ }
123
+ function codePlan(model) {
124
+ return {
125
+ five_hour: model.resourceWindows?.fiveHour ? {
126
+ remaining_percent: model.resourceWindows.fiveHour.remainingPercent ?? null,
127
+ reset_at: model.resourceWindows.fiveHour.resetAt ?? null,
128
+ reset_in_minutes: model.resourceWindows.fiveHour.resetInMinutes ?? null,
129
+ } : null,
130
+ weekly: model.resourceWindows?.weekly ? {
131
+ remaining_percent: model.resourceWindows.weekly.remainingPercent ?? null,
132
+ reset_at: model.resourceWindows.weekly.resetAt ?? null,
133
+ reset_in_minutes: model.resourceWindows.weekly.resetInMinutes ?? null,
134
+ } : null,
135
+ };
136
+ }
137
+ function workerProviderFacts(routes) {
138
+ const workers = new Map();
139
+ for (const route of routes) {
140
+ const list = workers.get(route.worker.workerId) ?? [];
141
+ list.push(route);
142
+ workers.set(route.worker.workerId, list);
143
+ }
144
+ return [...workers.entries()].map(([workerId, workerRoutes]) => {
145
+ const providers = new Map();
146
+ for (const route of workerRoutes) {
147
+ const list = providers.get(route.model.provider) ?? [];
148
+ list.push(route);
149
+ providers.set(route.model.provider, list);
150
+ }
151
+ return {
152
+ id: workerId,
153
+ adapter: workerRoutes[0].worker.adapter,
154
+ providers: Object.fromEntries([...providers.entries()].map(([provider, providerRoutes]) => {
155
+ const models = providerRoutes.map(route => route.model);
156
+ const configured = models.find(model => !model.id.includes(':user/')) ?? models[0];
157
+ return [provider, {
158
+ default: { model: configured.model, reasoning: configured.reasoning ?? null },
159
+ code_plan: codePlan(configured),
160
+ }];
161
+ })),
162
+ };
163
+ });
164
+ }
165
+ function decisionRouteLabel(route) {
166
+ const prefix = route.worker.workerId + '/' + route.model.provider + '/';
167
+ if (!route.model.id.includes(':user/'))
168
+ return prefix + 'default';
169
+ return prefix + route.model.model + (route.model.reasoning ? '@' + route.model.reasoning : '');
170
+ }
63
171
  function routeId(worker, model) {
64
172
  return worker.workerId + '::' + model.id + (model.reasoning ? '@' + model.reasoning : '');
65
173
  }
@@ -95,7 +203,11 @@ export async function discoverExecutionRoutes(workers, requirements, options = {
95
203
  const evaluations = [];
96
204
  for (const worker of workers) {
97
205
  const workerReasons = workerGateEvaluations.find(item => item.workerId === worker.workerId)?.rejectionReasons ?? [];
98
- const models = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
206
+ const discoveredModels = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
207
+ const configuredModels = discoveredModels.filter(model => !model.id.startsWith('catalog:'));
208
+ const defaults = configuredModels.filter(model => model.id.startsWith('default:') || (model.profile && model.id.startsWith(model.profile + ':')) || worker.adapter === 'chatgpt');
209
+ const advertised = userModelOverrides(policy.rules.text, defaults, discoveredModels);
210
+ const models = [...configuredModels, ...advertised];
99
211
  for (const model of models) {
100
212
  const rejectionReasons = [...workerReasons, ...modelGateReasons(model, options.routingConstraints, options.excludedRoutes, policy)];
101
213
  const route = { id: routeId(worker, model), worker, model };
@@ -136,36 +248,51 @@ export async function routeExecution(workers, requirements, options = {}) {
136
248
  chatgpt_running: chatgptRunning,
137
249
  preferred_chatgpt_concurrency: preferredChatgptConcurrency,
138
250
  },
139
- eligible_routes: routes.map(route => ({
140
- id: route.id,
141
- worker: route.worker.adapter,
142
- provider: route.model.provider,
143
- model: route.model.model,
144
- profile: route.model.profile ?? null,
145
- reasoning: route.model.reasoning ?? null,
146
- code_plan: {
147
- five_hour: route.model.resourceWindows?.fiveHour ? {
148
- remaining_percent: route.model.resourceWindows.fiveHour.remainingPercent ?? null,
149
- reset_at: route.model.resourceWindows.fiveHour.resetAt ?? null,
150
- reset_in_minutes: route.model.resourceWindows.fiveHour.resetInMinutes ?? null,
151
- } : null,
152
- weekly: route.model.resourceWindows?.weekly ? {
153
- remaining_percent: route.model.resourceWindows.weekly.remainingPercent ?? null,
154
- reset_at: route.model.resourceWindows.weekly.resetAt ?? null,
155
- reset_in_minutes: route.model.resourceWindows.weekly.resetInMinutes ?? null,
156
- } : null,
157
- },
158
- })),
251
+ worker_candidates: workerProviderFacts(routes),
252
+ routing_instruction: 'For each selected provider, use its default model and reasoning unless user_input_rules explicitly provides another model/reasoning option. Any override must be compatible with the selected worker/provider and pass runtime validation.',
159
253
  }));
254
+ const decisionChoices = routes.map(decisionRouteLabel);
160
255
  const decisionRequest = { facts };
161
256
  const decisionContract = {
162
257
  type: 'choice',
163
- question: 'Choose one eligible complete execution route. Treat context.task_shape.preferred_worker as the default worker-fit signal; user_input_rules and concrete runtime or code-plan resource facts may override it. The choice identifies worker, provider, model, profile, and reasoning together.',
164
- choices: routes.map(route => route.id),
258
+ question: 'Choose one legal route. Each provider default is available. Additional model/reasoning routes exist only when explicitly provided by user_input_rules. Use context, code-plan quota, runtime load, and user_input_rules.',
259
+ choices: decisionChoices,
165
260
  };
166
- const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', decisionRequest, decisionContract, () => fallback.id);
167
- const selected = routes.find(route => route.id === choice.choice) ?? fallback;
168
- const selectedBy = choice.evidence.engine && !choice.evidence.fallback ? 'execution-router:laya' : 'execution-router:fallback';
261
+ let selected = fallback;
262
+ let evidence = { mode: options.decisionMode ?? 'off', fallback: true };
263
+ if ((options.decisionMode ?? 'off') !== 'off' && options.decisionEngine) {
264
+ try {
265
+ const decision = await options.decisionEngine.decide(decisionRequest, decisionContract);
266
+ if (decision.type !== 'choice')
267
+ throw new Error('Execution router expected a route choice result');
268
+ const index = decisionChoices.indexOf(decision.choice);
269
+ if (index < 0)
270
+ throw new Error('Execution router returned an unknown route');
271
+ selected = routes[index];
272
+ evidence = {
273
+ mode: options.decisionMode ?? 'active',
274
+ engine: options.decisionEngine.name,
275
+ proposed: decision.choice,
276
+ probabilities: decision.probabilities,
277
+ proposedFields: {
278
+ worker: selected.worker.workerId,
279
+ provider: selected.model.provider,
280
+ model: selected.model.model,
281
+ reasoning: selected.model.reasoning ?? '',
282
+ },
283
+ fallback: false,
284
+ };
285
+ }
286
+ catch (error) {
287
+ evidence = {
288
+ mode: options.decisionMode ?? 'active',
289
+ engine: options.decisionEngine.name,
290
+ fallback: true,
291
+ error: error instanceof Error ? error.message : String(error),
292
+ };
293
+ }
294
+ }
295
+ const selectedBy = evidence.engine && !evidence.fallback ? 'execution-router:laya' : 'execution-router:fallback';
169
296
  const routing = {
170
297
  provider: selected.model.provider,
171
298
  model: selected.model.model,
@@ -173,7 +300,7 @@ export async function routeExecution(workers, requirements, options = {}) {
173
300
  reasoning: options.routingConstraints?.reasoning ?? selected.model.reasoning,
174
301
  decidedBy: selectedBy,
175
302
  decidedAt: new Date().toISOString(),
176
- decisionEvidence: choice.evidence,
303
+ decisionEvidence: evidence,
177
304
  };
178
305
  const audit = makeExecutionDecisionAudit({
179
306
  task: options.auditTask,
@@ -200,7 +327,7 @@ export async function routeExecution(workers, requirements, options = {}) {
200
327
  await options.auditStore.append(audit);
201
328
  return {
202
329
  candidate: { ...selected.worker, routing },
203
- selection: workerSelection(selected.worker, selectedBy, choice.evidence),
330
+ selection: workerSelection(selected.worker, selectedBy, evidence),
204
331
  model: selected.model,
205
332
  audit,
206
333
  };
package/dist/relayd.js CHANGED
@@ -397,6 +397,7 @@ export async function runDaemon(argv) {
397
397
  reasoning: result.candidate.routing.reasoning,
398
398
  },
399
399
  probabilities: result.candidate.routing.decisionEvidence?.probabilities ?? {},
400
+ proposedFields: result.candidate.routing.decisionEvidence?.proposedFields ?? {},
400
401
  fallback: result.candidate.routing.decisionEvidence?.fallback ?? false,
401
402
  current: {
402
403
  chatgptRunning: runtime.workers.chatgpt,
@@ -12,12 +12,12 @@ export const ROUTER_EVAL_CASES = [
12
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
13
  { id: 'large-workspace-available', storySize: 'L', task: 'Implement a large feature even though ChatGPT workspace capacity is healthy.', chatgptAvailable: true, expectedWorker: 'codex' },
14
14
  ];
15
- const CODEX_ROUTE = 'codex::default:openai/gpt-5.6-sol@medium';
16
- const CHATGPT_ROUTE = 'chatgpt/browser-worker::chatgpt/account-defaults';
15
+ const CODEX_ROUTE = 'codex/openai/default';
16
+ const CHATGPT_ROUTE = 'chatgpt/browser-worker/chatgpt/default';
17
17
  function workerForRoute(route) {
18
18
  if (route.startsWith('chatgpt/'))
19
19
  return 'chatgpt';
20
- if (route.startsWith('codex::'))
20
+ if (route.startsWith('codex/'))
21
21
  return 'codex';
22
22
  return 'unknown';
23
23
  }
@@ -28,29 +28,31 @@ export async function runRouterEvals(engine, rulesPath) {
28
28
  const taskShape = executionTaskShape({ storySize: testCase.storySize });
29
29
  const available = testCase.chatgptAvailable;
30
30
  const choices = available ? [CODEX_ROUTE, CHATGPT_ROUTE] : [CODEX_ROUTE];
31
- const eligibleRoutes = [
31
+ const workerCandidates = [
32
32
  {
33
- id: CODEX_ROUTE,
34
- worker: 'codex',
35
- provider: 'openai',
36
- model: 'gpt-5.6-sol',
37
- profile: null,
38
- reasoning: 'medium',
39
- code_plan: { five_hour: null, weekly: null },
33
+ id: 'codex',
34
+ adapter: 'codex',
35
+ providers: {
36
+ openai: {
37
+ default: { model: 'gpt-5.6-sol', reasoning: 'medium' },
38
+ code_plan: { five_hour: null, weekly: null },
39
+ },
40
+ },
40
41
  },
41
42
  ...(available ? [{
42
- id: CHATGPT_ROUTE,
43
- worker: 'chatgpt',
44
- provider: 'chatgpt',
45
- model: 'account-defaults',
46
- profile: null,
47
- reasoning: null,
48
- code_plan: { five_hour: null, weekly: null },
43
+ id: 'chatgpt/browser-worker',
44
+ adapter: 'chatgpt',
45
+ providers: {
46
+ chatgpt: {
47
+ default: { model: 'account-defaults', reasoning: null },
48
+ code_plan: { five_hour: null, weekly: null },
49
+ },
50
+ },
49
51
  }] : []),
50
52
  ];
51
- const facts = {
53
+ const facts = JSON.parse(JSON.stringify({
52
54
  context: {
53
- job: { id: 'router-eval', title: 'Router eval', objective: 'Evaluate worker and model route selection.' },
55
+ job: { id: 'router-eval', title: 'Router eval', objective: 'Evaluate worker and provider route selection.' },
54
56
  task: { id: testCase.id, kind: 'work', storySize: testCase.storySize, input: testCase.task },
55
57
  task_shape: {
56
58
  story_size: taskShape.storySize,
@@ -62,13 +64,16 @@ export async function runRouterEvals(engine, rulesPath) {
62
64
  },
63
65
  user_input_rules: rules.text,
64
66
  runtime: { chatgpt_running: testCase.chatgptRunning ?? 0, preferred_chatgpt_concurrency: 10 },
65
- eligible_routes: eligibleRoutes,
66
- };
67
+ worker_candidates: workerCandidates,
68
+ routing_instruction: 'For each selected provider, use its default model and reasoning unless user_input_rules explicitly provides another model/reasoning option.',
69
+ }));
67
70
  const decision = await engine.decide({ facts }, {
68
71
  type: 'choice',
69
- question: 'Choose one eligible complete execution route. Treat context.task_shape.preferred_worker as the default worker-fit signal; user_input_rules and concrete runtime or code-plan resource facts may override it. The choice identifies worker, provider, model, profile, and reasoning together.',
72
+ question: 'Choose one legal route. Each provider default is available. Additional model/reasoning routes exist only when explicitly provided by user_input_rules. Use context, code-plan quota, runtime load, and user_input_rules.',
70
73
  choices,
71
74
  });
75
+ if (decision.type !== 'choice')
76
+ throw new Error('Router eval expected a single-choice decision');
72
77
  const selectedWorker = workerForRoute(decision.choice);
73
78
  results.push({
74
79
  ...testCase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.27",
3
+ "version": "2.0.28",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {