agents-relay 2.0.30 → 2.0.32

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/README.md CHANGED
@@ -121,7 +121,7 @@ Planner-created tasks use stable IDs and the existing parent/subtask tree, so
121
121
  retries and daemon restarts do not create duplicate work.
122
122
  Task producers must follow `skills/agents-relay/contracts/task-job-contract.md`; that shared skill contract defines story size, lifecycle events, review, and auto-merge semantics.
123
123
 
124
- Execution routing is code-driven: Relay builds complete executable worker+model routes from authoritative runtime/model/resource facts, removes only impossible routes, then calls the generic typed `decide(request, contract)` boundary once. Free-text routing guidance and task/resource/capability signals are soft decision facts; decision failure falls back deterministically to a usable Codex default route. See [Execution Router and typed decisions](docs/worker-model-routers.md).
124
+ Execution routing is code-driven: Relay builds complete executable worker+model routes from authoritative runtime/model/resource facts, merges profile defaults with the user's explicit `[choices]`, deduplicates them, removes only impossible routes via hard gates, then calls the generic typed `decide(request, contract)` boundary exactly once with one merged choice list and the `[routing-instruction]` text forwarded verbatim. Task/resource/capability signals and the routing instruction are soft decision facts; decision failure falls back deterministically to a usable Codex default route. See [Execution Router and typed decisions](docs/worker-model-routers.md).
125
125
  All task producers use the same story-size contract: `S`≈5 minutes, `M`≈15 minutes, and `L`≈45 minutes of normal-reasoning execution, with `M` as the default. `high` reasoning doubles that overall window. ChatGPT Browser Worker start-ack timeout is derived as one third of the effective overall timeout, clamped between 1 and 5 minutes. Orchestrators and planners should choose `storySize`, not raw timeout milliseconds; `--timeout` remains only as an explicit compatibility/debug override.
126
126
 
127
127
  Autonomous jobs can also be scheduled. The Job marker stores a five-field cron expression, IANA timezone, next run, and missed-run/concurrency policy. The daemon evaluates the SQLite-backed managed Job view and validates durable schedule facts before claiming a due slot. A repository heartbeat invokes `tick` at 10-minute resolution. See
