agents-relay 1.0.3 → 1.0.5
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 +9 -1
- package/dist/adapters.js +80 -31
- package/dist/cli.js +89 -2
- package/dist/reconciler.js +6 -4
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +2 -0
- package/skills/chatgpt-browser-worker/SKILL.md +102 -0
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +113 -0
- package/skills/chatgpt-browser-worker/references/contract.md +132 -0
- package/skills/chatgpt-browser-worker/references/orchestration.md +101 -0
- package/skills/chatgpt-browser-worker/scripts/_create_bh.py +158 -0
- package/skills/chatgpt-browser-worker/scripts/_operate_bh.py +198 -0
- package/skills/chatgpt-browser-worker/scripts/contract.py +151 -0
- package/skills/chatgpt-browser-worker/scripts/create.py +84 -0
- package/skills/chatgpt-browser-worker/scripts/create_bh.py +34 -0
- package/skills/chatgpt-browser-worker/scripts/operate_bh.py +36 -0
- package/skills/chatgpt-browser-worker/scripts/operations.py +179 -0
- package/skills/chatgpt-browser-worker/tests/fixtures/relay_lifecycle.json +21 -0
- package/skills/chatgpt-browser-worker/tests/test_agent_definition.py +27 -0
- package/skills/chatgpt-browser-worker/tests/test_contract.py +315 -0
package/README.md
CHANGED
|
@@ -17,6 +17,14 @@ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
|
|
|
17
17
|
npx agents-relay status --repo OWNER/REPO --pr 12 --id job-1
|
|
18
18
|
~~~
|
|
19
19
|
|
|
20
|
+
Mutable queued/retryable task fields can be changed in place without replacing the durable task identity. `task update` rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` tasks so active execution and terminal history cannot be rewritten. For example:
|
|
21
|
+
|
|
22
|
+
~~~sh
|
|
23
|
+
npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child \
|
|
24
|
+
--adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high \
|
|
25
|
+
--capabilities model,chatgpt,browser-harness
|
|
26
|
+
~~~
|
|
27
|
+
|
|
20
28
|
Jobs default to `fixed`, preserving the original submit-and-run behavior. Use
|
|
21
29
|
`--mode autonomous` with `init` or `job create` to make reconciliation own
|
|
22
30
|
planner progress. The durable status JSON and dashboard show the mode. An
|
|
@@ -95,7 +103,7 @@ npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
|
|
|
95
103
|
--input "Research the dashboard UX and return implementation guidance."
|
|
96
104
|
~~~
|
|
97
105
|
|
|
98
|
-
The adapter discovers `chatgpt-browser-worker
|
|
106
|
+
The adapter discovers the packaged `chatgpt-browser-worker` skill from `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` first, then `AGENTS_RELAY_SKILL_ROOTS`, the package `skills` directory, `~/.codex/skills`, `~/.agents/skills`, and the local `skills` directory. It invokes that skill's Browser Harness drivers directly; it does not launch Codex, MacDeveloperBridge, or a ChatGPT API transport. Normal consumers do not need a Neo source checkout.
|
|
99
107
|
|
|
100
108
|
GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows repository-wide managed PR state beside each durable Agents Relay job state so stale markers are visible instead of being mistaken for current truth.
|
|
101
109
|
|
package/dist/adapters.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { access, readFile, readdir } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { randomUUID } from 'node:crypto';
|
|
6
7
|
function commandExecution(child, output, signal, id) { const promise = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => code === 0 ? resolve({ summary: output().trim() || 'Command completed' }) : reject(new Error(output().trim() || `Command exited ${code}`))); signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true }); }); return { id, promise, cancel: () => { child.kill('SIGTERM'); } }; }
|
|
7
8
|
export function buildCodexArgs(route, input) {
|
|
@@ -151,17 +152,20 @@ export class CodexAdapter {
|
|
|
151
152
|
return null;
|
|
152
153
|
}
|
|
153
154
|
}
|
|
155
|
+
export class LaunchFailure extends Error {
|
|
156
|
+
}
|
|
154
157
|
function browserWorkerCandidates(explicit) {
|
|
155
158
|
const roots = (process.env.AGENTS_RELAY_SKILL_ROOTS ?? '').split(':').filter(Boolean);
|
|
156
|
-
|
|
159
|
+
const packageSkills = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
160
|
+
return [explicit, ...roots, packageSkills, join(homedir(), '.codex', 'skills'), join(homedir(), '.agents', 'skills'), join(process.cwd(), 'skills')]
|
|
157
161
|
.filter((value) => Boolean(value))
|
|
158
162
|
.map(value => value.endsWith('browser-worker.agent.md') ? value : join(value, 'chatgpt-browser-worker', 'agents', 'browser-worker.agent.md'));
|
|
159
163
|
}
|
|
160
|
-
async function
|
|
164
|
+
async function loadBrowserWorkerRuntime(explicit) {
|
|
161
165
|
for (const candidate of browserWorkerCandidates(explicit)) {
|
|
162
166
|
try {
|
|
163
167
|
await access(candidate);
|
|
164
|
-
return readFile(candidate, 'utf8');
|
|
168
|
+
return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
|
|
165
169
|
}
|
|
166
170
|
catch { /* try the next installed skill root */ }
|
|
167
171
|
}
|
|
@@ -190,19 +194,71 @@ function parseBrowserWorkerResponse(output) {
|
|
|
190
194
|
throw new Error('browser-worker response omitted operation or status');
|
|
191
195
|
return response;
|
|
192
196
|
}
|
|
193
|
-
function
|
|
194
|
-
|
|
197
|
+
function parseDriverJson(output) {
|
|
198
|
+
const lines = output.trim().split(/\r?\n/).filter(Boolean);
|
|
199
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
200
|
+
try {
|
|
201
|
+
const value = JSON.parse(lines[index]);
|
|
202
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
catch { /* browser-harness diagnostics may precede the final JSON observation */ }
|
|
206
|
+
}
|
|
207
|
+
throw new Error('browser-worker driver returned no JSON observation');
|
|
195
208
|
}
|
|
196
|
-
function
|
|
197
|
-
return (
|
|
198
|
-
const
|
|
209
|
+
function directBrowserWorkerRunner(root) {
|
|
210
|
+
return (_definition, request, _route, signal) => {
|
|
211
|
+
const scripts = join(root, 'scripts');
|
|
212
|
+
const project = request.project.name;
|
|
213
|
+
const args = request.operation === 'create'
|
|
214
|
+
? [join(scripts, 'create_bh.py'), '--project', project, '--prompt', request.prompt ?? '', '--thinking-level', request.thinking_level ?? 'default']
|
|
215
|
+
: [join(scripts, 'operate_bh.py'), request.operation, '--thread-id', request.thread_id ?? '', '--project', project,
|
|
216
|
+
...(request.operation === 'continue' ? ['--prompt', request.prompt ?? ''] : [])];
|
|
217
|
+
const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
199
218
|
let stdout = '';
|
|
200
219
|
let stderr = '';
|
|
201
220
|
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
202
221
|
child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
|
|
203
222
|
const promise = new Promise((resolve, reject) => {
|
|
204
223
|
child.on('error', reject);
|
|
205
|
-
child.on('close', code =>
|
|
224
|
+
child.on('close', code => {
|
|
225
|
+
if (code !== 0) {
|
|
226
|
+
reject(new Error(stderr.trim() || stdout.trim() || `browser-worker driver exited ${code}`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const observed = parseDriverJson(stdout);
|
|
231
|
+
const threadId = typeof observed.thread_id === 'string' ? observed.thread_id : request.thread_id;
|
|
232
|
+
const response = { operation: request.operation, status: 'running', thread_id: threadId ?? null };
|
|
233
|
+
if (request.operation === 'create')
|
|
234
|
+
response.status = 'running';
|
|
235
|
+
else if (request.operation === 'continue')
|
|
236
|
+
response.status = 'running';
|
|
237
|
+
else if (request.operation === 'resume')
|
|
238
|
+
response.status = typeof observed.status === 'string' ? observed.status : 'awaiting_result';
|
|
239
|
+
else if (request.operation === 'status')
|
|
240
|
+
response.status = typeof observed.status === 'string' ? observed.status : 'awaiting_result';
|
|
241
|
+
else if (request.operation === 'result') {
|
|
242
|
+
response.status = typeof observed.status === 'string' ? observed.status : 'completed';
|
|
243
|
+
response.result = {
|
|
244
|
+
message_id: typeof observed.message_id === 'string' ? observed.message_id : undefined,
|
|
245
|
+
text: typeof observed.text === 'string' ? observed.text : undefined,
|
|
246
|
+
verified: Boolean(observed.message_id && observed.text),
|
|
247
|
+
observed_at: new Date().toISOString()
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
else if (request.operation === 'delete') {
|
|
251
|
+
const outcome = typeof observed.outcome === 'string' ? observed.outcome : '';
|
|
252
|
+
response.status = outcome === 'deleted' || outcome === 'not_found' ? outcome : 'failed';
|
|
253
|
+
if (response.status === 'failed')
|
|
254
|
+
response.error = { code: 'cleanup_not_verified', message: 'browser-worker did not verify thread cleanup', retryable: true };
|
|
255
|
+
}
|
|
256
|
+
resolve(JSON.stringify(response));
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
reject(error);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
206
262
|
if (signal.aborted)
|
|
207
263
|
child.kill('SIGTERM');
|
|
208
264
|
else
|
|
@@ -211,22 +267,6 @@ function defaultChatGptAgentRunner(harness) {
|
|
|
211
267
|
return { promise, cancel: () => { child.kill('SIGTERM'); } };
|
|
212
268
|
};
|
|
213
269
|
}
|
|
214
|
-
function parseCodexAgentMessage(output) {
|
|
215
|
-
let final;
|
|
216
|
-
for (const line of output.split(/\r?\n/)) {
|
|
217
|
-
try {
|
|
218
|
-
const value = JSON.parse(line);
|
|
219
|
-
const payload = value.payload;
|
|
220
|
-
const item = value.item;
|
|
221
|
-
if (value.type === 'event_msg' && payload?.type === 'task_complete' && typeof payload.last_agent_message === 'string')
|
|
222
|
-
final = payload.last_agent_message;
|
|
223
|
-
if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
|
|
224
|
-
final = item.text;
|
|
225
|
-
}
|
|
226
|
-
catch { /* harness diagnostics are not the agent response */ }
|
|
227
|
-
}
|
|
228
|
-
return final;
|
|
229
|
-
}
|
|
230
270
|
function browserWorkerError(response) {
|
|
231
271
|
const detail = response.error?.message || `browser-worker ${response.status}`;
|
|
232
272
|
return new Error(response.error?.code ? `${response.error.code}: ${detail}` : detail);
|
|
@@ -236,16 +276,15 @@ export class ChatGptAdapter {
|
|
|
236
276
|
id = 'chatgpt/browser-worker';
|
|
237
277
|
capabilities = ['model', 'chatgpt', 'browser-harness'];
|
|
238
278
|
agentPath;
|
|
239
|
-
harness;
|
|
240
279
|
runner;
|
|
241
280
|
deleted = new Set();
|
|
242
281
|
routes = new Map();
|
|
243
|
-
constructor(options = {}) { this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT; this.
|
|
282
|
+
constructor(options = {}) { this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT; this.runner = options.agentRunner; }
|
|
244
283
|
async run(request, route, signal) {
|
|
245
|
-
const
|
|
284
|
+
const runtime = await loadBrowserWorkerRuntime(this.agentPath);
|
|
246
285
|
if (signal.aborted)
|
|
247
286
|
throw new Error('browser-worker execution aborted');
|
|
248
|
-
const run = (this.runner ??
|
|
287
|
+
const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, request, route, signal);
|
|
249
288
|
const output = await new Promise((resolve, reject) => {
|
|
250
289
|
const abort = () => { run.cancel(); reject(new Error('browser-worker execution aborted')); };
|
|
251
290
|
if (signal.aborted) {
|
|
@@ -312,7 +351,17 @@ export class ChatGptAdapter {
|
|
|
312
351
|
operation, thread_id: expectedThreadId, project: this.project(task, route), prompt: hasPrompt ? task.input : undefined,
|
|
313
352
|
thinking_level: thinkingLevel(route.reasoning), state: null,
|
|
314
353
|
};
|
|
315
|
-
|
|
354
|
+
let response;
|
|
355
|
+
try {
|
|
356
|
+
response = await this.run(request, route, controller.signal);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (controller.signal.aborted)
|
|
360
|
+
throw error;
|
|
361
|
+
if (operation === 'create')
|
|
362
|
+
throw new LaunchFailure(error instanceof Error ? error.message : String(error));
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
316
365
|
const threadId = this.exposeThread(execution, expectedThreadId, response);
|
|
317
366
|
this.routes.set(threadId, route);
|
|
318
367
|
this.validateOperation(response, operation);
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { codexAndZaiUsageRegistry } from './usage.js';
|
|
|
15
15
|
import { discoverAgents } from './registry.js';
|
|
16
16
|
import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
|
|
17
17
|
import { extendRetryBudget } from './scheduler.js';
|
|
18
|
-
const usage = 'agents-relay <job|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
|
|
18
|
+
const usage = 'agents-relay <job|task|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
|
|
19
19
|
const help = {
|
|
20
20
|
root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
|
|
21
21
|
|
|
@@ -24,6 +24,7 @@ Usage:
|
|
|
24
24
|
|
|
25
25
|
Commands:
|
|
26
26
|
job create|adopt|repair Create or repair a durable GitHub-backed job
|
|
27
|
+
task update Update mutable durable task fields in place
|
|
27
28
|
init Create a local demo job or initialize a GitHub job
|
|
28
29
|
submit Queue a task for a managed job
|
|
29
30
|
record Backfill a completed task into durable history
|
|
@@ -76,6 +77,21 @@ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
|
|
|
76
77
|
|
|
77
78
|
Example:
|
|
78
79
|
npx agents-relay job repair --repo OWNER/REPO --pr 12 --id job-12`,
|
|
80
|
+
task: `Manage a durable task.
|
|
81
|
+
|
|
82
|
+
Usage:
|
|
83
|
+
npx agents-relay task update [options]
|
|
84
|
+
|
|
85
|
+
Actions:
|
|
86
|
+
update Update mutable task fields without changing task identity/history`,
|
|
87
|
+
'task update': `Update mutable durable task fields in place.
|
|
88
|
+
|
|
89
|
+
Required: --task-id ID and the normal job storage options.
|
|
90
|
+
Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project.
|
|
91
|
+
Use --clear-routing to remove routing metadata. A model update requires --model; provider defaults to the existing provider or openai.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id build --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high`,
|
|
79
95
|
submit: `Queue a task for a managed job.
|
|
80
96
|
|
|
81
97
|
Required: --repo OWNER/REPO, --pr NUMBER, --id JOB_ID, and --input TEXT.
|
|
@@ -145,6 +161,8 @@ Example:
|
|
|
145
161
|
export function helpText(command, action) {
|
|
146
162
|
if (command === 'job')
|
|
147
163
|
return action ? help[`job ${action}`] ?? help.job : help.job;
|
|
164
|
+
if (command === 'task')
|
|
165
|
+
return action ? help[`task ${action}`] ?? help.task : help.task;
|
|
148
166
|
return command ? help[command] ?? help.root : help.root;
|
|
149
167
|
}
|
|
150
168
|
export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 1800000, dashboardRefreshMs: 300000 };
|
|
@@ -172,6 +190,10 @@ class ServiceEventBus {
|
|
|
172
190
|
}
|
|
173
191
|
}
|
|
174
192
|
function arg(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
|
|
193
|
+
function hasArg(args, name) { return args.includes(name); }
|
|
194
|
+
function integerArg(args, name, fallback) { if (!hasArg(args, name))
|
|
195
|
+
return fallback; const value = Number(arg(args, name)); if (!Number.isInteger(value) || value < 0)
|
|
196
|
+
throw new Error(`${name} must be a non-negative integer`); return value; }
|
|
175
197
|
function repository(args) { const value = arg(args, '--repo'); if (!value)
|
|
176
198
|
throw new Error('--repo is required for GitHub-backed operation'); return value; }
|
|
177
199
|
function pullRequest(args) { const value = Number(arg(args, '--pr')); if (!Number.isInteger(value) || value <= 0)
|
|
@@ -233,6 +255,66 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
233
255
|
await store.saveJob(job);
|
|
234
256
|
return { job: await store.load(options.id), pr };
|
|
235
257
|
}
|
|
258
|
+
async function runTaskCommand(action, args) {
|
|
259
|
+
if (action !== 'update')
|
|
260
|
+
throw new Error(usage);
|
|
261
|
+
const { store, job, localFile } = await storeFor(args);
|
|
262
|
+
const id = arg(args, '--task-id');
|
|
263
|
+
if (!id)
|
|
264
|
+
throw new Error('task update requires --task-id');
|
|
265
|
+
const task = job.tasks.find(item => item.id === id);
|
|
266
|
+
if (!task)
|
|
267
|
+
throw new Error(`Task ${id} not found`);
|
|
268
|
+
if (['RUNNING', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
|
|
269
|
+
throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
|
|
270
|
+
if (hasArg(args, '--adapter')) {
|
|
271
|
+
const adapter = arg(args, '--adapter');
|
|
272
|
+
if (!['shell', 'codex', 'chatgpt', 'orchestrator'].includes(adapter))
|
|
273
|
+
throw new Error('--adapter must be shell, codex, chatgpt, or orchestrator');
|
|
274
|
+
task.adapter = adapter;
|
|
275
|
+
}
|
|
276
|
+
if (hasArg(args, '--priority'))
|
|
277
|
+
task.priority = priority(args, task.priority ?? job.priority ?? 'P2');
|
|
278
|
+
if (hasArg(args, '--input'))
|
|
279
|
+
task.input = arg(args, '--input');
|
|
280
|
+
if (hasArg(args, '--project'))
|
|
281
|
+
task.projectName = arg(args, '--project') || undefined;
|
|
282
|
+
if (hasArg(args, '--agent'))
|
|
283
|
+
task.agentName = arg(args, '--agent') || undefined;
|
|
284
|
+
if (hasArg(args, '--parent'))
|
|
285
|
+
task.parentTaskId = arg(args, '--parent') || null;
|
|
286
|
+
if (hasArg(args, '--deps'))
|
|
287
|
+
task.dependencies = arg(args, '--deps').split(',').filter(Boolean);
|
|
288
|
+
if (hasArg(args, '--capabilities'))
|
|
289
|
+
task.capabilities = arg(args, '--capabilities').split(',').filter(Boolean);
|
|
290
|
+
task.timeoutMs = integerArg(args, '--timeout', task.timeoutMs);
|
|
291
|
+
task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
|
|
292
|
+
if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
|
|
293
|
+
throw new Error('--max-attempts cannot be less than the current attempt count');
|
|
294
|
+
const routingFlags = ['--provider', '--model', '--profile', '--reasoning', '--cwd', '--chatgpt-project'];
|
|
295
|
+
if (args.includes('--clear-routing'))
|
|
296
|
+
task.routing = undefined;
|
|
297
|
+
else if (routingFlags.some(flag => hasArg(args, flag))) {
|
|
298
|
+
const current = task.routing;
|
|
299
|
+
const model = hasArg(args, '--model') ? arg(args, '--model') : current?.model;
|
|
300
|
+
if (!model)
|
|
301
|
+
throw new Error('task routing update requires --model when no existing routing is present');
|
|
302
|
+
task.routing = {
|
|
303
|
+
provider: hasArg(args, '--provider') ? arg(args, '--provider') : current?.provider ?? 'openai',
|
|
304
|
+
model,
|
|
305
|
+
profile: hasArg(args, '--profile') ? arg(args, '--profile') || undefined : current?.profile,
|
|
306
|
+
reasoning: hasArg(args, '--reasoning') ? arg(args, '--reasoning') || undefined : current?.reasoning,
|
|
307
|
+
cwd: hasArg(args, '--cwd') ? arg(args, '--cwd') || undefined : current?.cwd,
|
|
308
|
+
projectId: hasArg(args, '--chatgpt-project') ? arg(args, '--chatgpt-project') || undefined : current?.projectId,
|
|
309
|
+
decidedBy: 'cli:update',
|
|
310
|
+
decidedAt: new Date().toISOString()
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
task.updatedAt = new Date().toISOString();
|
|
314
|
+
await store.saveTask(task);
|
|
315
|
+
await saveLocal(localFile, job);
|
|
316
|
+
console.log(JSON.stringify(task, null, 2));
|
|
317
|
+
}
|
|
236
318
|
async function runJobCommand(action, args) {
|
|
237
319
|
if (!['create', 'adopt', 'repair'].includes(action))
|
|
238
320
|
throw new Error(usage);
|
|
@@ -342,7 +424,7 @@ async function main() {
|
|
|
342
424
|
return;
|
|
343
425
|
}
|
|
344
426
|
if (args.includes('--help') || args.includes('-h')) {
|
|
345
|
-
console.log(helpText(command, command === 'job' ? args[0] : undefined));
|
|
427
|
+
console.log(helpText(command, command === 'job' || command === 'task' ? args[0] : undefined));
|
|
346
428
|
return;
|
|
347
429
|
}
|
|
348
430
|
if (command === 'job') {
|
|
@@ -350,6 +432,11 @@ async function main() {
|
|
|
350
432
|
await runJobCommand(action ?? '', jobArgs);
|
|
351
433
|
return;
|
|
352
434
|
}
|
|
435
|
+
if (command === 'task') {
|
|
436
|
+
const [action, ...taskArgs] = args;
|
|
437
|
+
await runTaskCommand(action ?? '', taskArgs);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
353
440
|
if (command === 'init') {
|
|
354
441
|
const now = new Date().toISOString();
|
|
355
442
|
const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: 'OPEN', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
|
package/dist/reconciler.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { schedule, transitionTask } from './scheduler.js';
|
|
3
|
+
import { LaunchFailure } from './adapters.js';
|
|
3
4
|
import { eventFor } from './events.js';
|
|
4
5
|
import { parsePlannerResult, plannerInput } from './planner.js';
|
|
5
6
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
@@ -206,7 +207,7 @@ export class Reconciler {
|
|
|
206
207
|
execution = adapter.launch(workerTask, controller.signal);
|
|
207
208
|
}
|
|
208
209
|
catch (error) {
|
|
209
|
-
await this.finish(job.id, task.id, task.executionId, null, error instanceof Error ? error.message : String(error));
|
|
210
|
+
await this.finish(job.id, task.id, task.executionId, null, error instanceof Error ? error.message : String(error), { retryable: false });
|
|
210
211
|
return;
|
|
211
212
|
}
|
|
212
213
|
// A worker may reject before durable execution metadata finishes saving. Attach a guard immediately so Node never treats that race as an unhandled rejection; the durable completion handler below still records the outcome.
|
|
@@ -222,7 +223,7 @@ export class Reconciler {
|
|
|
222
223
|
persistThread(execution.threadId);
|
|
223
224
|
const timer = setTimeout(() => { controller.abort(); execution.cancel(); }, task.timeoutMs);
|
|
224
225
|
this.live.set(task.id, { taskId: task.id, execution, controller, timer });
|
|
225
|
-
this.detach(execution.promise.then(async (result) => { await threadPersistence; await this.finish(job.id, task.id, execution.id, result, null); }, async (error) => { await threadPersistence; await this.finish(job.id, task.id, execution.id, null, error instanceof Error ? error.message : String(error)); }), job.id, task.id, 'worker completion');
|
|
226
|
+
this.detach(execution.promise.then(async (result) => { await threadPersistence; await this.finish(job.id, task.id, execution.id, result, null); }, async (error) => { await threadPersistence; await this.finish(job.id, task.id, execution.id, null, error instanceof Error ? error.message : String(error), { retryable: !(error instanceof LaunchFailure) }); }), job.id, task.id, 'worker completion');
|
|
226
227
|
}
|
|
227
228
|
async launchPlanner(job, task) {
|
|
228
229
|
const planner = this.options.planner;
|
|
@@ -319,7 +320,7 @@ export class Reconciler {
|
|
|
319
320
|
await this.store.saveTask(task);
|
|
320
321
|
await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'thread.started', 'running', `Worker thread ${threadId} started`, 'orchestrator', { threadId }));
|
|
321
322
|
} }
|
|
322
|
-
async finish(jobId, taskId, executionId, result, error) {
|
|
323
|
+
async finish(jobId, taskId, executionId, result, error, options = {}) {
|
|
323
324
|
const live = this.live.get(taskId);
|
|
324
325
|
if (live?.execution.id === executionId) {
|
|
325
326
|
clearTimeout(live.timer);
|
|
@@ -344,10 +345,11 @@ export class Reconciler {
|
|
|
344
345
|
await this.deliverContinuation(job, task);
|
|
345
346
|
}
|
|
346
347
|
else {
|
|
348
|
+
const retryable = options.retryable !== false && task.attempt < task.maxAttempts;
|
|
347
349
|
task.error = error;
|
|
348
350
|
task.leaseOwner = null;
|
|
349
351
|
task.leaseExpiresAt = null;
|
|
350
|
-
transitionTask(task,
|
|
352
|
+
transitionTask(task, retryable ? 'READY' : 'FAILED');
|
|
351
353
|
await this.store.saveTask(task);
|
|
352
354
|
await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'task.failed' : 'task.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Task ${task.id}: ${error}`, task.state === 'FAILED' ? 'user' : 'orchestrator'));
|
|
353
355
|
}
|
package/package.json
CHANGED
|
@@ -62,6 +62,8 @@ Use --file PATH only for explicit local demo/test mode. Optional --events nats e
|
|
|
62
62
|
|
|
63
63
|
Use task-level continuation when the sender needs to resume, otherwise the job continuation. Continuation delivery is deduplicated by the durable delivery timestamp. Treat BLOCKED as an approval/manual-release state until an explicit retry/release changes it. Cancellation, timeout, and lease expiry are durable state transitions; do not claim success from a worker process exit alone.
|
|
64
64
|
|
|
65
|
+
Use `npx agents-relay task update` to change safe durable fields of a `QUEUED`, `READY`, `WAITING`, `BLOCKED`, or `FAILED` task without changing its task ID or prior attempt/error history. This is the preferred way to reroute future execution to a different adapter/provider/model. The CLI rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` task updates so active execution semantics and terminal audit history are not rewritten.
|
|
66
|
+
|
|
65
67
|
For a ChatGPT worker, use the chatgpt adapter with capabilities model,chatgpt,browser-harness. The runtime launches the installed `chatgpt-browser-worker` agent, stores its observed `thread_id` as the durable task threadId, and reuses that ID on retry. Pass --chatgpt-project when the worker must run inside one exact ChatGPT Project. Set `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` to an explicit agent definition path when needed; otherwise standard skill roots are searched. Never place credentials or private prompts in job/task markers.
|
|
66
68
|
|
|
67
69
|
Managed Codex and ChatGPT worker prompts are enriched at launch with the PR URL and durable job/task/parent/project identity; do not duplicate that context manually in task input. Keep one ChatGPT conversation per logical task. Retries reuse the same task conversation, but sibling tasks use separate conversations even when their adapter is the same.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chatgpt-browser-worker
|
|
3
|
+
description: Manage ChatGPT work threads through the authenticated browser-harness session, including Project selection, thinking-level requests, durable thread identity, resume/status/result retrieval, and cleanup.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ChatGPT browser worker
|
|
7
|
+
|
|
8
|
+
This skill is the browser-backed ChatGPT worker contract for Neo. It uses the
|
|
9
|
+
existing `browser-harness` command as its only browser interaction layer. It
|
|
10
|
+
does not use the MacBridge ChatGPT runtime, ChatGPT APIs, copied cookies, or a
|
|
11
|
+
second browser automation stack.
|
|
12
|
+
|
|
13
|
+
## Capability contract
|
|
14
|
+
|
|
15
|
+
The worker boundary exposes these lifecycle operations:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
create(project, prompt, thinking_level) -> ThreadState
|
|
19
|
+
resume(thread_id, project) -> ThreadState
|
|
20
|
+
status(thread_id) -> StatusObservation
|
|
21
|
+
result(thread_id) -> ThreadResult
|
|
22
|
+
delete(thread_id) -> DeletedThreadState
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`project.name` is required when creating or resuming a thread. `project.id` is
|
|
26
|
+
optional because the UI may not expose it at every boundary. The adapter must
|
|
27
|
+
select and verify that Project before sending the first prompt. A resumed
|
|
28
|
+
thread keeps its recorded Project identity; callers cannot silently move it to
|
|
29
|
+
another Project.
|
|
30
|
+
|
|
31
|
+
Thinking levels are split into `requested_thinking_level` and
|
|
32
|
+
`effective_thinking_level`. Requested values are `default`, `low`, `medium`,
|
|
33
|
+
and `high`; effective values may additionally be `unknown`. `default` leaves
|
|
34
|
+
the account's current/default setting unchanged. The adapter must preserve
|
|
35
|
+
`unknown` rather than guessing from a label or silently downgrading a request.
|
|
36
|
+
|
|
37
|
+
## Durable identity and state
|
|
38
|
+
|
|
39
|
+
Persist the returned state after every successful lifecycle transition. The
|
|
40
|
+
durable key is the ChatGPT `thread_id`; a URL, title, or browser tab index is
|
|
41
|
+
not an identity. Records retain a deleted thread as a tombstone so a stale
|
|
42
|
+
caller cannot recreate or accidentally reuse it. The complete JSON schema and
|
|
43
|
+
valid transition table are in [references/contract.md](references/contract.md).
|
|
44
|
+
|
|
45
|
+
Pure validation and transition helpers live in `scripts/contract.py`, with
|
|
46
|
+
focused tests in `tests/test_contract.py`. The browser-harness driver is
|
|
47
|
+
intentionally injected through the semantic port described in the reference;
|
|
48
|
+
the contract layer does not import or launch `browser-harness`.
|
|
49
|
+
Run `python3 -m pytest skills/chatgpt-browser-worker/tests` from the repository
|
|
50
|
+
root.
|
|
51
|
+
|
|
52
|
+
For the Neo/Leo and Agents Relay handoff—task IDs, run-local state, event
|
|
53
|
+
ownership, and the create/resume/result/delete invocation sequence—see
|
|
54
|
+
[references/orchestration.md](references/orchestration.md). The outer
|
|
55
|
+
orchestrator may use MacBridge as a transport, but this worker never treats
|
|
56
|
+
MacBridge as the ChatGPT runtime.
|
|
57
|
+
|
|
58
|
+
The runtime entry point is the
|
|
59
|
+
[browser-worker agent](agents/browser-worker.agent.md). Callers launch that
|
|
60
|
+
agent with a typed lifecycle intent; they must not directly call
|
|
61
|
+
`create_bh.py` or `operate_bh.py`. Those scripts are helper implementations
|
|
62
|
+
that the agent may use, repair, or bypass while preserving this skill's
|
|
63
|
+
browser-harness and verification contract.
|
|
64
|
+
|
|
65
|
+
The browser-worker agent's `create` intent starts a new ChatGPT chat when the
|
|
66
|
+
attached tab is already a conversation and emits JSON with the observed
|
|
67
|
+
`thread_id`, conversation URL, selected Project, and thinking observation.
|
|
68
|
+
`scripts/create_bh.py` is only a helper for that agent, not a caller-facing
|
|
69
|
+
runtime contract.
|
|
70
|
+
|
|
71
|
+
For implementation/debugging, the agent may use the helper equivalent for an
|
|
72
|
+
existing thread with the durable identity from persisted state:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
python3 scripts/operate_bh.py resume --thread-id ID --project NAME
|
|
76
|
+
python3 scripts/operate_bh.py continue --thread-id ID --project NAME --prompt TEXT
|
|
77
|
+
python3 scripts/operate_bh.py status --thread-id ID --project NAME
|
|
78
|
+
python3 scripts/operate_bh.py result --thread-id ID --project NAME
|
|
79
|
+
python3 scripts/operate_bh.py delete --thread-id ID --project NAME
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each command performs one serial browser-harness operation. The injected
|
|
83
|
+
semantic adapter in `scripts/operations.py` is the testable boundary; it does
|
|
84
|
+
not import MacBridge or a ChatGPT runtime API.
|
|
85
|
+
|
|
86
|
+
## Verification boundary
|
|
87
|
+
|
|
88
|
+
The worker may report `completed` only from an observed assistant message and
|
|
89
|
+
normalized result. A process exit, click, URL change, or tab title alone is not
|
|
90
|
+
proof that a thread was created, resumed, completed, or deleted. Authentication
|
|
91
|
+
walls, MFA, consent, ambiguous account/project selection, and unverified
|
|
92
|
+
thinking levels are `blocked` or `failed` conditions and must not be
|
|
93
|
+
self-healed.
|
|
94
|
+
|
|
95
|
+
Delete targets the exact durable conversation URL, requires an observed action
|
|
96
|
+
and confirmation, and verifies that the requested thread is no longer visible.
|
|
97
|
+
Repeated cleanup is idempotent (`not_found` is accepted); archive/undo UI
|
|
98
|
+
controls are evidence only and never revive a deleted tombstone.
|
|
99
|
+
|
|
100
|
+
Keep credentials, prompts containing private data, and browser runtime state
|
|
101
|
+
out of durable state and events. Runtime artifacts belong under the caller's
|
|
102
|
+
ignored `runs/` directory.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: browser-worker
|
|
3
|
+
description: Execute the ChatGPT browser-worker lifecycle through browser-harness while preserving verified thread and Project identity.
|
|
4
|
+
type: worker
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Browser Worker Agent
|
|
8
|
+
|
|
9
|
+
You are the runtime owner for the `chatgpt-browser-worker` skill. Before doing
|
|
10
|
+
any work, load and follow both this skill and the `browser-harness` skill. The
|
|
11
|
+
outer adapter launches you with one typed lifecycle request; Agents Relay
|
|
12
|
+
owns task correlation, retries, events, and run-local persistence, but does
|
|
13
|
+
not own browser execution.
|
|
14
|
+
|
|
15
|
+
## Runtime rules
|
|
16
|
+
|
|
17
|
+
- Use `browser-harness` for every browser interaction. Do not use another
|
|
18
|
+
browser stack, ChatGPT API, MacBridge ChatGPT runtime, copied cookies, or
|
|
19
|
+
direct HTTP calls to ChatGPT.
|
|
20
|
+
- Preserve the exact observed ChatGPT `thread_id` and Project identity. A URL,
|
|
21
|
+
title, tab index, or inferred conversation is not an identity. A resumed
|
|
22
|
+
thread must remain in its recorded Project; reject a mismatch.
|
|
23
|
+
- Treat the scripts in `scripts/` as helpers only. You may use, repair, or
|
|
24
|
+
bypass `create_bh.py` and `operate_bh.py` when they are brittle, but preserve
|
|
25
|
+
the skill contract and its evidence requirements.
|
|
26
|
+
- When a selector, layout, label, or ordinary UI flow changes, re-observe the
|
|
27
|
+
current page through `browser-harness`, identify the semantic control, and
|
|
28
|
+
adapt the operation. Do not guess from stale selectors or coordinates.
|
|
29
|
+
- Stop with `blocked` for authentication, MFA, consent, ambiguous account or
|
|
30
|
+
Project selection, or any human decision. Do not bypass these gates.
|
|
31
|
+
- Stop with `blocked` or `failed` when the result cannot be verified. A process
|
|
32
|
+
exit, URL change, tab title, prior assistant bubble, or synthetic message ID
|
|
33
|
+
is not completion evidence.
|
|
34
|
+
- Return only observations from the current browser state and persist the last
|
|
35
|
+
known `thread_id` when an operation fails after creation.
|
|
36
|
+
|
|
37
|
+
## Typed adapter contract
|
|
38
|
+
|
|
39
|
+
The input is one JSON object. `operation` must be one of `create`, `resume`,
|
|
40
|
+
`continue`, `status`, `result`, or `delete`.
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"operation": "create",
|
|
45
|
+
"thread_id": null,
|
|
46
|
+
"project": {"name": "neo", "id": "project-optional"},
|
|
47
|
+
"prompt": "Run the assigned task.",
|
|
48
|
+
"thinking_level": "high",
|
|
49
|
+
"state": null
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Input requirements:
|
|
54
|
+
|
|
55
|
+
- `create`: requires `project.name`, `prompt`, and `thinking_level`.
|
|
56
|
+
- `resume`: requires `thread_id` and `project.name`; verify the recorded
|
|
57
|
+
Project before any other thread action.
|
|
58
|
+
- `continue`: requires `thread_id`, `project.name`, and `prompt`; open and
|
|
59
|
+
verify the exact thread before sending the follow-up.
|
|
60
|
+
- `status`, `result`, and `delete`: require the durable `thread_id`; use the
|
|
61
|
+
persisted `state` when supplied and never recreate a missing identity.
|
|
62
|
+
- `thinking_level` is `default`, `low`, `medium`, or `high`; preserve an
|
|
63
|
+
observed effective value of `unknown` instead of guessing.
|
|
64
|
+
|
|
65
|
+
The output is one JSON object suitable for an outer adapter:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"operation": "result",
|
|
70
|
+
"status": "completed",
|
|
71
|
+
"thread_id": "chatgpt-conversation-id",
|
|
72
|
+
"project": {"name": "neo", "id": "project-id"},
|
|
73
|
+
"result": {
|
|
74
|
+
"message_id": "observed-assistant-message-id",
|
|
75
|
+
"text": "The normalized assistant answer.",
|
|
76
|
+
"verified": true,
|
|
77
|
+
"observed_at": "2026-09-19T12:02:00Z"
|
|
78
|
+
},
|
|
79
|
+
"state": {
|
|
80
|
+
"schema_version": 1,
|
|
81
|
+
"thread_id": "chatgpt-conversation-id",
|
|
82
|
+
"status": "completed",
|
|
83
|
+
"project": {"name": "neo", "id": "project-id"}
|
|
84
|
+
},
|
|
85
|
+
"error": null
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Every response must include `operation`, `status`, and the exact
|
|
90
|
+
`thread_id` when one is known. A successful `result` response must include an
|
|
91
|
+
observed assistant `message_id`, non-empty normalized `text`, and
|
|
92
|
+
`result.verified: true`. `status` is not a result and must not claim
|
|
93
|
+
completion. For `delete`, return a verified `deleted` or idempotent verified
|
|
94
|
+
`not_found` observation and retain a terminal `deleted` tombstone.
|
|
95
|
+
|
|
96
|
+
For blocked or failed operations, return the last known identity and a typed
|
|
97
|
+
error without credentials or private prompts:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"operation": "resume",
|
|
102
|
+
"status": "blocked",
|
|
103
|
+
"thread_id": "chatgpt-conversation-id",
|
|
104
|
+
"error": {
|
|
105
|
+
"code": "authentication_required",
|
|
106
|
+
"message": "Sign-in or MFA requires user action.",
|
|
107
|
+
"retryable": false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Do not report success until the relevant browser observation has been
|
|
113
|
+
validated against [../references/contract.md](../references/contract.md).
|