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 +2 -2
- package/dist/dashboard-html.js +2 -2
- package/dist/decision-audit.js +1 -1
- package/dist/execution-routing.js +117 -88
- package/dist/relayd.js +10 -0
- package/dist/router-evals.js +2 -2
- package/dist/router-rules.js +101 -0
- package/dist/routing-policy.js +4 -3
- package/package.json +1 -1
- package/skills/agents-relay/contracts/orchestrator-workflows.md +19 -0
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
|
|
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
|
|
package/dist/dashboard-html.js
CHANGED
|
@@ -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">
|
|
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.';}
|
package/dist/decision-audit.js
CHANGED
|
@@ -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: '
|
|
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
|
|
157
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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:
|
|
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
|
};
|
package/dist/router-evals.js
CHANGED
|
@@ -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: '
|
|
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
|
|
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
|
+
}
|
package/dist/routing-policy.js
CHANGED
|
@@ -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
|
@@ -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:
|