@@ -193,7 +193,7 @@ External product feedback uses the reusable `src/feedback.ts` contract. See [doc
193
193
 
194
194
  reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode. Model-backed workers require --events nats; the event transport carries authoritative terminal worker events as well as wake/progress traffic. See [docs/service.md](docs/service.md). --file PATH is explicit local demo/test mode only.
195
195
 
196
- Router Rules are stored at `~/.agents-relay/execution-router-rules.md` by default. `agents-relayd --execution-router-rules PATH` selects a different file for that daemon process; the explicit CLI path takes precedence over `AGENTS_RELAY_EXECUTION_ROUTER_RULES`, which remains supported for compatibility.
196
+ Router Rules are stored at `~/.agents-relay/execution-router-rules.md` by default and use `[choices]` plus `[routing-instruction]` sections (see the Execution Router doc). `agents-relayd --execution-router-rules PATH` selects a different file for that daemon process; the explicit CLI path takes precedence over `AGENTS_RELAY_EXECUTION_ROUTER_RULES`, which remains supported for compatibility.
197
197
 
198
198
  `agents-relayd` without `--pr`/`--id` runs in workspace mode by default: it discovers nested Git repositories under `~/Workspace`, keeps only GitHub `origin` remotes, and aggregates their managed PR jobs into one dashboard and worker pool. Pass `--workspace PATH` to use another root. `--repo OWNER/REPO` remains the single-repository pool mode.
199
199
 
@@ -235,7 +235,7 @@ export const dashboardHtml = `<!doctype html>
235
235
  </div>
236
236
  </div>
237
237
  </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><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 class="router-test-panel"><h3>Test Router Result</h3><div id="router-test-results" class="router-test-results" aria-live="polite"><p class="empty">Select a Job and Task, then run Test Router.</p></div></div></div></div></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">Execution Router rules with an explicit <code>[choices]</code> section (provider: model [reasoning] entries, additive to profile defaults) and a <code>[routing-instruction]</code> section forwarded verbatim to Laya. Relay deduplicates, applies hard gates, and sends one merged choice list; impossible routes remain excluded by code and Codex stays the deterministic fallback.</p><textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Execution router rules" placeholder="[choices]\nopenai: sol medium, luna high\nzai: glm-5.3-flash high\n\n[routing-instruction]\nPrefer zai until its quota resets; otherwise use the openai default."></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><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 class="router-test-panel"><h3>Test Router Result</h3><div id="router-test-results" class="router-test-results" aria-live="polite"><p class="empty">Select a Job and Task, then run Test Router.</p></div></div></div></div></section>
239
239
  <section id="shortcuts-view" role="tabpanel" aria-labelledby="shortcuts-tab" hidden>
240
240
  <div class="panel">
241
241
  <div class="panel-head"><h2>ChatGPT Shortcuts</h2><span class="subtle">One per line · %1 = current repo/pr</span></div>
@@ -453,7 +453,7 @@ function routeWorker(record){const id=record?.selected?.worker?.id||record?.work
453
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);}}
454
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;}}
455
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();}
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;}}
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),effective_choices:result.effectiveChoices||[],routing_instruction:result.routingInstruction||'',merged_choices:result.mergedChoices||[],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;}}
457
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;}}
458
458
  async function drawShortcuts(){shortcutsStatus.textContent='';shortcutsText.value=chatShortcutText();}
459
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.';}
@@ -135,7 +135,7 @@ export function makeExecutionDecisionAudit(input) {
135
135
  fallback: { used: input.routing.decisionEvidence?.fallback ?? false, reason: input.routing.decisionEvidence?.error },
136
136
  decision_evidence: input.routing.decisionEvidence,
137
137
  decision_input: {
138
- type: 'staged_route_decision',
138
+ type: 'merged_choice_decision',
139
139
  stages: input.decisionStages.map(stage => ({
140
140
  name: stage.name,
141
141
  facts: stage.facts,
@@ -111,13 +111,6 @@ async function decideStage(engine, name, facts, question, choices) {
111
111
  throw new Error('Execution router expected a choice result for ' + name);
112
112
  return { name, facts, contract, result: { choice: decision.choice, probabilities: decision.probabilities } };
113
113
  }
114
- function stageFacts(baseFacts, selected, candidates) {
115
- return JSON.parse(JSON.stringify({
116
- ...baseFacts,
117
- selected,
118
- ...candidates,
119
- }));
120
- }
121
114
  function routeId(worker, model) {
122
115
  return worker.workerId + '::' + model.id + (model.reasoning ? '@' + model.reasoning : '');
123
116
  }
@@ -143,9 +136,89 @@ function workerSelection(worker, selectedBy, evidence) {
143
136
  decisionEvidence: evidence,
144
137
  };
145
138
  }
139
+ /**
140
+ * Configured profile defaults for one worker: the explicitly configured
141
+ * provider/model/reasoning profiles (Codex `config.toml` and `*.config.toml`),
142
+ * i.e. every discovered route that is not a runtime catalog expansion.
143
+ */
144
+ function profileDefaults(candidates) {
145
+ return candidates.filter(candidate => !candidate.id.startsWith('catalog:'));
146
+ }
147
+ /**
148
+ * Resolve explicit user `[choices]` entries against discovered runtime models.
149
+ * A choice may reference a runtime catalog model because the user named it
150
+ * explicitly; Relay never expands the unreferenced catalog into choices.
151
+ * Unresolvable or reasoning-incompatible entries are dropped rather than
152
+ * fabricated into an impossible route.
153
+ */
154
+ function resolveExplicitModel(choice, pool) {
155
+ const model = choice.model.toLowerCase();
156
+ const exact = pool.find(candidate => candidate.model.toLowerCase() === model);
157
+ if (exact)
158
+ return exact.model;
159
+ const suffixes = [...new Set(pool.filter(candidate => candidate.model.toLowerCase().endsWith('-' + model)).map(candidate => candidate.model))];
160
+ if (suffixes.length === 1)
161
+ return suffixes[0];
162
+ const hint = choice.model.match(/^(gpt-\d+(?:\.\d+)?)-[a-z0-9.-]+$/i)?.[1];
163
+ if (hint) {
164
+ const family = pool.find(candidate => candidate.model.toLowerCase() === (hint + '-' + model).toLowerCase());
165
+ if (family)
166
+ return family.model;
167
+ }
168
+ return null;
169
+ }
170
+ function resolveExplicitChoices(choices, candidates) {
171
+ const result = [];
172
+ const seen = new Set();
173
+ for (const choice of choices) {
174
+ const provider = choice.provider.toLowerCase();
175
+ const providerPool = candidates.filter(candidate => candidate.provider.toLowerCase() === provider);
176
+ const model = resolveExplicitModel(choice, providerPool);
177
+ if (!model)
178
+ continue;
179
+ const matches = providerPool.filter(candidate => candidate.model === model);
180
+ const pool = choice.reasoning
181
+ ? matches.filter(candidate => (candidate.reasoning ?? '').toLowerCase() === choice.reasoning)
182
+ : matches;
183
+ const candidate = pool.find(item => !item.id.startsWith('catalog:')) ?? pool[0];
184
+ if (!candidate)
185
+ continue;
186
+ const key = candidate.id + '@' + (candidate.reasoning ?? '');
187
+ if (seen.has(key))
188
+ continue;
189
+ seen.add(key);
190
+ result.push(candidate);
191
+ }
192
+ return result;
193
+ }
194
+ function mergeCandidates(...lists) {
195
+ const result = [];
196
+ const seen = new Set();
197
+ for (const list of lists) {
198
+ for (const candidate of list) {
199
+ const key = candidate.id + '@' + (candidate.reasoning ?? '');
200
+ if (seen.has(key))
201
+ continue;
202
+ seen.add(key);
203
+ result.push(candidate);
204
+ }
205
+ }
206
+ return result;
207
+ }
208
+ export function executionChoiceLabel(route, isDefault) {
209
+ return 'worker=' + route.worker.workerId
210
+ + ' provider=' + route.model.provider
211
+ + ' model=' + route.model.model
212
+ + ' reasoning=' + (route.model.reasoning ?? 'provider-default')
213
+ + ' default=' + String(isDefault);
214
+ }
215
+ function isDefaultCandidate(candidate, defaults) {
216
+ return defaults.some(item => item.id === candidate.id && item.reasoning === candidate.reasoning);
217
+ }
146
218
  export async function discoverExecutionRoutes(workers, requirements, options = {}) {
147
219
  const registered = options.registered ?? [];
148
220
  const policy = await loadRoutingPolicy(options.policyPath, options.rulesPath);
221
+ const rules = await loadExecutionRouterRules(options.rulesPath);
149
222
  const workerGateEvaluations = workers.map(worker => {
150
223
  const rejectionReasons = workerGateReasons(worker, requirements);
151
224
  return { workerId: worker.workerId, eligible: rejectionReasons.length === 0, rejectionReasons };
@@ -153,28 +226,45 @@ export async function discoverExecutionRoutes(workers, requirements, options = {
153
226
  const evaluations = [];
154
227
  for (const worker of workers) {
155
228
  const workerReasons = workerGateEvaluations.find(item => item.workerId === worker.workerId)?.rejectionReasons ?? [];
156
- const models = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
157
- for (const model of models) {
229
+ const discovered = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
230
+ const defaults = profileDefaults(discovered);
231
+ const explicit = resolveExplicitChoices(rules.parsed.choices, discovered);
232
+ const merged = mergeCandidates(defaults, explicit);
233
+ for (const model of merged) {
158
234
  const rejectionReasons = [...workerReasons, ...modelGateReasons(model, options.routingConstraints, options.excludedRoutes, policy)];
159
235
  const route = { id: routeId(worker, model), worker, model };
160
236
  evaluations.push({ route, eligible: rejectionReasons.length === 0, rejectionReasons });
161
237
  }
162
238
  }
163
239
  const routes = evaluations.filter(item => item.eligible).map(item => item.route);
164
- return { routes, evaluations, workerGateEvaluations, policy };
240
+ return { routes, evaluations, workerGateEvaluations, policy, choices: rules.parsed.choices, routingInstruction: rules.parsed.routingInstruction };
165
241
  }
166
242
  export async function routeExecution(workers, requirements, options = {}) {
167
- const { routes, evaluations, workerGateEvaluations, policy } = await discoverExecutionRoutes(workers, requirements, options);
243
+ const { routes, evaluations, workerGateEvaluations, policy, routingInstruction } = await discoverExecutionRoutes(workers, requirements, options);
168
244
  if (routes.length === 0)
169
245
  throw new Error('No executable worker+model route satisfies authoritative constraints');
170
246
  const fallback = fallbackRoute(routes);
171
247
  const rules = await loadExecutionRouterRules(options.rulesPath);
172
- const userInputRules = rules.text;
173
248
  const preferredChatgptConcurrency = options.preferredChatgptConcurrency ?? DEFAULT_CHATGPT_PREFERRED_CONCURRENCY;
174
249
  const chatgptRunning = options.chatgptRunning ?? 0;
175
250
  const taskFacts = options.taskFacts ?? {};
176
251
  const taskShape = executionTaskShape(taskFacts);
177
- const baseFacts = JSON.parse(JSON.stringify({
252
+ const discoveredDefaults = new Map();
253
+ for (const worker of workers) {
254
+ const discovered = await discoverModelCandidates(worker, options.registered ?? [], options.usage, undefined, options.rulesPath);
255
+ discoveredDefaults.set(worker.workerId, profileDefaults(discovered));
256
+ }
257
+ const effectiveChoices = routes.map(route => ({
258
+ worker: route.worker.workerId,
259
+ provider: route.model.provider,
260
+ model: route.model.model,
261
+ profile: route.model.profile ?? null,
262
+ reasoning: route.model.reasoning ?? null,
263
+ default: isDefaultCandidate(route.model, discoveredDefaults.get(route.worker.workerId) ?? []),
264
+ source: isDefaultCandidate(route.model, discoveredDefaults.get(route.worker.workerId) ?? []) ? 'profile-default' : 'explicit-choice',
265
+ }));
266
+ const choiceLabels = routes.map((route, index) => executionChoiceLabel(route, effectiveChoices[index].default));
267
+ const facts = JSON.parse(JSON.stringify({
178
268
  context: {
179
269
  job: taskFacts.job ?? null,
180
270
  task: taskFacts.task ?? taskFacts,
@@ -189,95 +279,32 @@ export async function routeExecution(workers, requirements, options = {}) {
189
279
  reviewer_diversity: taskFacts.reviewer_diversity,
190
280
  } : null,
191
281
  },
192
- user_input_rules: userInputRules,
282
+ user_input_rules: rules.text,
283
+ routing_instruction: routingInstruction,
193
284
  runtime: {
194
285
  chatgpt_running: chatgptRunning,
195
286
  preferred_chatgpt_concurrency: preferredChatgptConcurrency,
196
287
  },
197
288
  worker_candidates: workerProviderFacts(routes),
198
- routing_instruction: 'Interpret user_input_rules directly. Choose among the runtime-provided legal candidates at each stage. Relay does not parse user_input_rules into model or reasoning choices.',
289
+ effective_choices: effectiveChoices,
199
290
  }));
291
+ const question = 'Choose one complete route from the merged choice list. Relay has already merged profile defaults with the user\'s explicit [choices] and applied every authoritative hard gate, so only these routes are legal. Follow [routing_instruction] verbatim; do not invent, add, or substitute routes that are not listed.';
200
292
  let selected = fallback;
201
293
  let evidence = { mode: options.decisionMode ?? 'off', fallback: true };
202
294
  const decisionStages = [];
203
295
  if ((options.decisionMode ?? 'off') !== 'off' && options.decisionEngine) {
204
296
  try {
205
- const providerKeys = [...new Set(routes.map(route => route.worker.workerId + '::' + route.model.provider))];
206
- const providerOptions = providerKeys.map(key => {
207
- const splitAt = key.lastIndexOf('::');
208
- const worker = key.slice(0, splitAt);
209
- const provider = key.slice(splitAt + 2);
210
- const providerRoutes = routes.filter(route => route.worker.workerId === worker && route.model.provider === provider);
211
- const configured = providerRoutes.find(route => !route.model.id.startsWith('catalog:')) ?? providerRoutes[0];
212
- return {
213
- key,
214
- worker,
215
- provider,
216
- default_model: configured.model.model,
217
- label: 'worker=' + worker + ' provider=' + provider + ' default_model=' + configured.model.model,
218
- };
219
- });
220
- const providerFacts = stageFacts(baseFacts, {}, {
221
- provider_candidates: providerOptions.map(({ label: _label, key: _key, ...option }) => option),
222
- });
223
- const providerStage = await decideStage(options.decisionEngine, 'provider', providerFacts, 'Choose the best worker/provider pair. Interpret user_input_rules directly together with task context, runtime load, provider defaults, and code-plan quota. When user_input_rules explicitly names providers as routing options, prefer those named providers when applicable; treat unmentioned providers as fallbacks unless other facts clearly require them.', providerOptions.map(option => option.label));
224
- decisionStages.push(providerStage);
225
- const selectedProvider = providerOptions.find(option => option.label === providerStage.result.choice);
226
- if (!selectedProvider)
227
- throw new Error('Laya selected an unknown worker/provider option');
228
- const workerId = selectedProvider.worker;
229
- const provider = selectedProvider.provider;
230
- const providerRoutes = routes.filter(route => route.worker.workerId === workerId && route.model.provider === provider);
231
- if (providerRoutes.length === 0)
232
- throw new Error('Laya selected a worker/provider pair with no eligible routes');
233
- const modelGroups = new Map();
234
- for (const route of providerRoutes) {
235
- const group = modelGroups.get(route.model.model) ?? [];
236
- group.push(route);
237
- modelGroups.set(route.model.model, group);
238
- }
239
- const modelChoices = [...modelGroups.keys()];
240
- const modelOptions = modelChoices.map(model => {
241
- const group = modelGroups.get(model) ?? [];
242
- const configured = group.some(route => !route.model.id.startsWith('catalog:'));
243
- return {
244
- model,
245
- configured_default: configured,
246
- supported_reasoning: [...new Set(group.map(route => route.model.reasoning).filter((value) => Boolean(value)))],
247
- label: 'model=' + model + ' configured_default=' + String(configured),
248
- };
249
- });
250
- const modelFacts = stageFacts(baseFacts, { worker: workerId, provider }, {
251
- model_candidates: modelOptions.map(({ label: _label, ...option }) => option),
252
- });
253
- const modelStage = await decideStage(options.decisionEngine, 'model', modelFacts, 'Choose the best model for the selected worker/provider. Interpret user_input_rules directly. Candidate model names come from current runtime/configuration and are not derived from the free-text rules. If user_input_rules explicitly maps a model to the current task type, size, complexity, or other current condition, follow that mapping instead of the provider default.', modelOptions.map(option => option.label));
254
- decisionStages.push(modelStage);
255
- const selectedModel = modelOptions.find(option => option.label === modelStage.result.choice);
256
- if (!selectedModel)
257
- throw new Error('Laya selected an unknown model option');
258
- const modelRoutes = modelGroups.get(selectedModel.model) ?? [];
259
- const reasoningChoices = [...new Set(modelRoutes.map(route => route.model.reasoning ?? 'provider-default'))];
260
- const reasoningOptions = reasoningChoices.map(reasoning => ({
261
- reasoning,
262
- label: 'reasoning=' + reasoning,
263
- }));
264
- const reasoningFacts = stageFacts(baseFacts, { worker: workerId, provider, model: selectedModel.model }, {
265
- reasoning_candidates: reasoningChoices,
266
- });
267
- const reasoningStage = await decideStage(options.decisionEngine, 'reasoning', reasoningFacts, 'Choose the reasoning level for the selected model. Interpret user_input_rules directly. If user_input_rules explicitly assigns a reasoning level to the selected model for the current conditions, follow that assignment. Use provider-default only when the model does not expose a specific reasoning level.', reasoningOptions.map(option => option.label));
268
- decisionStages.push(reasoningStage);
269
- const selectedReasoning = reasoningOptions.find(option => option.label === reasoningStage.result.choice);
270
- if (!selectedReasoning)
271
- throw new Error('Laya selected an unknown reasoning option');
272
- const reasoning = selectedReasoning.reasoning === 'provider-default' ? null : selectedReasoning.reasoning;
273
- const proposed = modelRoutes.find(route => (route.model.reasoning ?? null) === reasoning);
274
- if (!proposed)
275
- throw new Error('Laya selected a model/reasoning combination with no eligible route');
276
- selected = proposed;
297
+ const stage = await decideStage(options.decisionEngine, 'route', facts, question, choiceLabels);
298
+ decisionStages.push(stage);
299
+ const index = stage.result.choice ? choiceLabels.indexOf(stage.result.choice) : -1;
300
+ if (index < 0)
301
+ throw new Error('Laya selected a route outside the merged choice list');
302
+ selected = routes[index];
277
303
  evidence = {
278
304
  mode: options.decisionMode ?? 'active',
279
305
  engine: options.decisionEngine.name,
280
- proposed: providerStage.result.choice + '/' + selectedModel.model + '@' + selectedReasoning.reasoning,
306
+ proposed: stage.result.choice,
307
+ probabilities: stage.result.probabilities,
281
308
  proposedFields: {
282
309
  worker: selected.worker.workerId,
283
310
  provider: selected.model.provider,
@@ -320,6 +347,8 @@ export async function routeExecution(workers, requirements, options = {}) {
320
347
  constraints: options.routingConstraints ?? {},
321
348
  excludedRoutes: options.excludedRoutes ?? [],
322
349
  candidateRoutes: routes.map(route => ({ id: route.id, workerId: route.worker.workerId, adapter: route.worker.adapter })),
350
+ effectiveChoices,
351
+ routingInstruction,
323
352
  },
324
353
  policy,
325
354
  routing,
package/dist/relayd.js CHANGED
@@ -63,6 +63,13 @@ Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
63
63
  Examples:
64
64
  npx agents-relayd --repo OWNER/REPO
65
65
  npx agents-relayd --workspace ~/Workspace`;
66
+ function firstStageChoices(decisionInput) {
67
+ const stages = decisionInput?.stages;
68
+ if (!Array.isArray(stages) || stages.length === 0)
69
+ return [];
70
+ const contract = stages[0].contract;
71
+ return Array.isArray(contract?.choices) ? contract.choices.filter((item) => typeof item === 'string') : [];
72
+ }
66
73
  function value(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
67
74
  export function normalizeDaemonArguments(argv) {
68
75
  const repository = value(argv, '--repo') || null;
@@ -412,6 +419,9 @@ export async function runDaemon(argv) {
412
419
  providers: usage.providers,
413
420
  credits: usage.credits,
414
421
  },
422
+ effectiveChoices: result.audit?.contract?.effectiveChoices ?? [],
423
+ routingInstruction: result.audit?.contract?.routingInstruction ?? '',
424
+ mergedChoices: firstStageChoices(result.audit?.decision_input),
415
425
  decisionInput: result.audit?.decision_input ?? null,
416
426
  gates: result.audit?.gate_results ?? [],
417
427
  };
@@ -65,11 +65,11 @@ export async function runRouterEvals(engine, rulesPath) {
65
65
  user_input_rules: rules.text,
66
66
  runtime: { chatgpt_running: testCase.chatgptRunning ?? 0, preferred_chatgpt_concurrency: 10 },
67
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.',
68
+ routing_instruction: 'Choose one route from the merged choice list. Profile defaults and explicit [choices] have already been merged and hard-gated; do not invent additional routes.',
69
69
  }));
