agents-relay 1.0.103 → 1.0.105

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.
@@ -66,8 +66,22 @@ export function parseAgentGraph(source) {
66
66
  throw new Error('Agent Graph contains a cycle');
67
67
  return { source, nodes: [...agents].map(([id, agent]) => ({ id, agent })), edges, waves };
68
68
  }
69
+ export function validateAgentSelection(context, agentName, agentGraph) {
70
+ const visible = new Set(context.agents.map(agent => agent.name));
71
+ if (agentName && !visible.has(agentName)) {
72
+ throw new Error(`Agent ${agentName} is not visible in the resolved planning/execution context`);
73
+ }
74
+ if (!agentGraph)
75
+ return;
76
+ for (const node of parseAgentGraph(agentGraph).nodes) {
77
+ if (!visible.has(node.agent)) {
78
+ throw new Error(`Agent ${node.agent} referenced by agentGraph node ${node.id} is not visible in the resolved planning/execution context`);
79
+ }
80
+ }
81
+ }
69
82
  export async function agentGraphPrompt(context, source) {
70
83
  const graph = parseAgentGraph(source);
84
+ validateAgentSelection(context, undefined, source);
71
85
  const contracts = await Promise.all(graph.nodes.map(async (node) => ({ node, prompt: await agentContractPrompt(context, node.agent) })));
72
86
  const incoming = new Map(graph.nodes.map(node => [node.id, graph.edges.filter(edge => edge.to === node.id).map(edge => edge.from)]));
73
87
  return `[Task Agent Graph]\n${source.trim()}\n\nExecution waves: ${graph.waves.map((wave, index) => `${index + 1}:${wave.join(',')}`).join(' | ')}\n\nExecute this graph inside the current durable Task. Nodes are reasoning roles, NOT durable child Tasks. Preserve the original Job/Task context for every node. Nodes in the same wave are independent: evaluate each from the same original context without using sibling prose. For each later node, provide the original context plus structured outputs only from its declared predecessors. Do not emit outer task.started or task terminal events per node; the current durable Task owns exactly one start and one terminal event.\n\n${contracts.map(({ node, prompt }) => `[Node ${node.id}; predecessors=${(incoming.get(node.id) ?? []).join(',') || 'none'}]\n${prompt}`).join('\n\n')}`;
package/dist/cli-help.js CHANGED
@@ -156,10 +156,12 @@ Common: --task-id ID, --commit SHA, --project NAME.
156
156
 
157
157
  Example:
158
158
  npx agents-relay record --file .agents-relay.json --summary "Built release" --commit abc123`,
159
- reconcile: `Reconcile a job once, launching ready tasks and persisting results.
159
+ reconcile: `Reconcile a job once by requesting one reconciliation pass.
160
160
 
161
161
  Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id JOB_ID.
162
162
 
163
+ Managed GitHub jobs are reconciled by agents-relayd. The CLI publishes a job.wake request and does not launch workers; use --events nats.
164
+
163
165
  Example:
164
166
  npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1`,
165
167
  tick: `Process autonomous jobs indexed into current/recent GitHub next-run buckets.
@@ -74,6 +74,16 @@ export async function publishWake(bus, job, taskId, message) { if (!bus)
74
74
  catch (error) {
75
75
  process.stderr.write(`warning: event wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
76
76
  } }
