agents-relay 2.0.33 → 2.0.35
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 +1 -1
- package/dist/adapters.js +119 -50
- package/dist/cli-help.js +2 -2
- package/dist/cli.js +3 -2
- package/dist/execution-routing.js +4 -5
- package/dist/model-routing.js +2 -2
- package/dist/relayd.js +14 -5
- package/dist/router-evals.js +2 -3
- package/dist/router-rules.js +21 -1
- package/dist/store.js +1 -1
- package/dist/worker-routing.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ Autonomous jobs can also be scheduled. The Job marker stores a five-field cron e
|
|
|
128
128
|
[Scheduled autonomous jobs](docs/scheduled-autonomous-jobs.md) for the exact
|
|
129
129
|
operational contract.
|
|
130
130
|
|
|
131
|
-
job create first resolves an existing open PR with the same head/base, then creates it only when needed. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker. Job creation is always create-only. Task execution options belong to `task create`, and the orchestrator may create zero, one, or multiple initial Tasks according to user intent.
|
|
131
|
+
job create first resolves an existing open PR with the same head/base, then creates it only when needed. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker. Job creation is always create-only and always enables auto-merge; `--no-auto-merge` is not a `job create` option. Existing Jobs can still change that setting with `job update`. Task execution options belong to `task create`, and the orchestrator may create zero, one, or multiple initial Tasks according to user intent.
|
|
132
132
|
|
|
133
133
|
To create a Job on an existing unmanaged PR, use the same `job create` action with `--pr` instead of `--head`:
|
|
134
134
|
|
package/dist/adapters.js
CHANGED
|
@@ -120,6 +120,8 @@ export class CodexAdapter {
|
|
|
120
120
|
return execution;
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
export const CHATGPT_BROWSER_WORKSPACE = 'agents-replay';
|
|
124
|
+
export const CHATGPT_BROWSER_WORKSPACE_POOL_SIZE = 6;
|
|
123
125
|
export function chatGptTabClosePolicy(value, fallback = 'after-start') {
|
|
124
126
|
const policy = value || fallback;
|
|
125
127
|
if (!['after-start', 'never', 'after-terminal'].includes(policy))
|
|
@@ -163,62 +165,129 @@ function parseDriverJson(output) {
|
|
|
163
165
|
}
|
|
164
166
|
throw new Error('browser-worker driver returned no submitted receipt');
|
|
165
167
|
}
|
|
166
|
-
function
|
|
167
|
-
return (
|
|
168
|
-
const
|
|
169
|
-
const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt, '--release-file', releaseFile, '--close-policy', request.tabClosePolicy ?? 'after-start'];
|
|
170
|
-
for (const file of request.files ?? [])
|
|
171
|
-
args.push('--file', file);
|
|
172
|
-
const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
168
|
+
function browserWorkspaceCommand(command, args, stdin) {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const child = spawn(command, [...args], { shell: false, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
|
|
173
171
|
let stdout = '';
|
|
174
172
|
let stderr = '';
|
|
175
|
-
|
|
173
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
174
|
+
child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
|
|
175
|
+
child.on('error', reject);
|
|
176
|
+
child.on('close', code => {
|
|
177
|
+
if (code === 0)
|
|
178
|
+
resolve(stdout.trim());
|
|
179
|
+
else
|
|
180
|
+
reject(new Error(stderr.trim() || stdout.trim() || `browser-workspace exited ${code}`));
|
|
181
|
+
});
|
|
182
|
+
if (stdin !== undefined) {
|
|
183
|
+
child.stdin?.end(stdin);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function parseWorkspaceJson(output) {
|
|
188
|
+
const lines = output.trim().split(/\r?\n/).filter(Boolean);
|
|
189
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(lines[index]);
|
|
192
|
+
}
|
|
193
|
+
catch { /* diagnostics may precede JSON */ }
|
|
194
|
+
}
|
|
195
|
+
throw new Error('browser-workspace returned no JSON result');
|
|
196
|
+
}
|
|
197
|
+
export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND ?? 'browser-workspace') {
|
|
198
|
+
await browserWorkspaceCommand(command, ['create', CHATGPT_BROWSER_WORKSPACE, String(CHATGPT_BROWSER_WORKSPACE_POOL_SIZE)]);
|
|
199
|
+
}
|
|
200
|
+
function sessionBrowserWorkerRunner(root) {
|
|
201
|
+
const command = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND ?? 'browser-workspace';
|
|
202
|
+
return (_definition, request, _route, signal) => {
|
|
203
|
+
const policy = request.tabClosePolicy ?? 'after-start';
|
|
204
|
+
let activeSessionId;
|
|
205
|
+
let released = false;
|
|
206
|
+
let releaseResolve;
|
|
207
|
+
const releaseBarrier = new Promise(resolve => { releaseResolve = resolve; });
|
|
208
|
+
const release = () => {
|
|
209
|
+
if (released)
|
|
210
|
+
return;
|
|
211
|
+
released = true;
|
|
212
|
+
releaseResolve();
|
|
213
|
+
};
|
|
176
214
|
let submittedResolve;
|
|
177
215
|
let submittedReject;
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (!submitted) {
|
|
182
|
-
try {
|
|
183
|
-
const response = parseDriverJson(stdout);
|
|
184
|
-
submitted = true;
|
|
185
|
-
submittedResolve?.(JSON.stringify(response));
|
|
186
|
-
}
|
|
187
|
-
catch { /* receipt may be split across chunks */ }
|
|
188
|
-
}
|
|
216
|
+
const submitted = new Promise((resolve, reject) => {
|
|
217
|
+
submittedResolve = resolve;
|
|
218
|
+
submittedReject = reject;
|
|
189
219
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
const promise = (async () => {
|
|
221
|
+
const configFile = join(process.env.TMPDIR ?? '/tmp', `agents-relay-chatgpt-${randomUUID()}.json`);
|
|
222
|
+
try {
|
|
223
|
+
const [readiness, submitSource, temporarySource] = await Promise.all([
|
|
224
|
+
readFile(join(root, 'scripts', '_submit_readiness.py'), 'utf8'),
|
|
225
|
+
readFile(join(root, 'scripts', '_submit_bh.py'), 'utf8'),
|
|
226
|
+
readFile(join(root, 'scripts', '_temporary_bh.py'), 'utf8'),
|
|
227
|
+
]);
|
|
228
|
+
await writeFile(configFile, JSON.stringify({
|
|
229
|
+
prompt: request.prompt,
|
|
230
|
+
file: request.files ?? [],
|
|
231
|
+
close_policy: policy,
|
|
232
|
+
release_file: '',
|
|
233
|
+
}));
|
|
234
|
+
const startOutput = await browserWorkspaceCommand(command, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE]);
|
|
235
|
+
const started = parseWorkspaceJson(startOutput);
|
|
236
|
+
if (typeof started.session_id !== 'string') {
|
|
237
|
+
throw new Error('browser-workspace session start returned no session_id');
|
|
201
238
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
239
|
+
activeSessionId = started.session_id;
|
|
240
|
+
if (signal.aborted)
|
|
241
|
+
throw new Error('browser-worker execution aborted');
|
|
242
|
+
const escapedConfig = configFile.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
243
|
+
const source = [
|
|
244
|
+
readiness,
|
|
245
|
+
submitSource.replace(/^from browser_harness import \*\s*$/m, ''),
|
|
246
|
+
temporarySource.replace(/^_wait_for_release\(\)\s*$/m, ''),
|
|
247
|
+
].join('\n\n').replaceAll('__CFG_PATH__', escapedConfig);
|
|
248
|
+
const execOutput = await browserWorkspaceCommand(command, ['session', 'exec', activeSessionId], source);
|
|
249
|
+
const wrapper = parseWorkspaceJson(execOutput);
|
|
250
|
+
if (wrapper.ok !== true) {
|
|
251
|
+
throw new Error(typeof wrapper.error === 'string' ? wrapper.error : 'browser-workspace session exec failed');
|
|
209
252
|
}
|
|
210
|
-
|
|
211
|
-
|
|
253
|
+
const receipt = parseDriverJson(typeof wrapper.stdout === 'string' ? wrapper.stdout : '');
|
|
254
|
+
const result = JSON.stringify(receipt);
|
|
255
|
+
submittedResolve(result);
|
|
256
|
+
if (policy === 'never') {
|
|
257
|
+
await unlink(configFile).catch(() => { });
|
|
258
|
+
activeSessionId = undefined;
|
|
259
|
+
return result;
|
|
212
260
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
261
|
+
await releaseBarrier;
|
|
262
|
+
if (activeSessionId) {
|
|
263
|
+
await browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]);
|
|
264
|
+
activeSessionId = undefined;
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
catch (failure) {
|
|
269
|
+
if (activeSessionId) {
|
|
270
|
+
await browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]).catch(() => { });
|
|
271
|
+
activeSessionId = undefined;
|
|
272
|
+
}
|
|
273
|
+
await unlink(configFile).catch(() => { });
|
|
274
|
+
const error = failure instanceof Error ? failure : new Error(String(failure));
|
|
275
|
+
submittedReject(error);
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
})();
|
|
279
|
+
const cancel = () => {
|
|
280
|
+
release();
|
|
281
|
+
if (activeSessionId) {
|
|
282
|
+
void browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]);
|
|
283
|
+
activeSessionId = undefined;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
if (signal.aborted)
|
|
287
|
+
cancel();
|
|
288
|
+
else
|
|
289
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
290
|
+
return { promise, submitted, release, cancel };
|
|
222
291
|
};
|
|
223
292
|
}
|
|
224
293
|
function parseBrowserWorkerResponse(output) {
|
|
@@ -243,7 +312,7 @@ function parseBrowserWorkerResponse(output) {
|
|
|
243
312
|
export class ChatGptAdapter {
|
|
244
313
|
name = 'chatgpt';
|
|
245
314
|
id = 'chatgpt/browser-worker';
|
|
246
|
-
capabilities = ['model', 'chatgpt', 'browser-
|
|
315
|
+
capabilities = ['model', 'chatgpt', 'browser-workspace'];
|
|
247
316
|
agentPath;
|
|
248
317
|
runner;
|
|
249
318
|
tabClosePolicy;
|
|
@@ -276,7 +345,7 @@ export class ChatGptAdapter {
|
|
|
276
345
|
if (controller.signal.aborted)
|
|
277
346
|
throw new Error('browser-worker execution aborted');
|
|
278
347
|
const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
|
|
279
|
-
const run = (this.runner ??
|
|
348
|
+
const run = (this.runner ?? sessionBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
|
|
280
349
|
try {
|
|
281
350
|
const output = await (run.submitted ?? run.promise);
|
|
282
351
|
const response = parseBrowserWorkerResponse(output);
|
package/dist/cli-help.js
CHANGED
|
@@ -47,8 +47,8 @@ Example:
|
|
|
47
47
|
|
|
48
48
|
Required: --repo OWNER/REPO, --id JOB_ID, and either --head BRANCH or --pr NUMBER.
|
|
49
49
|
With --head, reuse/create the PR for that head. With --pr, bind an existing unmanaged PR.
|
|
50
|
-
Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --no-
|
|
51
|
-
Job creation never creates executable tasks. The orchestrator decides the initial task batch separately. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
|
|
50
|
+
Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --no-review, --tag JSON. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
|
|
51
|
+
Job creation always enables auto-merge and never creates executable tasks. The orchestrator decides the initial task batch separately. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
|
|
52
52
|
|
|
53
53
|
Example:
|
|
54
54
|
npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id job-1 --title "Objective" --objective-file job.md`,
|
package/dist/cli.js
CHANGED
|
@@ -54,7 +54,7 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
54
54
|
priority: options.priority ?? 'P2',
|
|
55
55
|
executionMode: options.executionMode ?? 'fixed',
|
|
56
56
|
tag: options.tag,
|
|
57
|
-
autoMerge:
|
|
57
|
+
autoMerge: true,
|
|
58
58
|
reviewEnabled: options.reviewEnabled ?? true,
|
|
59
59
|
prNumber: pr.number,
|
|
60
60
|
repository: repositoryName,
|
|
@@ -331,6 +331,8 @@ async function runJobCommand(action, args) {
|
|
|
331
331
|
const repo = repository(args);
|
|
332
332
|
let createObjective;
|
|
333
333
|
if (action === 'create') {
|
|
334
|
+
if (hasArg(args, '--no-auto-merge'))
|
|
335
|
+
throw new Error('job create does not accept --no-auto-merge; new Jobs always enable auto-merge');
|
|
334
336
|
const taskOnlyFlags = ['--start', '--task-id', '--input', '--input-file', '--adapter', '--provider', '--model', '--profile', '--reasoning', '--story-size', '--project', '--agent', '--parent', '--deps', '--capabilities', '--attachments', '--max-attempts', '--timeout', '--cwd', '--chatgpt-project', '--chatgpt-tab-close-policy', '--output', '--output-path', '--draft', '--queue'];
|
|
335
337
|
const invalid = taskOnlyFlags.find(flag => hasArg(args, flag));
|
|
336
338
|
if (invalid)
|
|
@@ -364,7 +366,6 @@ async function runJobCommand(action, args) {
|
|
|
364
366
|
executionMode: requestedExecutionMode(args),
|
|
365
367
|
schedule: scheduleFromArgs(args),
|
|
366
368
|
tag: jobTagFromArgs(args),
|
|
367
|
-
autoMerge: !hasArg(args, '--no-auto-merge'),
|
|
368
369
|
reviewEnabled: !hasArg(args, '--no-review'),
|
|
369
370
|
continuation: continuation(args),
|
|
370
371
|
prNumber,
|
|
@@ -2,6 +2,7 @@ import { makeExecutionDecisionAudit } from './decision-audit.js';
|
|
|
2
2
|
import { discoverModelCandidates } from './model-routing.js';
|
|
3
3
|
import { loadExecutionRouterRules, loadRoutingPolicy, policyAllows } from './routing-policy.js';
|
|
4
4
|
import { DEFAULT_CHATGPT_PREFERRED_CONCURRENCY } from './worker-routing.js';
|
|
5
|
+
import { appendRoutingInstruction } from './router-rules.js';
|
|
5
6
|
export function executionTaskShape(taskFacts = {}) {
|
|
6
7
|
const nestedTask = taskFacts.task && typeof taskFacts.task === 'object' ? taskFacts.task : undefined;
|
|
7
8
|
const raw = taskFacts.storySize ?? nestedTask?.storySize;
|
|
@@ -228,7 +229,8 @@ export async function discoverExecutionRoutes(workers, requirements, options = {
|
|
|
228
229
|
const workerReasons = workerGateEvaluations.find(item => item.workerId === worker.workerId)?.rejectionReasons ?? [];
|
|
229
230
|
const discovered = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
|
|
230
231
|
const defaults = profileDefaults(discovered);
|
|
231
|
-
const
|
|
232
|
+
const workerChoices = rules.parsed.choices.filter(choice => !choice.worker || choice.worker === worker.workerId || choice.worker === worker.adapter);
|
|
233
|
+
const explicit = resolveExplicitChoices(workerChoices, discovered);
|
|
232
234
|
const merged = mergeCandidates(defaults, explicit);
|
|
233
235
|
for (const model of merged) {
|
|
234
236
|
const rejectionReasons = [...workerReasons, ...modelGateReasons(model, options.routingConstraints, options.excludedRoutes, policy)];
|
|
@@ -244,7 +246,6 @@ export async function routeExecution(workers, requirements, options = {}) {
|
|
|
244
246
|
if (routes.length === 0)
|
|
245
247
|
throw new Error('No executable worker+model route satisfies authoritative constraints');
|
|
246
248
|
const fallback = fallbackRoute(routes);
|
|
247
|
-
const rules = await loadExecutionRouterRules(options.rulesPath);
|
|
248
249
|
const preferredChatgptConcurrency = options.preferredChatgptConcurrency ?? DEFAULT_CHATGPT_PREFERRED_CONCURRENCY;
|
|
249
250
|
const chatgptRunning = options.chatgptRunning ?? 0;
|
|
250
251
|
const taskFacts = options.taskFacts ?? {};
|
|
@@ -279,8 +280,6 @@ export async function routeExecution(workers, requirements, options = {}) {
|
|
|
279
280
|
reviewer_diversity: taskFacts.reviewer_diversity,
|
|
280
281
|
} : null,
|
|
281
282
|
},
|
|
282
|
-
user_input_rules: rules.text,
|
|
283
|
-
routing_instruction: routingInstruction,
|
|
284
283
|
runtime: {
|
|
285
284
|
chatgpt_running: chatgptRunning,
|
|
286
285
|
preferred_chatgpt_concurrency: preferredChatgptConcurrency,
|
|
@@ -288,7 +287,7 @@ export async function routeExecution(workers, requirements, options = {}) {
|
|
|
288
287
|
worker_candidates: workerProviderFacts(routes),
|
|
289
288
|
effective_choices: effectiveChoices,
|
|
290
289
|
}));
|
|
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.
|
|
290
|
+
const question = appendRoutingInstruction('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. Do not invent, add, or substitute routes that are not listed.', routingInstruction);
|
|
292
291
|
let selected = fallback;
|
|
293
292
|
let evidence = { mode: options.decisionMode ?? 'off', fallback: true };
|
|
294
293
|
const decisionStages = [];
|
package/dist/model-routing.js
CHANGED
|
@@ -5,6 +5,7 @@ import { chooseWithDecision } from './decision.js';
|
|
|
5
5
|
import { usageForProvider } from './worker-routing.js';
|
|
6
6
|
import { makeDecisionAudit } from './decision-audit.js';
|
|
7
7
|
import { loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
|
|
8
|
+
import { appendRoutingInstruction } from './router-rules.js';
|
|
8
9
|
export function modelChoiceId(candidate) {
|
|
9
10
|
return candidate.id + (candidate.reasoning ? '@' + candidate.reasoning : '');
|
|
10
11
|
}
|
|
@@ -170,14 +171,13 @@ export async function routeModel(worker, candidates, options = {}) {
|
|
|
170
171
|
: await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', {
|
|
171
172
|
facts: JSON.parse(JSON.stringify({
|
|
172
173
|
task: options.taskFacts ?? {},
|
|
173
|
-
user_input_rules: policy.rules.text,
|
|
174
174
|
structured_policy: policy.snapshot,
|
|
175
175
|
prior_decision_id: options.priorDecisionId ?? null,
|
|
176
176
|
prior_failure: options.priorFailure ?? null,
|
|
177
177
|
worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
|
|
178
178
|
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 })),
|
|
179
179
|
})),
|
|
180
|
-
}, { type: 'choice', question: 'Which eligible model profile is best suited to execute this task? Apply hard gates first, then explicit task constraints, then
|
|
180
|
+
}, { type: 'choice', question: appendRoutingInstruction('Which eligible model profile is best suited to execute this task? Apply hard gates first, then explicit task constraints, then structured policy; consider every quota window and never violate task fit or continuity.', policy.rules.parsed.routingInstruction), choices: eligible.map(modelChoiceId) }, () => modelChoiceId(deterministic));
|
|
181
181
|
const selected = eligible.find(item => modelChoiceId(item) === choice.choice) ?? deterministic;
|
|
182
182
|
const routing = {
|
|
183
183
|
provider: selected.provider,
|
package/dist/relayd.js
CHANGED
|
@@ -3,7 +3,7 @@ import { once } from 'node:events';
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { ChatGptAdapter, CodexAdapter } from './adapters.js';
|
|
6
|
+
import { ChatGptAdapter, CodexAdapter, ensureChatGptBrowserWorkspace } from './adapters.js';
|
|
7
7
|
import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
|
|
8
8
|
import { serveDashboard } from './dashboard.js';
|
|
9
9
|
import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
|
|
@@ -35,6 +35,15 @@ export async function overlayOperationalOverviews(overviews, storeFor) {
|
|
|
35
35
|
return { ...item, job };
|
|
36
36
|
}));
|
|
37
37
|
}
|
|
38
|
+
function managedOverviewKey(item) {
|
|
39
|
+
return item.job.repository + '#' + item.job.prNumber + ':' + item.job.id;
|
|
40
|
+
}
|
|
41
|
+
export function mergeOperationalInventory(current, discovered) {
|
|
42
|
+
const merged = new Map(current.map(item => [managedOverviewKey(item), item]));
|
|
43
|
+
for (const item of discovered)
|
|
44
|
+
merged.set(managedOverviewKey(item), item);
|
|
45
|
+
return [...merged.values()];
|
|
46
|
+
}
|
|
38
47
|
const PR_REFRESH_COALESCE_MS = 1000;
|
|
39
48
|
export const SCHEDULE_HEARTBEAT_MS = 600_000;
|
|
40
49
|
export function dueScheduledOverviews(overviews, now = new Date()) {
|
|
@@ -120,6 +129,7 @@ export async function runDaemon(argv) {
|
|
|
120
129
|
return;
|
|
121
130
|
}
|
|
122
131
|
configureExecutionRouterRulesPath(argv);
|
|
132
|
+
await ensureChatGptBrowserWorkspace();
|
|
123
133
|
const normalized = normalizeDaemonArguments(argv);
|
|
124
134
|
if (normalized.pullRequest && normalized.jobId) {
|
|
125
135
|
await startService([...argv]);
|
|
@@ -183,9 +193,8 @@ export async function runDaemon(argv) {
|
|
|
183
193
|
try {
|
|
184
194
|
githubReadPaths.workspaceScans += 1;
|
|
185
195
|
const refreshed = await withGitHubRequestContext(client, 'relayd.workspace-refresh', () => aggregateManagedGitHubJobs(client, scanRepositories, trusted));
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
cachedOverviews = await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber));
|
|
196
|
+
const merged = mergeOperationalInventory(cachedOverviews, refreshed);
|
|
197
|
+
cachedOverviews = dashboardManagedJobs(await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber)));
|
|
189
198
|
return cachedOverviews;
|
|
190
199
|
}
|
|
191
200
|
catch (error) {
|
|
@@ -382,7 +391,7 @@ export async function runDaemon(argv) {
|
|
|
382
391
|
const runtime = summarizeRuntime(cachedOverviews.map(item => item.job));
|
|
383
392
|
const workers = workerCandidates([
|
|
384
393
|
{ name: 'codex', id: 'codex', capabilities: ['model', 'codex'] },
|
|
385
|
-
{ name: 'chatgpt', id: 'chatgpt/browser-worker', capabilities: ['model', 'chatgpt', 'browser-
|
|
394
|
+
{ name: 'chatgpt', id: 'chatgpt/browser-worker', capabilities: ['model', 'chatgpt', 'browser-workspace'] },
|
|
386
395
|
], [], usage);
|
|
387
396
|
const result = await routeExecution(workers, task.workerRequirements ?? { capabilities: task.capabilities ?? [] }, {
|
|
388
397
|
decisionEngine: new LayaCliDecisionEngine(),
|
package/dist/router-evals.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { executionTaskShape } from './execution-routing.js';
|
|
2
2
|
import { loadExecutionRouterRules } from './routing-policy.js';
|
|
3
|
+
import { appendRoutingInstruction } from './router-rules.js';
|
|
3
4
|
export const ROUTER_EVAL_CASES = [
|
|
4
5
|
{ id: 'small-review', storySize: 'S', task: 'Review a focused PR and explain any correctness issue.', chatgptAvailable: true, expectedWorker: 'chatgpt' },
|
|
5
6
|
{ id: 'medium-investigation', storySize: 'M', task: 'Investigate why a Relay job is blocked and propose a focused fix.', chatgptAvailable: true, expectedWorker: 'chatgpt' },
|
|
@@ -62,14 +63,12 @@ export async function runRouterEvals(engine, rulesPath) {
|
|
|
62
63
|
},
|
|
63
64
|
reviewer: null,
|
|
64
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: '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
68
|
}));
|
|
70
69
|
const decision = await engine.decide({ facts }, {
|
|
71
70
|
type: 'choice',
|
|
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,
|
|
71
|
+
question: appendRoutingInstruction('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, and runtime load.', rules.parsed.routingInstruction),
|
|
73
72
|
choices,
|
|
74
73
|
});
|
|
75
74
|
if (decision.type !== 'choice')
|
package/dist/router-rules.js
CHANGED
|
@@ -27,7 +27,7 @@ function sectionOf(line) {
|
|
|
27
27
|
return match ? match[1].toLowerCase() : null;
|
|
28
28
|
}
|
|
29
29
|
function choiceKey(choice) {
|
|
30
|
-
return [choice.provider, choice.model, choice.reasoning ?? ''].join('\u0000');
|
|
30
|
+
return [choice.worker ?? '', choice.provider, choice.model, choice.reasoning ?? ''].join('\u0000');
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* Return the raw body of the first section named `name`, exactly as authored.
|
|
@@ -61,6 +61,11 @@ function rawSectionBody(text, name) {
|
|
|
61
61
|
* `[choices]` entries are deduplicated deterministically (first occurrence
|
|
62
62
|
* wins). The `[routing-instruction]` body is captured verbatim.
|
|
63
63
|
*/
|
|
64
|
+
export function appendRoutingInstruction(question, instruction) {
|
|
65
|
+
if (!instruction)
|
|
66
|
+
return question;
|
|
67
|
+
return question + '\n\n' + instruction;
|
|
68
|
+
}
|
|
64
69
|
export function parseRouterRules(text) {
|
|
65
70
|
const choices = [];
|
|
66
71
|
const seen = new Set();
|
|
@@ -77,6 +82,21 @@ export function parseRouterRules(text) {
|
|
|
77
82
|
continue;
|
|
78
83
|
if (!rawLine.trim() || rawLine.trim().startsWith('#'))
|
|
79
84
|
continue;
|
|
85
|
+
const route = rawLine.trim().match(/^([A-Za-z0-9._/-]+)::([A-Za-z0-9._-]+):([A-Za-z0-9._/-]+)(?:@([A-Za-z0-9._-]+))?$/);
|
|
86
|
+
if (route) {
|
|
87
|
+
const choice = {
|
|
88
|
+
worker: route[1],
|
|
89
|
+
provider: route[2],
|
|
90
|
+
model: route[3],
|
|
91
|
+
...(route[4] ? { reasoning: route[4].toLowerCase() } : {}),
|
|
92
|
+
};
|
|
93
|
+
const key = choiceKey(choice);
|
|
94
|
+
if (!seen.has(key)) {
|
|
95
|
+
seen.add(key);
|
|
96
|
+
choices.push(choice);
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
80
100
|
const match = rawLine.match(/^\s*([A-Za-z0-9._-]+)\s*:\s*(.+)$/);
|
|
81
101
|
if (!match)
|
|
82
102
|
continue;
|
package/dist/store.js
CHANGED
|
@@ -70,7 +70,7 @@ export async function loadManagedGitHubJobGraphql(client, repository, prNumber,
|
|
|
70
70
|
return (await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors)).find(item => item.job.id === jobId) ?? null;
|
|
71
71
|
}
|
|
72
72
|
const COMPLETED_JOB_HISTORY_LIMIT = 10;
|
|
73
|
-
function isCompletedManagedJob(item) { return ['COMPLETED', 'CANCELLED'].includes(item.job.state)
|
|
73
|
+
function isCompletedManagedJob(item) { return ['COMPLETED', 'CANCELLED'].includes(item.job.state); }
|
|
74
74
|
function completedAt(item) {
|
|
75
75
|
return [item.job.updatedAt, item.mergedAt, item.closedAt].filter((value) => typeof value === 'string' && value.length > 0).sort().at(-1) ?? '';
|
|
76
76
|
}
|
package/dist/worker-routing.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { chooseWithDecision } from './decision.js';
|
|
2
2
|
import { loadExecutionRouterRules } from './routing-policy.js';
|
|
3
|
+
import { appendRoutingInstruction } from './router-rules.js';
|
|
3
4
|
export const DEFAULT_CHATGPT_PREFERRED_CONCURRENCY = 10;
|
|
4
5
|
export const DEFAULT_WORKER_ROUTER_PRINCIPLES = [
|
|
5
|
-
'User-provided
|
|
6
|
+
'User-provided routing instructions are higher-priority guidance than these built-in defaults. Follow that guidance when it conflicts with a default unless doing so would violate an authoritative hard constraint.',
|
|
6
7
|
'Worker Router chooses only the execution worker. Model Router chooses provider/model/profile after worker selection.',
|
|
7
8
|
'Prefer ChatGPT when both workers can execute the task because it does not consume Codex coding-plan tokens and normally has access to a stronger ChatGPT Sol model.',
|
|
8
9
|
'Prefer Codex when the task requires native/local execution or computer-use abilities that are unavailable through ChatGPT plus MacBridge.',
|
|
@@ -141,8 +142,7 @@ export async function routeWorker(candidates, requirements, options = {}) {
|
|
|
141
142
|
const facts = JSON.parse(JSON.stringify({
|
|
142
143
|
task: options.taskFacts ?? {},
|
|
143
144
|
explicit_constraints: { runtime: requirements.runtime ?? null },
|
|
144
|
-
decision_precedence: ['authoritative_hard_constraints', '
|
|
145
|
-
user_input_rules: rules.text,
|
|
145
|
+
decision_precedence: ['authoritative_hard_constraints', 'routing_instruction', 'routing_principles', 'resource_and_runtime_preferences'],
|
|
146
146
|
routing_principles: DEFAULT_WORKER_ROUTER_PRINCIPLES,
|
|
147
147
|
usage_snapshot: options.usage ?? null,
|
|
148
148
|
runtime_load: {
|
|
@@ -167,7 +167,7 @@ export async function routeWorker(candidates, requirements, options = {}) {
|
|
|
167
167
|
}));
|
|
168
168
|
const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, {
|
|
169
169
|
type: 'choice',
|
|
170
|
-
question: 'Choose the best execution worker for this task. Authoritative hard constraints come first. Then follow
|
|
170
|
+
question: appendRoutingInstruction('Choose the best execution worker for this task. Authoritative hard constraints come first. Then follow the user routing instruction ahead of built-in routing principles when they conflict. Use task facts, reviewer-diversity context when present, current usage, and runtime load as supporting evidence. Do not choose a provider or model here.', rules.parsed.routingInstruction),
|
|
171
171
|
choices: eligible.map(item => item.workerId),
|
|
172
172
|
}, () => deterministic.workerId);
|
|
173
173
|
const selected = eligible.find(item => item.workerId === choice.choice) ?? deterministic;
|