70
70
  const decision = await engine.decide({ facts }, {
71
71
  type: 'choice',
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.',
72
+ question: 'Choose one legal route from the merged choice list. Each provider default is available; extra model/reasoning routes appear only when explicitly listed in [choices]. Use context, code-plan quota, runtime load, and routing_instruction.',
73
73
  choices,
74
74
  });
75
75
  if (decision.type !== 'choice')
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Router Rules free-text parsing.
3
+ *
4
+ * The Execution Router rules file carries two explicit, user-authored sections:
5
+ *
6
+ * ```text
7
+ * [choices]
8
+ * openai: gpt-5.6-luna high, sol medium
9
+ * zai: glm-5.3-flash high
10
+ *
11
+ * [routing-instruction]
12
+ * Prefer zai until its quota resets; otherwise use the openai default.
13
+ * ```
14
+ *
15
+ * `[choices]` is an explicit, additive list of provider/model/reasoning routes.
16
+ * `[routing-instruction]` is opaque guidance forwarded to Laya verbatim: the
17
+ * exact section body is preserved byte-for-byte (leading and trailing
18
+ * whitespace, blank lines, and line endings included) so Relay never rewrites or
19
+ * normalizes user intent. Everything outside these sections is ignored so legacy
20
+ * free-text files remain valid and Relay never has to guess at intent.
21
+ */
22
+ const SECTION_CHOICES = 'choices';
23
+ const SECTION_INSTRUCTION = 'routing-instruction';
24
+ const SECTION_HEADER = /^[ \t]*\[([a-z][a-z0-9-]*)\][ \t]*\r?$/gim;
25
+ function sectionOf(line) {
26
+ const match = line.match(/^\s*\[([a-z][a-z0-9-]*)\]\s*$/i);
27
+ return match ? match[1].toLowerCase() : null;
28
+ }
29
+ function choiceKey(choice) {
30
+ return [choice.provider, choice.model, choice.reasoning ?? ''].join('\u0000');
31
+ }
32
+ /**
33
+ * Return the raw body of the first section named `name`, exactly as authored.
34
+ *
35
+ * The body begins after the line terminator that ends the section header line
36
+ * (the terminator belongs to the header) and ends at the start of the next
37
+ * section header line, or at end of input. Nothing inside the body is trimmed or
38
+ * normalized: whitespace, blank lines, and line endings are part of the
39
+ * forwarded instruction.
40
+ */
41
+ function rawSectionBody(text, name) {
42
+ const headers = [];
43
+ for (const match of text.matchAll(SECTION_HEADER)) {
44
+ const lineStart = match.index ?? 0;
45
+ headers.push({ name: match[1].toLowerCase(), lineStart, lineEnd: lineStart + match[0].length });
46
+ }
47
+ const index = headers.findIndex(header => header.name === name);
48
+ if (index < 0)
49
+ return null;
50
+ let bodyStart = headers[index].lineEnd;
51
+ if (text[bodyStart] === '\r')
52
+ bodyStart += 1;
53
+ if (text[bodyStart] === '\n')
54
+ bodyStart += 1;
55
+ const bodyEnd = index + 1 < headers.length ? headers[index + 1].lineStart : text.length;
56
+ return text.slice(bodyStart, bodyEnd);
57
+ }
58
+ /**
59
+ * Parse explicit router choices and the routing instruction from Router Rules.
60
+ *
61
+ * `[choices]` entries are deduplicated deterministically (first occurrence
62
+ * wins). The `[routing-instruction]` body is captured verbatim.
63
+ */
64
+ export function parseRouterRules(text) {
65
+ const choices = [];
66
+ const seen = new Set();
67
+ let section = null;
68
+ for (const rawLine of text.split(/\r?\n/)) {
69
+ const header = sectionOf(rawLine);
70
+ if (header !== null) {
71
+ section = header;
72
+ continue;
73
+ }
74
+ if (section === SECTION_INSTRUCTION)
75
+ continue;
76
+ if (section !== SECTION_CHOICES)
77
+ continue;
78
+ if (!rawLine.trim() || rawLine.trim().startsWith('#'))
79
+ continue;
80
+ const match = rawLine.match(/^\s*([A-Za-z0-9._-]+)\s*:\s*(.+)$/);
81
+ if (!match)
82
+ continue;
83
+ const provider = match[1].trim();
84
+ for (const segment of match[2].split(',')) {
85
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
86
+ if (tokens.length === 0)
87
+ continue;
88
+ const choice = {
89
+ provider,
90
+ model: tokens[0],
91
+ ...(tokens[1] ? { reasoning: tokens[1].toLowerCase() } : {}),
92
+ };
93
+ const key = choiceKey(choice);
94
+ if (seen.has(key))
95
+ continue;
96
+ seen.add(key);
97
+ choices.push(choice);
98
+ }
99
+ }
100
+ return { choices, routingInstruction: rawSectionBody(text, SECTION_INSTRUCTION) ?? '' };
101
+ }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
+ import { parseRouterRules } from './router-rules.js';
5
6
  const EMPTY_POLICY = {};
