agents-relay 1.0.92 → 1.0.93
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 -0
- package/dist/cli.js +2 -1
- package/dist/decision.js +190 -0
- package/dist/model-routing.js +124 -0
- package/dist/reconciler-review.js +55 -21
- package/dist/relayd.js +2 -1
- package/dist/worker-routing.js +68 -27
- package/package.json +1 -1
- package/skills/agents-relay/contracts/task-job-contract.md +4 -3
package/README.md
CHANGED
|
@@ -69,6 +69,8 @@ snapshot as JSON on stdin and must return `{ "objective_status": "in_progress"|"
|
|
|
69
69
|
Planner-created tasks use stable IDs and the existing parent/subtask tree, so
|
|
70
70
|
retries and daemon restarts do not create duplicate work.
|
|
71
71
|
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.
|
|
72
|
+
|
|
73
|
+
Worker/model routing is code-driven: Relay gathers authoritative worker/model/resource facts, applies hard gates, then can call the generic typed `decide(request, contract)` boundary for judgment among legal candidates. The optional Laya implementation is a transient CLI subprocess per decision (no resident model/service), with deterministic fallback and `off|shadow|active` rollout. See [Worker Router, Model Router, and typed decisions](docs/worker-model-routers.md).
|
|
72
74
|
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.
|
|
73
75
|
|
|
74
76
|
Autonomous jobs can also be scheduled without a Relay database. The job marker
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } fro
|
|
|
12
12
|
import { eventFor } from './events.js';
|
|
13
13
|
import { serveDashboard } from './dashboard.js';
|
|
14
14
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
15
|
+
import { decisionMode, LayaCliDecisionEngine } from './decision.js';
|
|
15
16
|
import { discoverAgents } from './registry.js';
|
|
16
17
|
import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
|
|
17
18
|
import { extendRetryBudget, transitionJobEvent, transitionTaskEvent } from './scheduler.js';
|
|
@@ -411,7 +412,7 @@ async function runJobCommand(action, args) {
|
|
|
411
412
|
}
|
|
412
413
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
413
414
|
export function runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
|
|
414
|
-
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: job => resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()) }); }
|
|
415
|
+
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: job => resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()) }); }
|
|
415
416
|
export function dashboardTaskAction(store, reconciler, action) {
|
|
416
417
|
return async (jobId, taskId) => {
|
|
417
418
|
const job = await store.load(jobId);
|
package/dist/decision.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
function validateContract(contract) {
|
|
3
|
+
if (contract.type !== 'choice')
|
|
4
|
+
throw new Error(`Unsupported decision contract ${contract.type}`);
|
|
5
|
+
if (!contract.question.trim())
|
|
6
|
+
throw new Error('Decision choice question is required');
|
|
7
|
+
if (contract.choices.length === 0)
|
|
8
|
+
throw new Error('Decision choice contract requires at least one choice');
|
|
9
|
+
if (new Set(contract.choices).size !== contract.choices.length)
|
|
10
|
+
throw new Error('Decision choice contract contains duplicate choices');
|
|
11
|
+
if (contract.choices.some(choice => !choice.trim()))
|
|
12
|
+
throw new Error('Decision choice contract contains an empty choice');
|
|
13
|
+
}
|
|
14
|
+
function assertJson(value, path = 'facts') {
|
|
15
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string')
|
|
16
|
+
return;
|
|
17
|
+
if (typeof value === 'number') {
|
|
18
|
+
if (!Number.isFinite(value))
|
|
19
|
+
throw new Error(`${path} contains a non-finite number`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
value.forEach((item, index) => assertJson(item, `${path}[${index}]`));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'object') {
|
|
27
|
+
for (const [key, item] of Object.entries(value)) {
|
|
28
|
+
if (item === undefined)
|
|
29
|
+
throw new Error(`${path}.${key} is undefined`);
|
|
30
|
+
assertJson(item, `${path}.${key}`);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`${path} is not JSON-safe`);
|
|
35
|
+
}
|
|
36
|
+
export function validateDecisionRequest(request, contract) {
|
|
37
|
+
validateContract(contract);
|
|
38
|
+
assertJson(request.facts);
|
|
39
|
+
}
|
|
40
|
+
function probabilities(value, choices) {
|
|
41
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
42
|
+
return undefined;
|
|
43
|
+
const source = value;
|
|
44
|
+
const result = {};
|
|
45
|
+
for (const choice of choices) {
|
|
46
|
+
const score = source[choice];
|
|
47
|
+
if (typeof score !== 'number' || !Number.isFinite(score) || score < 0 || score > 1)
|
|
48
|
+
return undefined;
|
|
49
|
+
result[choice] = score;
|
|
50
|
+
}
|
|
51
|
+
return Object.keys(result).length === choices.length ? result : undefined;
|
|
52
|
+
}
|
|
53
|
+
export function parseChoiceDecision(raw, contract) {
|
|
54
|
+
validateContract(contract);
|
|
55
|
+
if (!raw || typeof raw !== 'object')
|
|
56
|
+
throw new Error('Decision output must be a JSON object');
|
|
57
|
+
const envelope = raw;
|
|
58
|
+
const nested = envelope.answers && typeof envelope.answers === 'object' && !Array.isArray(envelope.answers)
|
|
59
|
+
? envelope.answers.decision
|
|
60
|
+
: undefined;
|
|
61
|
+
const answer = nested && typeof nested === 'object' && !Array.isArray(nested) ? nested : envelope;
|
|
62
|
+
const choice = typeof answer.choice === 'string' ? answer.choice : '';
|
|
63
|
+
if (!contract.choices.includes(choice))
|
|
64
|
+
throw new Error(`Decision choice ${choice || '<missing>'} is outside the contract`);
|
|
65
|
+
const result = { type: 'choice', choice };
|
|
66
|
+
const parsedProbabilities = probabilities(answer.probabilities, contract.choices);
|
|
67
|
+
if (parsedProbabilities)
|
|
68
|
+
result.probabilities = parsedProbabilities;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Stateless Laya invocation. The command receives one JSON request on stdin and
|
|
73
|
+
* must emit one JSON decision on stdout. It is intentionally a subprocess per
|
|
74
|
+
* decision: no daemon, resident model, socket, or PM2 lifecycle is introduced.
|
|
75
|
+
*/
|
|
76
|
+
export class LayaCliDecisionEngine {
|
|
77
|
+
name = 'laya-cli';
|
|
78
|
+
command;
|
|
79
|
+
args;
|
|
80
|
+
timeoutMs;
|
|
81
|
+
env;
|
|
82
|
+
constructor(options = {}) {
|
|
83
|
+
this.command = options.command ?? process.env.AGENTS_RELAY_LAYA_COMMAND ?? 'npx';
|
|
84
|
+
this.args = options.args ?? layaArgsFromEnv();
|
|
85
|
+
this.timeoutMs = options.timeoutMs ?? Number(process.env.AGENTS_RELAY_LAYA_TIMEOUT_MS ?? 30_000);
|
|
86
|
+
this.env = options.env;
|
|
87
|
+
}
|
|
88
|
+
async decide(request, contract) {
|
|
89
|
+
validateDecisionRequest(request, contract);
|
|
90
|
+
const payload = {
|
|
91
|
+
state: request.facts,
|
|
92
|
+
questions: {
|
|
93
|
+
decision: {
|
|
94
|
+
type: contract.type,
|
|
95
|
+
instructions: contract.question,
|
|
96
|
+
criteria: [...contract.choices],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const stdout = await this.run(JSON.stringify(payload));
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(stdout);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new Error('Laya CLI returned invalid JSON');
|
|
107
|
+
}
|
|
108
|
+
return parseChoiceDecision(parsed, contract);
|
|
109
|
+
}
|
|
110
|
+
async run(stdin) {
|
|
111
|
+
return await new Promise((resolve, reject) => {
|
|
112
|
+
const child = spawn(this.command, [...this.args], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env ?? process.env });
|
|
113
|
+
const stdout = [];
|
|
114
|
+
const stderr = [];
|
|
115
|
+
let settled = false;
|
|
116
|
+
const finish = (error, output) => {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
if (error)
|
|
122
|
+
reject(error);
|
|
123
|
+
else
|
|
124
|
+
resolve(output ?? '');
|
|
125
|
+
};
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
child.kill('SIGKILL');
|
|
128
|
+
finish(new Error(`Laya CLI timed out after ${this.timeoutMs}ms`));
|
|
129
|
+
}, this.timeoutMs);
|
|
130
|
+
child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)));
|
|
131
|
+
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)));
|
|
132
|
+
child.once('error', error => finish(error));
|
|
133
|
+
child.once('close', code => {
|
|
134
|
+
if (code !== 0) {
|
|
135
|
+
const detail = Buffer.concat(stderr).toString('utf8').trim();
|
|
136
|
+
finish(new Error(`Laya CLI exited ${code}${detail ? `: ${detail}` : ''}`));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
finish(undefined, Buffer.concat(stdout).toString('utf8').trim());
|
|
140
|
+
});
|
|
141
|
+
child.stdin.end(`${stdin}\n`);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function layaArgsFromEnv() {
|
|
146
|
+
const configured = process.env.AGENTS_RELAY_LAYA_ARGS;
|
|
147
|
+
if (!configured)
|
|
148
|
+
return ['-y', 'laya', 'decide', '--json'];
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(configured);
|
|
151
|
+
if (!Array.isArray(parsed) || parsed.some(item => typeof item !== 'string'))
|
|
152
|
+
throw new Error('not a string array');
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw new Error('AGENTS_RELAY_LAYA_ARGS must be a JSON string array');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function decisionMode(value = process.env.AGENTS_RELAY_DECISION_MODE) {
|
|
160
|
+
return value === 'shadow' || value === 'active' ? value : 'off';
|
|
161
|
+
}
|
|
162
|
+
export async function chooseWithDecision(engine, mode, request, contract, fallback) {
|
|
163
|
+
validateDecisionRequest(request, contract);
|
|
164
|
+
const deterministic = fallback();
|
|
165
|
+
if (!contract.choices.includes(deterministic))
|
|
166
|
+
throw new Error('Deterministic fallback is outside the decision contract');
|
|
167
|
+
if (mode === 'off' || !engine)
|
|
168
|
+
return { choice: deterministic, evidence: { mode, fallback: true } };
|
|
169
|
+
try {
|
|
170
|
+
const result = await engine.decide(request, contract);
|
|
171
|
+
if (result.type !== 'choice')
|
|
172
|
+
throw new Error(`Unexpected decision result ${result.type}`);
|
|
173
|
+
if (mode === 'shadow') {
|
|
174
|
+
return {
|
|
175
|
+
choice: deterministic,
|
|
176
|
+
evidence: { mode, engine: engine.name, proposed: result.choice, probabilities: result.probabilities, fallback: true },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
choice: result.choice,
|
|
181
|
+
evidence: { mode, engine: engine.name, proposed: result.choice, probabilities: result.probabilities, fallback: false },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return {
|
|
186
|
+
choice: deterministic,
|
|
187
|
+
evidence: { mode, engine: engine.name, fallback: true, error: error instanceof Error ? error.message : String(error) },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { chooseWithDecision } from './decision.js';
|
|
5
|
+
import { usageForProvider } from './worker-routing.js';
|
|
6
|
+
function topLevelCodexConfig(text) {
|
|
7
|
+
const result = {};
|
|
8
|
+
let inSection = false;
|
|
9
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
10
|
+
const line = rawLine.trim();
|
|
11
|
+
if (!line || line.startsWith('#'))
|
|
12
|
+
continue;
|
|
13
|
+
if (line.startsWith('[')) {
|
|
14
|
+
inSection = true;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (inSection)
|
|
18
|
+
continue;
|
|
19
|
+
const match = line.match(/^(model|model_provider|model_reasoning_effort)\s*=\s*"([^"]+)"/);
|
|
20
|
+
if (!match)
|
|
21
|
+
continue;
|
|
22
|
+
if (match[1] === 'model')
|
|
23
|
+
result.model = match[2];
|
|
24
|
+
else if (match[1] === 'model_provider')
|
|
25
|
+
result.provider = match[2];
|
|
26
|
+
else
|
|
27
|
+
result.reasoning = match[2];
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
async function readConfig(path) {
|
|
32
|
+
try {
|
|
33
|
+
return topLevelCodexConfig(await readFile(path, 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function withResource(candidate, usage) {
|
|
40
|
+
const resource = usageForProvider(usage, candidate.provider);
|
|
41
|
+
return { ...candidate, resourceHealthy: resource.healthy, resourceUsedPercent: resource.usedPercent };
|
|
42
|
+
}
|
|
43
|
+
export async function discoverModelCandidates(worker, registered, usage, codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex')) {
|
|
44
|
+
const registration = registered.find(item => item.id === worker.workerId);
|
|
45
|
+
if (registration?.runtime.provider && registration.runtime.model) {
|
|
46
|
+
return [withResource({
|
|
47
|
+
id: `${registration.runtime.provider}/${registration.runtime.model}`,
|
|
48
|
+
provider: registration.runtime.provider,
|
|
49
|
+
model: registration.runtime.model,
|
|
50
|
+
}, usage)];
|
|
51
|
+
}
|
|
52
|
+
if (worker.adapter === 'chatgpt') {
|
|
53
|
+
return [withResource({ id: 'chatgpt/account-defaults', provider: 'chatgpt', model: 'account-defaults' }, usage)];
|
|
54
|
+
}
|
|
55
|
+
const base = await readConfig(join(codexHome, 'config.toml')) ?? {};
|
|
56
|
+
const candidates = [];
|
|
57
|
+
if (base.model)
|
|
58
|
+
candidates.push(withResource({ id: `default:${base.provider ?? 'openai'}/${base.model}`, provider: base.provider ?? 'openai', model: base.model, reasoning: base.reasoning }, usage));
|
|
59
|
+
let files = [];
|
|
60
|
+
try {
|
|
61
|
+
files = (await readdir(codexHome)).filter(name => name.endsWith('.config.toml')).sort();
|
|
62
|
+
}
|
|
63
|
+
catch { /* no profiles */ }
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const config = await readConfig(join(codexHome, file));
|
|
66
|
+
if (!config?.model)
|
|
67
|
+
continue;
|
|
68
|
+
const profile = basename(file, '.config.toml');
|
|
69
|
+
const provider = config.provider ?? base.provider ?? 'openai';
|
|
70
|
+
candidates.push(withResource({ id: `${profile}:${provider}/${config.model}`, provider, model: config.model, profile, reasoning: config.reasoning ?? base.reasoning }, usage));
|
|
71
|
+
}
|
|
72
|
+
if (candidates.length === 0)
|
|
73
|
+
candidates.push(withResource({ id: 'default:openai/gpt-5.6-luna', provider: 'openai', model: 'gpt-5.6-luna' }, usage));
|
|
74
|
+
return candidates.filter((candidate, index, all) => all.findIndex(item => item.id === candidate.id) === index);
|
|
75
|
+
}
|
|
76
|
+
function eligibleModels(candidates, constraints) {
|
|
77
|
+
return candidates.filter(candidate => {
|
|
78
|
+
if (candidate.resourceHealthy === false)
|
|
79
|
+
return false;
|
|
80
|
+
if (constraints?.provider && candidate.provider !== constraints.provider)
|
|
81
|
+
return false;
|
|
82
|
+
if (constraints?.model && candidate.model !== constraints.model)
|
|
83
|
+
return false;
|
|
84
|
+
if (constraints?.profile && candidate.profile !== constraints.profile)
|
|
85
|
+
return false;
|
|
86
|
+
if (constraints?.reasoning && candidate.reasoning && candidate.reasoning !== constraints.reasoning)
|
|
87
|
+
return false;
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function deterministicModel(candidates) {
|
|
92
|
+
return [...candidates].map((candidate, index) => ({ candidate, index })).sort((left, right) => {
|
|
93
|
+
const pressure = (item) => item.resourceUsedPercent === undefined ? 1 : item.resourceUsedPercent >= 90 ? 2 : 0;
|
|
94
|
+
return pressure(left.candidate) - pressure(right.candidate) || left.index - right.index;
|
|
95
|
+
})[0].candidate;
|
|
96
|
+
}
|
|
97
|
+
export async function routeModel(worker, candidates, options = {}) {
|
|
98
|
+
const eligible = eligibleModels(candidates, options.constraints);
|
|
99
|
+
if (eligible.length === 0)
|
|
100
|
+
throw new Error(`No eligible model profile is usable by worker ${worker.workerId}`);
|
|
101
|
+
const deterministic = deterministicModel(eligible);
|
|
102
|
+
const choice = eligible.length === 1
|
|
103
|
+
? { choice: deterministic.id, evidence: { mode: options.decisionMode ?? 'off', fallback: true } }
|
|
104
|
+
: await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', {
|
|
105
|
+
facts: JSON.parse(JSON.stringify({
|
|
106
|
+
task: options.taskFacts ?? {},
|
|
107
|
+
worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
|
|
108
|
+
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 })),
|
|
109
|
+
})),
|
|
110
|
+
}, { type: 'choice', question: 'Which eligible model profile is best suited to execute this task?', choices: eligible.map(item => item.id) }, () => deterministic.id);
|
|
111
|
+
const selected = eligible.find(item => item.id === choice.choice) ?? deterministic;
|
|
112
|
+
return {
|
|
113
|
+
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
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -3,7 +3,8 @@ import { schedule, transitionTaskEvent, transitionJobEvent } from './scheduler.j
|
|
|
3
3
|
import { implementationComplete } from './runtime-status.js';
|
|
4
4
|
import { eventFor } from './events.js';
|
|
5
5
|
import { selectReviewer } from './reviewer-routing.js';
|
|
6
|
-
import {
|
|
6
|
+
import { routeWorker, workerCandidates } from './worker-routing.js';
|
|
7
|
+
import { discoverModelCandidates, routeModel } from './model-routing.js';
|
|
7
8
|
import { ReconcilerRuntimeBase } from './reconciler-runtime.js';
|
|
8
9
|
export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
9
10
|
async currentHeadSha(job) { return await this.store.headSha?.() ?? job.headSha ?? job.review?.headSha ?? null; }
|
|
@@ -52,43 +53,76 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
52
53
|
const configured = this.options.reviewer;
|
|
53
54
|
const registered = await this.store.listAgents();
|
|
54
55
|
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
55
|
-
const
|
|
56
|
-
let constrained = candidates.filter(candidate => {
|
|
56
|
+
const workers = workerCandidates(this.options.adapters, registered, usage).filter(candidate => {
|
|
57
57
|
if (configured?.workerId && configured.workerId !== candidate.workerId)
|
|
58
58
|
return false;
|
|
59
59
|
if (configured?.adapter && configured.adapter !== candidate.adapter)
|
|
60
60
|
return false;
|
|
61
|
-
if (configured?.routing?.provider && configured.routing.provider !== candidate.routing.provider)
|
|
62
|
-
return false;
|
|
63
|
-
if (configured?.routing?.model && configured.routing.model !== candidate.routing.model)
|
|
64
|
-
return false;
|
|
65
61
|
return true;
|
|
66
|
-
})
|
|
62
|
+
});
|
|
67
63
|
// Explicit reviewer routing is a caller-owned hard constraint and remains
|
|
68
64
|
// readable in isolated/test runtimes that do not instantiate that adapter.
|
|
69
|
-
if (
|
|
70
|
-
|
|
65
|
+
if (workers.length === 0 && configured?.adapter && configured.routing) {
|
|
66
|
+
const candidate = {
|
|
67
|
+
workerId: configured.workerId ?? configured.adapter,
|
|
68
|
+
adapter: configured.adapter,
|
|
69
|
+
capabilities: ['model'],
|
|
70
|
+
runtime: configured.adapter,
|
|
71
|
+
routing: configured.routing,
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
candidate,
|
|
75
|
+
selection: { workerId: candidate.workerId, adapter: candidate.adapter, capabilities: [...candidate.capabilities], selectedBy: 'worker-router:reviewer-diversity:eligible-fallback', selectedAt: new Date().toISOString() },
|
|
76
|
+
reason: 'eligible-fallback',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const previews = new Map();
|
|
80
|
+
for (const worker of workers) {
|
|
81
|
+
try {
|
|
82
|
+
const models = await discoverModelCandidates(worker, registered, usage);
|
|
83
|
+
const preview = await routeModel(worker, models, { decisionMode: 'off', constraints: configured?.routing });
|
|
84
|
+
previews.set(worker.workerId, { ...worker, routing: preview.routing });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const result = selectReviewer([...previews.values()], implementationTasks, configured?.requirements ?? { capabilities: ['model'] });
|
|
91
|
+
const selectedWorker = workers.find(worker => worker.workerId === result.selection.workerId);
|
|
92
|
+
if (selectedWorker) {
|
|
93
|
+
result.selection.resourceHealthy = selectedWorker.resourceHealthy;
|
|
94
|
+
result.selection.resourceProvider = selectedWorker.resourceProvider;
|
|
95
|
+
result.selection.resourceUsedPercent = selectedWorker.resourceUsedPercent;
|
|
96
|
+
const models = await discoverModelCandidates(selectedWorker, registered, usage);
|
|
97
|
+
const routedModel = await routeModel(selectedWorker, models, {
|
|
98
|
+
decisionEngine: this.options.decisionEngine,
|
|
99
|
+
decisionMode: this.options.decisionMode,
|
|
100
|
+
constraints: configured?.routing,
|
|
101
|
+
taskFacts: { role: 'reviewer', implementationWorkers: implementationTasks.map(task => task.workerSelection?.workerId ?? task.adapter) },
|
|
102
|
+
});
|
|
103
|
+
result.candidate.routing = routedModel.routing;
|
|
71
104
|
}
|
|
72
|
-
const result = selectReviewer(constrained, implementationTasks, configured?.requirements ?? { capabilities: ['model'] });
|
|
73
|
-
const resource = usageForProvider(usage, result.candidate.routing.provider);
|
|
74
|
-
result.selection.resourceHealthy = resource.healthy;
|
|
75
|
-
result.selection.resourceProvider = resource.healthy === undefined ? undefined : result.candidate.routing.provider;
|
|
76
|
-
result.selection.resourceUsedPercent = resource.usedPercent;
|
|
77
105
|
return result;
|
|
78
106
|
}
|
|
79
107
|
async routeExecutableTasks(job) {
|
|
80
108
|
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
81
|
-
const
|
|
109
|
+
const registered = await this.store.listAgents();
|
|
110
|
+
const candidates = workerCandidates(this.options.adapters, registered, usage);
|
|
82
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'))) {
|
|
83
112
|
const requirements = task.workerRequirements ?? { capabilities: task.capabilities.length > 0 ? task.capabilities : ['model'] };
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
task.routing
|
|
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 });
|
|
115
|
+
const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
|
|
116
|
+
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 });
|
|
118
|
+
task.workerSelection = routedWorker.selection;
|
|
119
|
+
task.adapter = routedWorker.candidate.adapter;
|
|
120
|
+
task.routing = routedModel.routing;
|
|
88
121
|
task.timeoutMs = task.timeoutMs || taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
|
|
89
122
|
task.updatedAt = new Date().toISOString();
|
|
90
123
|
await this.store.saveTask(task);
|
|
91
|
-
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${
|
|
124
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${routedWorker.selection.workerId} selected for task ${task.id}`, 'user', { workerSelection: routedWorker.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
|
|
125
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'model.selected', 'queued', `Selected ${task.routing.model} · thinking ${task.routing.reasoning ?? 'unknown'}`, 'user', { routing: task.routing }, { agent: 'relay', component: 'agents-relay' }));
|
|
92
126
|
}
|
|
93
127
|
}
|
|
94
128
|
isSupersededTask(task) {
|
package/dist/relayd.js
CHANGED
|
@@ -15,6 +15,7 @@ import { dashboardJobAction, dashboardTaskAction, isEntrypoint, runtimePlanner,
|
|
|
15
15
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
16
16
|
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath } from './workspace.js';
|
|
17
17
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
18
|
+
import { decisionMode, LayaCliDecisionEngine } from './decision.js';
|
|
18
19
|
export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
|
|
19
20
|
|
|
20
21
|
Usage:
|
|
@@ -71,7 +72,7 @@ export async function runDaemon(argv) {
|
|
|
71
72
|
const repositoryPaths = new Map(discoveredRepositories.map(item => [item.repository, item.path]));
|
|
72
73
|
const explicitProjectPath = value(argv, '--project-path');
|
|
73
74
|
const usageRegistry = codexAndZaiUsageRegistry();
|
|
74
|
-
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
|
|
75
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
|
|
75
76
|
if (repositories.length === 0)
|
|
76
77
|
throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
|
|
77
78
|
const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
|
package/dist/worker-routing.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
+
import { chooseWithDecision } from './decision.js';
|
|
1
2
|
export function usageForProvider(snapshot, provider) {
|
|
2
3
|
const usage = snapshot?.providers.find(item => item.id === provider)?.usage;
|
|
3
|
-
if (
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
if (usage?.available) {
|
|
5
|
+
const usedPercent = Math.max(usage.fiveHour?.usedPercent ?? 0, usage.weekly?.usedPercent ?? 0);
|
|
6
|
+
return { healthy: usedPercent < 100, usedPercent };
|
|
7
|
+
}
|
|
8
|
+
const credits = snapshot?.credits.find(item => item.id === provider)?.credits;
|
|
9
|
+
if (credits?.available) {
|
|
10
|
+
const knownRemaining = credits.balances.map(item => item.remaining).filter((value) => typeof value === 'number' && Number.isFinite(value));
|
|
11
|
+
if (knownRemaining.length > 0)
|
|
12
|
+
return { healthy: knownRemaining.some(value => value > 0) };
|
|
13
|
+
}
|
|
14
|
+
return {};
|
|
7
15
|
}
|
|
8
16
|
export function workerCandidates(adapters, registered, snapshot) {
|
|
9
17
|
return adapters.filter(adapter => adapter.name !== 'orchestrator').flatMap(adapter => {
|
|
10
|
-
const defaults = adapter.name === 'chatgpt' ? { provider: 'chatgpt', model: 'account-defaults' } : { provider: 'openai', model: 'gpt-5.6-luna' };
|
|
11
18
|
const workers = registered.filter(worker => worker.runtime.adapter === adapter.name);
|
|
12
19
|
return (workers.length > 0 ? workers : [undefined]).map(worker => {
|
|
13
|
-
const provider = worker?.runtime.provider ??
|
|
20
|
+
const provider = worker?.runtime.provider ?? (adapter.name === 'chatgpt' ? 'chatgpt' : 'openai');
|
|
14
21
|
const resource = usageForProvider(snapshot, provider);
|
|
15
22
|
return {
|
|
16
23
|
workerId: worker?.id ?? adapter.id,
|
|
@@ -27,12 +34,11 @@ export function workerCandidates(adapters, registered, snapshot) {
|
|
|
27
34
|
resourceHealthy: resource.healthy,
|
|
28
35
|
resourceProvider: provider,
|
|
29
36
|
resourceUsedPercent: resource.usedPercent,
|
|
30
|
-
routing: { provider, model: worker?.runtime.model ?? defaults.model, decidedBy: 'model-router', decidedAt: new Date().toISOString() },
|
|
31
37
|
};
|
|
32
38
|
});
|
|
33
39
|
});
|
|
34
40
|
}
|
|
35
|
-
function
|
|
41
|
+
export function satisfiesWorker(candidate, requirements) {
|
|
36
42
|
if (candidate.availability && candidate.availability !== 'available')
|
|
37
43
|
return false;
|
|
38
44
|
if (candidate.resourceHealthy === false)
|
|
@@ -51,26 +57,61 @@ function satisfies(candidate, requirements) {
|
|
|
51
57
|
return false;
|
|
52
58
|
return true;
|
|
53
59
|
}
|
|
54
|
-
export function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
export function eligibleWorkerCandidates(candidates, requirements) {
|
|
61
|
+
return candidates.filter(candidate => satisfiesWorker(candidate, requirements));
|
|
62
|
+
}
|
|
63
|
+
function deterministicWorker(eligible) {
|
|
64
|
+
return [...eligible].map((candidate, index) => ({ candidate, index })).sort((left, right) => {
|
|
59
65
|
const pressure = (item) => item.resourceUsedPercent === undefined ? 1 : item.resourceUsedPercent >= 90 ? 2 : 0;
|
|
60
|
-
return pressure(
|
|
61
|
-
});
|
|
62
|
-
|
|
66
|
+
return pressure(left.candidate) - pressure(right.candidate) || left.index - right.index;
|
|
67
|
+
})[0].candidate;
|
|
68
|
+
}
|
|
69
|
+
function selection(selected, selectedBy, decisionEvidence) {
|
|
63
70
|
return {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
resourceUsedPercent: selected.resourceUsedPercent,
|
|
74
|
-
},
|
|
71
|
+
workerId: selected.workerId,
|
|
72
|
+
adapter: selected.adapter,
|
|
73
|
+
capabilities: [...selected.capabilities],
|
|
74
|
+
selectedBy,
|
|
75
|
+
selectedAt: new Date().toISOString(),
|
|
76
|
+
resourceHealthy: selected.resourceHealthy,
|
|
77
|
+
resourceProvider: selected.resourceProvider,
|
|
78
|
+
resourceUsedPercent: selected.resourceUsedPercent,
|
|
79
|
+
decisionEvidence,
|
|
75
80
|
};
|
|
76
81
|
}
|
|
82
|
+
/** Deterministic fallback and backwards-compatible direct selector. */
|
|
83
|
+
export function selectWorker(candidates, requirements) {
|
|
84
|
+
const eligible = eligibleWorkerCandidates(candidates, requirements);
|
|
85
|
+
if (eligible.length === 0)
|
|
86
|
+
throw new Error('No eligible worker satisfies the declared requirements');
|
|
87
|
+
const selected = deterministicWorker(eligible);
|
|
88
|
+
return { candidate: selected, selection: selection(selected, 'worker-router:requirements') };
|
|
89
|
+
}
|
|
90
|
+
/** Gathered facts and hard gates stay in code; Laya only judges among legal choices. */
|
|
91
|
+
export async function routeWorker(candidates, requirements, options = {}) {
|
|
92
|
+
const eligible = eligibleWorkerCandidates(candidates, requirements);
|
|
93
|
+
if (eligible.length === 0)
|
|
94
|
+
throw new Error('No eligible worker satisfies the declared requirements');
|
|
95
|
+
const deterministic = deterministicWorker(eligible);
|
|
96
|
+
if (eligible.length === 1) {
|
|
97
|
+
const evidence = { mode: options.decisionMode ?? 'off', fallback: true };
|
|
98
|
+
return { candidate: deterministic, selection: selection(deterministic, 'worker-router:requirements', evidence) };
|
|
99
|
+
}
|
|
100
|
+
const facts = JSON.parse(JSON.stringify({
|
|
101
|
+
task: options.taskFacts ?? {},
|
|
102
|
+
requirements,
|
|
103
|
+
candidates: eligible.map(item => ({
|
|
104
|
+
id: item.workerId,
|
|
105
|
+
adapter: item.adapter,
|
|
106
|
+
capabilities: [...item.capabilities],
|
|
107
|
+
runtime: item.runtime ?? null,
|
|
108
|
+
resourceHealthy: item.resourceHealthy ?? null,
|
|
109
|
+
resourceProvider: item.resourceProvider ?? null,
|
|
110
|
+
resourceUsedPercent: item.resourceUsedPercent ?? null,
|
|
111
|
+
})),
|
|
112
|
+
}));
|
|
113
|
+
const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, { type: 'choice', question: 'Which eligible worker is best suited to execute this task?', choices: eligible.map(item => item.workerId) }, () => deterministic.workerId);
|
|
114
|
+
const selected = eligible.find(item => item.workerId === choice.choice) ?? deterministic;
|
|
115
|
+
const selectedBy = choice.evidence.engine && !choice.evidence.fallback ? 'worker-router:laya' : 'worker-router:requirements';
|
|
116
|
+
return { candidate: selected, selection: selection(selected, selectedBy, choice.evidence) };
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -55,9 +55,10 @@ Keep these concepts distinct: observation is not root cause; hypothesis is not r
|
|
|
55
55
|
Every new model-backed executable task follows this order:
|
|
56
56
|
|
|
57
57
|
1. The planner declares `workerRequirements` (capabilities and optional runtime, task-kind, trust-domain, region, and label constraints).
|
|
58
|
-
2. `worker-router`
|
|
59
|
-
3. `model-router`
|
|
60
|
-
4.
|
|
58
|
+
2. `worker-router` is code-driven: it gathers authoritative worker/resource facts, applies capability/availability and other hard gates, then may call the generic typed `decide(request, contract)` primitive only among eligible workers.
|
|
59
|
+
3. `model-router` runs after worker selection, discovers model/profile facts usable by that worker, applies model hard gates, then may use the same typed `decide()` primitive among eligible profiles. Router judgment is not a free-form agent invocation.
|
|
60
|
+
4. `decide()` owns no routing facts or legality policy. Its optional Laya implementation is a transient CLI subprocess per decision; unavailable/invalid decisions fall back to deterministic routing. `src/usage.ts` remains the shared resource-fact source.
|
|
61
|
+
5. Relay persists the selected worker and model routing on the durable task before launch.
|
|
61
62
|
|
|
62
63
|
Planner output must not hard-code `codex`/`chatgpt`, provider preference, or usage probes. The worker-router and model-router are separate responsibilities; usage readers remain owned by `src/usage.ts` and are not duplicated in either router. Legacy task records that already contain adapter/routing metadata remain readable, but newly produced planner tasks must carry worker requirements.
|
|
63
64
|
|