77
+ export async function requestManagedReconciliation(bus, job) {
78
+ if (!bus)
79
+ throw new Error('Managed GitHub reconciliation requires --events nats so agents-relayd can execute the job');
80
+ try {
81
+ await bus.publish(eventFor(job.id, 'reconcile', null, 'job.wake', 'queued', `Reconcile requested for ${job.id}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber }));
82
+ }
83
+ catch (error) {
84
+ throw new Error(`Unable to deliver managed reconciliation wake to agents-relayd: ${error instanceof Error ? error.message : String(error)}`);
85
+ }
86
+ }
77
87
  export function priority(args, fallback = 'P2') { const value = arg(args, '--priority', fallback); if (!['P0', 'P1', 'P2', 'P3'].includes(value))
78
88
  throw new Error('--priority must be P0, P1, P2, or P3'); return value; }
79
89
  export function storySize(args, fallback = 'M') { const value = arg(args, '--story-size', fallback).toUpperCase(); if (!['S', 'M', 'L'].includes(value))
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js'
22
22
  import { runBlockerCommand } from './cli-blocker.js';
23
23
  import { helpText, usage } from './cli-help.js';
24
24
  export { helpText } from './cli-help.js';
25
- import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, } from './cli-support.js';
25
+ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, requestManagedReconciliation, } from './cli-support.js';
26
26
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
27
27
  export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
28
28
  if (!options.id)
@@ -646,6 +646,16 @@ async function main() {
646
646
  console.log(JSON.stringify(await store.load(job.id), null, 2));
647
647
  return;
648
648
  }
649
+ if (command === 'reconcile' && !localFile) {
650
+ try {
651
+ await requestManagedReconciliation(bus, job);
652
+ console.log(JSON.stringify(await store.load(job.id), null, 2));
653
+ }
654
+ finally {
655
+ await bus?.close?.();
656
+ }
657
+ return;
658
+ }
649
659
  const reconciler = runtime(store, args, bus);
650
660
  if (command === 'reconcile') {
651
661
  await reconciler.reconcile(job.id);
@@ -1,3 +1,5 @@
1
+ import { createRequire } from 'node:module';
2
+ const packageVersion = createRequire(import.meta.url)('../package.json').version;
1
3
  export const dashboardHtml = `<!doctype html>
2
4
  <html lang="en" data-dashboard-contract="safe-dom,named-relay-events,snapshot-events,bounded-event-history">
3
5
  <head>
@@ -11,6 +13,7 @@ export const dashboardHtml = `<!doctype html>
11
13
  body { margin:0; min-height:100vh; background:var(--bg); color:var(--text); font:14px/1.45 ui-sans-serif,system-ui,sans-serif; }
12
14
  header { position:sticky; top:0; z-index:3; background:var(--surface); border-bottom:1px solid var(--border); padding:.8rem 1rem; }
13
15
  .brand { display:flex; align-items:baseline; gap:.65rem; min-width:0; }
16
+ .version { margin-left:auto; color:var(--muted); font-size:.72rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; white-space:nowrap; }
14
17
  h1 { margin:0; font-size:1.08rem; letter-spacing:-.02em; }
15
18
  .tagline,.subtle { color:var(--muted); font-size:.78rem; }
16
19
  .warning-banner { margin:.65rem 0 0; padding:.5rem .65rem; border:1px solid #e6bf62; border-radius:.4rem; background:#fff4d6; color:#6f4b00; font-size:.78rem; font-weight:700; }
@@ -83,7 +86,13 @@ export const dashboardHtml = `<!doctype html>
83
86
  .event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
84
87
  .event-kind { color:var(--accent); font-weight:800; }
85
88
  .event-message { margin:.12rem 0 0; overflow-wrap:anywhere; }
86
- #usage-view { grid-column:1 / -1; }
89
+ #usage-view,#rules-view { grid-column:1 / -1; }
90
+ .rules-editor { display:grid; gap:.65rem; }
91
+ .rules-help { margin:0; color:var(--muted); font-size:.8rem; }
92
+ .rules-textarea { width:100%; min-height:55vh; resize:vertical; border:1px solid var(--border); border-radius:.45rem; background:var(--surface2); color:var(--text); padding:.8rem; font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; }
93
+ .rules-footer { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; }
94
+ .rules-save { border:1px solid var(--accent); border-radius:.35rem; background:var(--accent); color:#fff; padding:.38rem .8rem; font:inherit; font-weight:750; cursor:pointer; }
95
+ .rules-save:disabled { opacity:.55; cursor:default; }
87
96
  .usage-providers { display:grid; gap:.55rem; }
88
97
  .usage-provider { display:grid; grid-template-columns:minmax(130px,180px) minmax(0,1fr); gap:1rem; align-items:center; padding:.7rem .8rem; border:1px solid var(--border); border-radius:.45rem; background:var(--surface2); }
89
98
  .usage-provider h3 { margin:0; font-size:.86rem; }
@@ -103,11 +112,12 @@ export const dashboardHtml = `<!doctype html>
103
112
  </head>
104
113
  <body>
105
114
  <header>
106
- <div class="brand"><h1>Agents Relay Console</h1><p class="tagline">Durable PR state is authoritative; events are observability.</p></div>
115
+ <div class="brand"><h1>Agents Relay Console</h1><p class="tagline">Durable PR state is authoritative; events are observability.</p><span class="version">v${packageVersion}</span></div>
107
116
  <div id="rate-limit-warning" class="warning-banner" role="status" hidden></div>
108
117
  <nav role="tablist" aria-label="Console views">
109
118
  <button id="jobs-tab" type="button" role="tab" aria-selected="true" aria-controls="jobs-view">Jobs</button>
110
119
  <button id="usage-tab" type="button" role="tab" aria-selected="false" aria-controls="usage-view">Usage</button>
120
+ <button id="rules-tab" type="button" role="tab" aria-selected="false" aria-controls="rules-view">Model Router Rules</button>
111
121
  </nav>
112
122
  </header>
113
123
  <main>
@@ -137,6 +147,18 @@ export const dashboardHtml = `<!doctype html>
137
147
  <div class="panel-body" id="usage-root" aria-live="polite"><div class="skeleton"></div></div>
138
148
  </div>
139
149
  </section>
150
+ <section id="rules-view" role="tabpanel" aria-labelledby="rules-tab" hidden>
151
+ <div class="panel">
152
+ <div class="panel-head"><h2>Model Router Rules</h2><span id="rules-meta" class="subtle"></span></div>
153
+ <div class="panel-body">
154
+ <div class="rules-editor">
155
+ <p class="rules-help">Free-text guidance loaded by the model decider for every routing decision. Hard capability and health gates remain enforced by code.</p>
156
+ <textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Model router rules" placeholder="Example: Prefer ZAI when paid quota is abundant and task quality is sufficient. If a provider/runtime path just failed, reconsider using the failure evidence and fresh usage."></textarea>
157
+ <div class="rules-footer"><span id="rules-status" class="subtle" aria-live="polite"></span><button id="rules-save" class="rules-save" type="button">Save rules</button></div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </section>
140
162
  </main>
141
163
  <script>
142
164
  'use strict';
@@ -155,8 +177,10 @@ export const dashboardHtml = `<!doctype html>
155
177
  const MAX_EVENTS=60;
156
178
  const jobsTab=document.querySelector('#jobs-tab');
157
179
  const usageTab=document.querySelector('#usage-tab');
180
+ const rulesTab=document.querySelector('#rules-tab');
158
181
  const jobsView=document.querySelector('#jobs-view');
159
182
  const usageView=document.querySelector('#usage-view');
183
+ const rulesView=document.querySelector('#rules-view');
160
184
  const jobsWorkspace=document.querySelector('#jobs-workspace');
161
185
  const jobList=document.querySelector('#job-list');
162
186
  const jobsRefresh=document.querySelector('#jobs-refresh');
@@ -176,6 +200,10 @@ const connection=document.querySelector('#connection');
176
200
  const selectedJobTitle=document.querySelector('#selected-job-title');
177
201
  const usageRoot=document.querySelector('#usage-root');
178
202
  const usageMeta=document.querySelector('#usage-meta');
203
+ const rulesText=document.querySelector('#rules-text');
204
+ const rulesMeta=document.querySelector('#rules-meta');
205
+ const rulesStatus=document.querySelector('#rules-status');
206
+ const rulesSave=document.querySelector('#rules-save');
179
207
  const rateLimitWarning=document.querySelector('#rate-limit-warning');
180
208
  let jobs=[];
181
209
  let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
@@ -266,7 +294,9 @@ function usageProvider(provider){const card=element('section','usage-provider');
266
294
  function formatCredit(value,currency){if(value===null||value===undefined||!Number.isFinite(Number(value)))return '—';const number=Number(value);if(currency==='USD')return '$'+number.toFixed(2);if(currency==='CNY')return '¥'+number.toFixed(2);return number.toFixed(2)+' '+currency;}
267
295
  function creditProvider(provider){const row=element('section','usage-provider');row.append(element('h3','',text(provider.label)));const credits=provider.credits||{};if(!credits.available||!Array.isArray(credits.balances)||!credits.balances.length){row.append(element('p','empty',text(credits.reason||'Credit balance is unavailable.')));return row;}const balance=credits.balances[0];row.append(element('div','detail-value',text(formatCredit(balance.remaining,balance.currency))));return row;}
268
296
  async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];if(!providers.length&&!credits.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}const grid=element('div','usage-providers');for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
269
- function showView(view){const isJobs=view==='jobs';jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=isJobs;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(!isJobs));if(isJobs)void refresh();else void drawUsage();}
297
+ async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/model-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;}}
298
+ async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/model-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;}}
299
+ function showView(view){const isJobs=view==='jobs';const isUsage=view==='usage';const isRules=view==='rules';jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=!isUsage;rulesView.hidden=!isRules;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(isUsage));rulesTab.setAttribute('aria-selected',String(isRules));if(isJobs)void refresh();else if(isUsage)void drawUsage();else void drawRules();}
270
300
  jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
271
301
  jobsRefresh.addEventListener('click',()=>void refresh(true));
272
302
  eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
@@ -277,15 +307,17 @@ quickDraft.addEventListener('click',()=>toggleQuickMode('draft'));
277
307
  quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
278
308
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
279
309
  usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
280
- window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();showView(location.hash==='#/usage'?'jobs':'usage');}});
310
+ rulesTab.addEventListener('click',()=>{location.hash='#/model-router-rules';showView('rules');});
311
+ rulesSave.addEventListener('click',()=>void saveRules());
312
+ window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();const next=location.hash==='#/usage'?'rules':location.hash==='#/model-router-rules'?'jobs':'usage';location.hash=next==='usage'?'#/usage':next==='rules'?'#/model-router-rules':(selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs');showView(next);}});
281
313
  function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
282
314
  const stream=new EventSource('/events');
283
315
  stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
284
316
  stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
285
317
  stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>job.id===payload.job.id);if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);renderAll();}};
286
318
  stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>job.id===payload.job.id);if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);}if(payload?.event)addEvent(payload.event);renderAll();});
287
- if(location.hash==='#/usage')showView('usage');else showView('jobs');
288
- setInterval(()=>{if(location.hash!=='#/usage')void refresh();},300000);
319
+ if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/model-router-rules')showView('rules');else showView('jobs');
320
+ setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/model-router-rules')void refresh();},300000);
289
321
  setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
290
322
  async function refreshSelected(){if(!selectedJob){await refresh();return;}selectedJobRefresh.disabled=true;selectedJobRefresh.textContent='Refreshing…';try{const updated=await loadJSON('/api/job?id='+encodeURIComponent(selectedJob));const index=jobs.findIndex(job=>job.id===selectedJob);if(index>=0)jobs[index]=updated;else jobs.push(updated);rememberSelection();renderAll();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','Durable state unavailable');connection.className='status-pill bad';}finally{selectedJobRefresh.disabled=false;selectedJobRefresh.textContent='Refresh';}}
291
323
  </script>
package/dist/dashboard.js CHANGED
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { readUsageSnapshot } from './usage.js';
6
6
  import { deriveJobRuntimeStatus } from './runtime-status.js';
7
7
  import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
8
+ import { loadModelRouterRules, saveModelRouterRules } from './routing-policy.js';
8
9
  const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
9
10
  const SSE_HEARTBEAT_MS = 15000;
10
11
  const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
@@ -267,6 +268,47 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
267
268
  res.end(JSON.stringify(events));
268
269
  return;
269
270
  }
271
+ if (req.url === '/api/model-router-rules') {
272
+ if (req.method === 'GET') {
273
+ try {
274
+ const rules = await loadModelRouterRules();
275
+ res.writeHead(200, { 'content-type': 'application/json' });
276
+ res.end(JSON.stringify(rules));
277
+ }
278
+ catch (error) {
279
+ res.writeHead(500);
280
+ res.end(error instanceof Error ? error.message : String(error));
281
+ }
282
+ return;
283
+ }
284
+ if (req.method === 'PUT') {
285
+ const chunks = [];
286
+ let size = 0;
287
+ for await (const chunk of req) {
288
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
289
+ size += buffer.length;
290
+ if (size > 64 * 1024) {
291
+ res.writeHead(413);
292
+ res.end('Model router rules exceed 64 KiB');
293
+ return;
294
+ }
295
+ chunks.push(buffer);
296
+ }
297
+ try {
298
+ const rules = await saveModelRouterRules(Buffer.concat(chunks).toString('utf8'));
299
+ res.writeHead(200, { 'content-type': 'application/json' });
300
+ res.end(JSON.stringify(rules));
301
+ }
302
+ catch (error) {
303
+ res.writeHead(500);
304
+ res.end(error instanceof Error ? error.message : String(error));
305
+ }
306
+ return;
307
+ }
308
+ res.writeHead(405, { allow: 'GET, PUT' });
309
+ res.end('Method not allowed');
310
+ return;
311
+ }
270
312
  if (req.url === '/api/status') {
271
313
  const status = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
272
314
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -0,0 +1,58 @@
1
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ export const DECISION_AUDIT_SCHEMA_VERSION = 2;
5
+ export class JsonlDecisionAuditStore {
6
+ path;
7
+ constructor(path) {
8
+ this.path = path;
9
+ }
10
+ async append(record) { await mkdir(dirname(this.path), { recursive: true }); await appendFile(this.path, `${JSON.stringify(sanitize(record))}\n`, 'utf8'); }
11
+ async read() {
12
+ try {
13
+ return (await readFile(this.path, 'utf8')).split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ }
19
+ }
20
+ function sanitize(value, key = '') {
21
+ if (/(token|secret|password|credential|authorization|api.?key)/i.test(key))
22
+ return '[redacted]';
23
+ if (Array.isArray(value))
24
+ return value.map(item => sanitize(item));
25
+ if (value && typeof value === 'object')
26
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, sanitize(v, k)]));
27
+ return value;
28
+ }
29
+ export function decisionAuditId() { return randomUUID(); }
30
+ export function auditInputHash(record) { return createHash('sha256').update(JSON.stringify(record)).digest('hex'); }
31
+ export function outcomeForDecision(decisionId, kind, summary, evidence) { return { record_type: 'outcome', schema_version: DECISION_AUDIT_SCHEMA_VERSION, outcome_id: randomUUID(), decision_id: decisionId, timestamp: new Date().toISOString(), kind, summary, evidence }; }
32
+ export function taskAuditFacts(task) {
33
+ if (!task)
34
+ return {};
35
+ return { job_id: task.jobId, task_id: task.id, attempt: task.attempt, execution_id: task.executionId, capabilities: [...task.capabilities], input_class: task.input.slice(0, 80).replace(/\s+/g, ' ') };
36
+ }
37
+ export function makeDecisionAudit(input) {
38
+ const candidate = (item) => ({ id: item.id, provider: item.provider, model: item.model, profile: item.profile ?? null, reasoning: item.reasoning ?? null, resourceHealthy: item.resourceHealthy ?? null, resourceUsedPercent: item.resourceUsedPercent ?? null, resourceRemainingPercent: item.resourceRemainingPercent ?? null, resourceResetAt: item.resourceResetAt ?? null, resourceResetInMinutes: item.resourceResetInMinutes ?? null, resourceWindows: item.resourceWindows ?? null });
39
+ const eligibleIds = new Set(input.eligibleCandidates.map(item => item.id));
40
+ const gate_results = input.candidates.map(item => {
41
+ const reasons = [];
42
+ if (item.resourceHealthy === false)
43
+ reasons.push('resource_unhealthy');
44
+ const constraints = input.contract.constraints;
45
+ if (typeof constraints?.provider === 'string' && constraints.provider !== item.provider)
46
+ reasons.push('provider_constraint');
47
+ if (typeof constraints?.model === 'string' && constraints.model !== item.model)
48
+ reasons.push('model_constraint');
49
+ if (typeof constraints?.profile === 'string' && constraints.profile !== item.profile)
50
+ reasons.push('profile_constraint');
51
+ if (input.policy.policy.disabledProviders?.includes(item.provider))
52
+ reasons.push('policy_disabled_provider');
53
+ if (item.profile && input.policy.policy.disabledProfiles?.includes(item.profile))
54
+ reasons.push('policy_disabled_profile');
55
+ return { candidate_id: item.id, eligible: eligibleIds.has(item.id), rejection_reasons: reasons };
56
+ });
57
+ return { record_type: 'decision', schema_version: DECISION_AUDIT_SCHEMA_VERSION, decision_id: decisionAuditId(), prior_decision_id: input.priorDecisionId, timestamp: input.routing.decidedAt, correlation: { job_id: input.task?.jobId, task_id: input.task?.id, execution_id: input.task?.executionId, attempt: input.task?.attempt }, task: taskAuditFacts(input.task), worker: input.worker, contract: input.contract, policy: { path: input.policy.path, hash: input.policy.hash, snapshot: input.policy.snapshot, rules: input.policy.rules }, candidates: input.candidates.map(candidate), gate_results, eligible_candidates: input.eligibleCandidates.map(candidate), selected: candidate(input.selected), engine: input.routing.decidedBy, fallback: { used: input.routing.decisionEvidence?.fallback ?? false, reason: input.routing.decisionEvidence?.error }, decision_evidence: input.routing.decisionEvidence, prior_failure: input.priorFailure };
58
+ }
@@ -258,6 +258,7 @@ export function executionContextPrompt(context) {
258
258
  const skills = context.skills.map(item => `- ${item.name}: ${item.path} [${item.source}]`).join('\n') || '- none';
259
259
  const agents = context.agents.map(item => {
260
260
  const details = [
261
+ item.description ? `purpose=${item.description}` : '',
261
262
  item.capabilities.length ? `capabilities=${item.capabilities.join(',')}` : '',
262
263
  item.runtime ? `runtime=${item.runtime}` : '',
263
264
  item.trustDomains.length ? `trust=${item.trustDomains.join(',')}` : '',
@@ -0,0 +1,41 @@
1
+ import { outcomeForDecision } from './decision-audit.js';
2
+ import { discoverModelCandidates, routeModel } from './model-routing.js';
3
+ import { readUsageSnapshot } from './usage.js';
4
+ /** Classify from durable lifecycle evidence and logs; task.error is only a supporting hint. */
5
+ export function classifyFailureEvidence(input) {
6
+ const text = [...input.events.map(event => `${event.type} ${event.message ?? ''} ${JSON.stringify(event.data ?? {})}`), ...(input.logs ?? [])].join('\n').toLowerCase();
7
+ const model = /model|provider|laya|launch|auth|credential|configuration|incompatible|rate.?limit|quota|unavailable/.test(text);
8
+ const infra = /worker|tool|daemon|relay|network|socket|nats|checkout|filesystem/.test(text);
9
+ const semantic = /assert|test failed|implementation|compile|typescript|syntax|semantic/.test(text);
10
+ const timeout = /timeout|timed out|deadline/.test(text);
11
+ if (model && !infra)
12
+ return { classification: 'model_provider_runtime', summary: 'Durable events/logs indicate the selected model/provider/runtime path failed.', sources: ['task.events', 'worker.logs'] };
13
+ if (infra)
14
+ return { classification: 'worker_tool_infrastructure', summary: 'Durable events/logs indicate worker, tool, or infrastructure failure unrelated to model choice.', sources: ['task.events', 'worker.logs'] };
15
+ if (semantic)
16
+ return { classification: 'semantic_task', summary: 'Durable events/logs indicate meaningful task execution failed semantically.', sources: ['task.events', 'worker.logs'] };
17
+ if (timeout)
18
+ return { classification: 'timeout_unknown', summary: 'Timeout evidence is insufficient to attribute failure to model choice.', sources: ['task.events', 'worker.logs'] };
19
+ return { classification: 'timeout_unknown', summary: input.events.length === 0 && !input.logs?.length ? 'No durable event or log evidence was supplied.' : 'Failure attribution is unknown from the available evidence.', sources: ['task.events', 'worker.logs'] };
20
+ }
21
+ /** Re-route only. This action never retries or changes the Task lifecycle. */
22
+ export async function redecideFailedTask(options) {
23
+ if (options.evidence.classification !== 'model_provider_runtime')
24
+ throw new Error('Model re-decision requires model/provider/runtime evidence');
25
+ const usage = await readUsageSnapshot(options.usage);
26
+ const candidates = await discoverModelCandidates(options.worker, options.registered, usage);
27
+ const result = await routeModel(options.worker, candidates, {
28
+ decisionEngine: options.decisionEngine,
29
+ decisionMode: options.decisionMode,
30
+ policyPath: options.policyPath,
31
+ auditStore: options.auditStore,
32
+ auditTask: options.task,
33
+ taskFacts: { kind: options.task.kind ?? 'work', capabilities: options.task.capabilities, prior_routing: options.task.routing ?? null, fresh_usage_generated_at: usage.generatedAt },
34
+ priorDecisionId: options.task.routing?.decisionId,
35
+ priorFailure: options.evidence,
36
+ });
37
+ await options.updateRouting(options.task, result.routing);
38
+ if (options.auditStore && options.task.routing?.decisionId)
39
+ await options.auditStore.append(outcomeForDecision(options.task.routing.decisionId, 'provider_failure', options.evidence.summary, { classification: options.evidence.classification, fresh_usage_generated_at: usage.generatedAt }));
40
+ return { ...result, freshUsageGeneratedAt: usage.generatedAt };
41
+ }
@@ -3,6 +3,8 @@ import { basename, join } from 'node:path';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { chooseWithDecision } from './decision.js';
5
5
  import { usageForProvider } from './worker-routing.js';
6
+ import { makeDecisionAudit } from './decision-audit.js';
7
+ import { loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
6
8
  function topLevelCodexConfig(text) {
7
9
  const result = {};
8
10
  let inSection = false;
@@ -73,10 +75,12 @@ export async function discoverModelCandidates(worker, registered, usage, codexHo
73
75
  candidates.push(withResource({ id: 'default:openai/gpt-5.6-luna', provider: 'openai', model: 'gpt-5.6-luna' }, usage));
74
76
  return candidates.filter((candidate, index, all) => all.findIndex(item => item.id === candidate.id) === index);
75
77
  }
76
- function eligibleModels(candidates, constraints) {
78
+ function eligibleModels(candidates, constraints, policy) {
77
79
  return candidates.filter(candidate => {
78
80
  if (candidate.resourceHealthy === false)
79
81
  return false;
82
+ if (policy && !policyAllows(candidate, policy.policy))
83
+ return false;
80
84
  if (constraints?.provider && candidate.provider !== constraints.provider)
81
85
  return false;
82
86
  if (constraints?.model && candidate.model !== constraints.model)
@@ -88,37 +92,59 @@ function eligibleModels(candidates, constraints) {
88
92
  return true;
89
93
  });
90
94
  }
91
- function deterministicModel(candidates) {
95
+ function deterministicModel(candidates, taskKind, policy) {
92
96
  return [...candidates].map((candidate, index) => ({ candidate, index })).sort((left, right) => {
93
97
  const pressure = (item) => item.resourceUsedPercent === undefined ? 1 : item.resourceUsedPercent >= 90 ? 2 : 0;
94
- return pressure(left.candidate) - pressure(right.candidate) || left.index - right.index;
98
+ return pressure(left.candidate) - pressure(right.candidate) || (policy ? policyRank(right.candidate, taskKind, policy.policy) - policyRank(left.candidate, taskKind, policy.policy) : 0) || left.index - right.index;
95
99
  })[0].candidate;
96
100
  }
97
101
  export async function routeModel(worker, candidates, options = {}) {
98
- const eligible = eligibleModels(candidates, options.constraints);
102
+ const policy = await loadRoutingPolicy(options.policyPath, options.rulesPath);
103
+ const eligible = eligibleModels(candidates, options.constraints, policy);
99
104
  if (eligible.length === 0)
100
105
  throw new Error(`No eligible model profile is usable by worker ${worker.workerId}`);
101
- const deterministic = deterministicModel(eligible);
106
+ const taskKind = typeof options.taskFacts?.kind === 'string' ? options.taskFacts.kind : undefined;
107
+ const deterministic = deterministicModel(eligible, taskKind, policy);
102
108
  const choice = eligible.length === 1
103
109
  ? { choice: deterministic.id, evidence: { mode: options.decisionMode ?? 'off', fallback: true } }
104
110
  : await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', {
105
111
  facts: JSON.parse(JSON.stringify({
106
112
  task: options.taskFacts ?? {},
113
+ environment_policy: { structured_policy: policy.snapshot, rules: policy.rules.text },
114
+ prior_decision_id: options.priorDecisionId ?? null,
115
+ prior_failure: options.priorFailure ?? null,
107
116
  worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
108
117
  candidates: eligible.map(item => ({ id: item.id, provider: item.provider, model: item.model, profile: item.profile ?? null, reasoning: item.reasoning ?? null, resourceHealthy: item.resourceHealthy ?? null, resourceUsedPercent: item.resourceUsedPercent ?? null, resourceRemainingPercent: item.resourceRemainingPercent ?? null, resourceResetAt: item.resourceResetAt ?? null, resourceResetInMinutes: item.resourceResetInMinutes ?? null, resourceWindows: item.resourceWindows ?? null })),
109
118
  })),
110
- }, { type: 'choice', question: 'Which eligible model profile is best suited to execute this task? Prefer spending otherwise-expiring paid quota on stronger reasoning when reset is near and substantial capacity remains, but consider every quota window so a tighter longer-horizon limit is not needlessly depleted; never violate task fit or continuity.', choices: eligible.map(item => item.id) }, () => deterministic.id);
119
+ }, { type: 'choice', question: 'Which eligible model profile is best suited to execute this task? Apply hard gates first, then explicit task constraints, then current environment policy; consider every quota window and never violate task fit or continuity.', choices: eligible.map(item => item.id) }, () => deterministic.id);
111
120
  const selected = eligible.find(item => item.id === choice.choice) ?? deterministic;
121
+ const routing = {
122
+ provider: selected.provider,
123
+ model: selected.model,
124
+ profile: selected.profile,
125
+ reasoning: options.constraints?.reasoning ?? selected.reasoning,
126
+ decidedBy: choice.evidence.engine && !choice.evidence.fallback ? 'model-router:laya' : 'model-router',
127
+ decidedAt: new Date().toISOString(),
128
+ decisionEvidence: choice.evidence,
129
+ };
130
+ const audit = makeDecisionAudit({
131
+ task: options.auditTask,
132
+ worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
133
+ contract: { question: 'model-profile-selection', task: options.taskFacts ?? {}, constraints: options.constraints ?? {} },
134
+ policy,
135
+ candidates,
136
+ eligibleCandidates: eligible,
137
+ selected,
138
+ routing,
139
+ priorDecisionId: options.priorDecisionId,
140
+ priorFailure: options.priorFailure,
141
+ });
142
+ routing.decisionId = audit.decision_id;
143
+ if (options.auditStore)
144
+ await options.auditStore.append(audit);
112
145
  return {
113
146
  candidate: selected,
114
- routing: {
115
- provider: selected.provider,
116
- model: selected.model,
117
- profile: selected.profile,
118
- reasoning: options.constraints?.reasoning ?? selected.reasoning,
119
- decidedBy: choice.evidence.engine && !choice.evidence.fallback ? 'model-router:laya' : 'model-router',
120
- decidedAt: new Date().toISOString(),
121
- decisionEvidence: choice.evidence,
122
- },
147
+ routing,
148
+ audit,
123
149
  };
124
150
  }
package/dist/planner.js CHANGED
@@ -140,7 +140,14 @@ export function parsePlannerOutput(output) {
140
140
  return parsePlannerResult(JSON.parse(candidate));
141
141
  }
142
142
  export function plannerInput(context) {
143
- return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, execution_context: context.executionContext, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
143
+ const visibleAgents = context.executionContext?.agents.map(agent => ({
144
+ name: agent.name,
145
+ description: agent.description,
146
+ capabilities: agent.capabilities,
147
+ boundaries: agent.restrictions,
148
+ runtime: agent.runtime,
149
+ }));
150
+ return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, visible_agents: visibleAgents ?? [], execution_context: context.executionContext, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
144
151
  }
145
152
  export class CommandObjectivePlanner {
146
153
  command;
@@ -109,12 +109,25 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
109
109
  const registered = await this.store.listAgents();
110
110
  const candidates = workerCandidates(this.options.adapters, registered, usage);
111
111
  for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.agentName !== 'reviewer' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
112
- const requirements = task.workerRequirements ?? { capabilities: task.capabilities.length > 0 ? task.capabilities : ['model'] };
113
- const taskFacts = { id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M', capabilities: task.capabilities };
114
- const routedWorker = await routeWorker(candidates, requirements, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts });
112
+ const requirements = task.workerRequirements ?? { capabilities: [] };
113
+ const taskFacts = {
114
+ id: task.id,
115
+ kind: task.kind ?? 'work',
116
+ storySize: task.storySize ?? 'M',
117
+ agentName: task.agentName ?? null,
118
+ input: task.input,
119
+ attachments: task.attachments ?? [],
120
+ output: task.output ?? null,
121
+ };
122
+ const routedWorker = await routeWorker(candidates, requirements, {
123
+ decisionEngine: this.options.decisionEngine,
124
+ decisionMode: this.options.decisionMode,
125
+ taskFacts,
126
+ usage,
127
+ });
115
128
  const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
116
129
  const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
117
- const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts, constraints: routingConstraints });
130
+ const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts, constraints: routingConstraints, auditStore: this.options.decisionAuditStore, auditTask: task });
118
131
  task.workerSelection = routedWorker.selection;
119
132
  task.adapter = routedWorker.candidate.adapter;
120
133
  task.routing = routedModel.routing;