6
7
  function safePolicy(value) {
7
8
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -34,16 +35,16 @@ export function policyPath(explicit) {
34
35
  async function loadRouterRules(path) {
35
36
  try {
36
37
  const text = await readFile(path, 'utf8');
37
- return { path, hash: createHash('sha256').update(text).digest('hex'), text };
38
+ return { path, hash: createHash('sha256').update(text).digest('hex'), text, parsed: parseRouterRules(text) };
38
39
  }
39
40
  catch {
40
- return { path, hash: 'none', text: '' };
41
+ return { path, hash: 'none', text: '', parsed: parseRouterRules('') };
41
42
  }
42
43
  }
43
44
  async function saveRouterRules(path, text) {
44
45
  await mkdir(dirname(path), { recursive: true });
45
46
  await writeFile(path, text, 'utf8');
46
- return { path, hash: createHash('sha256').update(text).digest('hex'), text };
47
+ return { path, hash: createHash('sha256').update(text).digest('hex'), text, parsed: parseRouterRules(text) };
47
48
  }
48
49
  export function executionRouterRulesPath(explicit) {
49
50
  return explicit ?? process.env.AGENTS_RELAY_EXECUTION_ROUTER_RULES ?? join(homedir(), '.agents-relay', 'execution-router-rules.md');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.30",
3
+ "version": "2.0.32",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,25 @@ Before `job create`, the orchestrator converts the relevant discussion into a st
6
6
 
7
7
  This contract defines orchestrator-owned exception workflows that sit above normal Agents Relay task/job execution.
8
8
 
9
+ ## Normal managed Job start workflow
10
+
11
+ For a normal managed Job, `job create` or `job adopt` establishes durable Job/PR state only. Unless the user explicitly requested **create-only / no execution**, the orchestrator must immediately start the Job by invoking the first-class Planner command for that exact Job:
12
+
13
+ ```text
14
+ job create|adopt
15
+ -> agents-relay plan --repo <owner/repo> --pr <number> --id <job_id>
16
+ -> durable plan-N
17
+ -> Planner next_tasks
18
+ -> Relay materializes Tasks
19
+ -> normal routing, execution, and reconciliation
20
+ ```
21
+
22
+ The initial decomposition for a normal managed Job belongs to that durable Planner round. The orchestrator must not hand-author the initial child Task batch as a substitute for `agents-relay plan`, and it must not leave a newly created executable Job with zero Plans and zero Tasks. If the Planner command fails, the Job is blocked at startup and the orchestrator must report the failure rather than silently inventing Tasks.
23
+
24
+ This rule applies regardless of whether the Job uses `fixed` or `autonomous` execution mode; execution mode does not replace the required initial Planner invocation. Later replanning continues to follow Relay's normal Planner/reconciliation rules.
25
+
26
+ The explicit exception is the P0/self-job workflow below: a self job is synchronous orchestrator-owned execution, so it creates orchestrator-owned Tasks directly and terminalizes them in the same turn instead of invoking the normal initial Planner round.
27
+
9
28
  ## P0 / self job workflow
10
29
 
11
30
  A **P0 job** (alias: **self job**) is executed directly by the current active orchestrator thread: