agents-relay 2.0.34 → 2.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -165,9 +165,12 @@ function parseDriverJson(output) {
165
165
  }
166
166
  throw new Error('browser-worker driver returned no submitted receipt');
167
167
  }
168
- function browserWorkspaceCommand(command, args, stdin) {
168
+ function browserWorkspaceCli(override = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND) {
169
+ return override ? { command: override, prefix: [] } : { command: 'npx', prefix: ['browser-workspace'] };
170
+ }
171
+ function browserWorkspaceCommand(cli, args, stdin) {
169
172
  return new Promise((resolve, reject) => {
170
- const child = spawn(command, [...args], { shell: false, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
173
+ const child = spawn(cli.command, [...cli.prefix, ...args], { shell: false, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
171
174
  let stdout = '';
172
175
  let stderr = '';
173
176
  child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
@@ -194,11 +197,11 @@ function parseWorkspaceJson(output) {
194
197
  }
195
198
  throw new Error('browser-workspace returned no JSON result');
196
199
  }
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)]);
200
+ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND) {
201
+ await browserWorkspaceCommand(browserWorkspaceCli(command), ['create', CHATGPT_BROWSER_WORKSPACE, String(CHATGPT_BROWSER_WORKSPACE_POOL_SIZE)]);
199
202
  }
200
203
  function sessionBrowserWorkerRunner(root) {
201
- const command = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND ?? 'browser-workspace';
204
+ const cli = browserWorkspaceCli();
202
205
  return (_definition, request, _route, signal) => {
203
206
  const policy = request.tabClosePolicy ?? 'after-start';
204
207
  let activeSessionId;
@@ -231,7 +234,7 @@ function sessionBrowserWorkerRunner(root) {
231
234
  close_policy: policy,
232
235
  release_file: '',
233
236
  }));
234
- const startOutput = await browserWorkspaceCommand(command, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE]);
237
+ const startOutput = await browserWorkspaceCommand(cli, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE]);
235
238
  const started = parseWorkspaceJson(startOutput);
236
239
  if (typeof started.session_id !== 'string') {
237
240
  throw new Error('browser-workspace session start returned no session_id');
@@ -245,7 +248,7 @@ function sessionBrowserWorkerRunner(root) {
245
248
  submitSource.replace(/^from browser_harness import \*\s*$/m, ''),
246
249
  temporarySource.replace(/^_wait_for_release\(\)\s*$/m, ''),
247
250
  ].join('\n\n').replaceAll('__CFG_PATH__', escapedConfig);
248
- const execOutput = await browserWorkspaceCommand(command, ['session', 'exec', activeSessionId], source);
251
+ const execOutput = await browserWorkspaceCommand(cli, ['session', 'exec', activeSessionId], source);
249
252
  const wrapper = parseWorkspaceJson(execOutput);
250
253
  if (wrapper.ok !== true) {
251
254
  throw new Error(typeof wrapper.error === 'string' ? wrapper.error : 'browser-workspace session exec failed');
@@ -260,14 +263,14 @@ function sessionBrowserWorkerRunner(root) {
260
263
  }
261
264
  await releaseBarrier;
262
265
  if (activeSessionId) {
263
- await browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]);
266
+ await browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]);
264
267
  activeSessionId = undefined;
265
268
  }
266
269
  return result;
267
270
  }
268
271
  catch (failure) {
269
272
  if (activeSessionId) {
270
- await browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]).catch(() => { });
273
+ await browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]).catch(() => { });
271
274
  activeSessionId = undefined;
272
275
  }
273
276
  await unlink(configFile).catch(() => { });
@@ -279,7 +282,7 @@ function sessionBrowserWorkerRunner(root) {
279
282
  const cancel = () => {
280
283
  release();
281
284
  if (activeSessionId) {
282
- void browserWorkspaceCommand(command, ['session', 'stop', activeSessionId]);
285
+ void browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]);
283
286
  activeSessionId = undefined;
284
287
  }
285
288
  };
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-auto-merge, --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 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: options.autoMerge ?? true,
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 explicit = resolveExplicitChoices(rules.parsed.choices, discovered);
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. Follow [routing_instruction] verbatim; do not invent, add, or substitute routes that are not listed.';
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 = [];
@@ -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 current user input rules and structured policy; consider every quota window and never violate task fit or continuity.', choices: eligible.map(modelChoiceId) }, () => modelChoiceId(deterministic));
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,
@@ -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, and routing_instruction.',
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')
@@ -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;
@@ -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 user_input_rules are higher-priority routing guidance than these built-in defaults. Follow user guidance when it conflicts with a default unless doing so would violate an authoritative hard constraint.',
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', 'user_input_rules', 'routing_principles', 'resource_and_runtime_preferences'],
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 user_input_rules 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.',
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.34",
3
+ "version": "2.0.36",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {