agents-relay 1.0.4 → 1.0.6
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 +14 -13
- package/dist/adapters.js +133 -234
- package/dist/cli.js +18 -7
- package/dist/planner.js +9 -4
- package/dist/reconciler.js +128 -98
- package/dist/relayd.js +2 -2
- package/dist/store.js +1 -1
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +57 -45
- package/skills/agents-relay/agents/planner.agent.md +12 -9
- package/skills/chatgpt-browser-worker/SKILL.md +62 -0
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +42 -0
- package/skills/chatgpt-browser-worker/scripts/_temporary_bh.py +171 -0
- package/skills/chatgpt-browser-worker/scripts/temporary_bh.py +31 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Agents Relay
|
|
2
2
|
|
|
3
|
-
Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. GitHub PR comments
|
|
3
|
+
Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. GitHub PR comments store durable workflow state; correlated terminal events are authoritative for model-backed task lifecycle transitions.
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
@@ -11,7 +11,8 @@ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main \
|
|
|
11
11
|
--id job-1 --title "Objective" --body "PR description" --mode fixed
|
|
12
12
|
|
|
13
13
|
npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 \
|
|
14
|
-
--task-id child --
|
|
14
|
+
--task-id child --adapter codex --provider openai --model MODEL --output task-pr \
|
|
15
|
+
--input "Implement the described child task and publish a terminal event."
|
|
15
16
|
|
|
16
17
|
npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
|
|
17
18
|
npx agents-relay status --repo OWNER/REPO --pr 12 --id job-1
|
|
@@ -67,14 +68,14 @@ For explicit local demo/test mode:
|
|
|
67
68
|
npm install
|
|
68
69
|
npm run build
|
|
69
70
|
node dist/cli.js init --file .agents-relay.json --title "Dogfood" --id demo
|
|
70
|
-
node dist/cli.js submit --file .agents-relay.json --task-id hello --input "
|
|
71
|
+
node dist/cli.js submit --file .agents-relay.json --task-id hello --adapter codex --provider openai --model MODEL --output task-pr --input "Complete the described task"
|
|
71
72
|
node dist/cli.js reconcile --file .agents-relay.json
|
|
72
73
|
npx agents-relay serve --file .agents-relay.json
|
|
73
74
|
~~~
|
|
74
75
|
|
|
75
76
|
Open the dashboard on localhost port 8787. The marker format is intentionally public and append-friendly; the CLI uses GitHubStore with the authenticated gh client in operational mode.
|
|
76
77
|
|
|
77
|
-
reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode
|
|
78
|
+
reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode. Model-backed workers require --events nats; the event transport carries authoritative terminal worker events as well as wake/progress traffic. See [docs/service.md](docs/service.md). --file PATH is explicit local demo/test mode only.
|
|
78
79
|
|
|
79
80
|
`agents-relayd` without `--pr`/`--id` runs in workspace mode by default: it discovers nested Git repositories under `~/Workspace`, keeps only GitHub `origin` remotes, and aggregates their managed PR jobs into one dashboard and worker pool. Pass `--workspace PATH` to use another root. `--repo OWNER/REPO` remains the single-repository pool mode.
|
|
80
81
|
|
|
@@ -86,25 +87,25 @@ Codex/model-backed tasks require --provider and --model (or equivalent routing m
|
|
|
86
87
|
|
|
87
88
|
The minimal agent-network surface is machine-driven registration and discovery. `agent-register` persists an agent identity, responsibility boundary, claimed capabilities, endpoint/runtime, availability, and routing metadata in a trusted PR marker; `agent-discover` applies hard filters and returns evidence-backed candidates. Adapters remain runtimes, not agent identities. See [docs/architecture.md](docs/architecture.md) for the constrained future remote submission contract.
|
|
88
89
|
|
|
89
|
-
###
|
|
90
|
+
### Agent worker lifecycle
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
Managed tasks are descriptive work for agents, not shell commands. Use `codex` or `chatgpt` for worker tasks. Commands such as tests, build tools, Markad Vision, ffmpeg, and other CLIs are invoked by the orchestrator or by an executing agent; they are not worker adapters.
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay enriches the prompt with the PR URL, job/task identity, output contract, and mandatory terminal-event contract.
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
For Codex and ChatGPT, **events are authoritative task state**. A process exit or browser submission receipt does not complete a task. The executing agent must publish exactly one correlated `task.completed`, `task.failed`, or `task.blocked` event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
|
|
97
|
+
|
|
98
|
+
The ChatGPT adapter uses the packaged `chatgpt-browser-worker` as a one-shot submitter. It opens a fresh Temporary Chat tab, uses the account defaults, submits the prompt, verifies acceptance, closes the owned tab, and returns. It does not poll for the assistant response, resume/reopen a thread, or use conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
|
|
96
99
|
|
|
97
100
|
~~~sh
|
|
98
101
|
npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
|
|
99
102
|
--task-id research-ui --adapter chatgpt \
|
|
100
103
|
--capabilities model,chatgpt,browser-harness \
|
|
101
|
-
--provider openai --model
|
|
102
|
-
--
|
|
103
|
-
--input "Research the dashboard UX and
|
|
104
|
+
--provider openai --model MODEL \
|
|
105
|
+
--output file --output-path runs/research-ui.md \
|
|
106
|
+
--input "Research the dashboard UX and write the final report to the declared output file."
|
|
104
107
|
~~~
|
|
105
108
|
|
|
106
|
-
The adapter discovers `chatgpt-browser-worker/agents/browser-worker.agent.md` from `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` first, then `AGENTS_RELAY_SKILL_ROOTS`, `~/.codex/skills`, `~/.agents/skills`, and the local `skills` directory. It runs the definition and one typed JSON request through the configured local harness (`AGENTS_RELAY_AGENT_HARNESS`, default `codex`). Normal consumers do not need a Neo source checkout.
|
|
107
|
-
|
|
108
109
|
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.
|
|
109
110
|
|
|
110
111
|
For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be submitted before execution. Managed jobs also retain the GitHub PR body as the job description for dashboard display.
|
package/dist/adapters.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { access, readFile
|
|
2
|
+
import { access, readFile } 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
|
-
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
7
|
export function buildCodexArgs(route, input) {
|
|
8
8
|
const args = ['exec', '--json'];
|
|
9
9
|
if (route.profile)
|
|
@@ -19,277 +19,189 @@ export function buildCodexArgs(route, input) {
|
|
|
19
19
|
args.push('--', input);
|
|
20
20
|
return args;
|
|
21
21
|
}
|
|
22
|
-
function parseThreadStarted(line) {
|
|
23
|
-
const value = JSON.parse(line);
|
|
24
|
-
return value.type === 'thread.started' && typeof value.thread_id === 'string' ? value.thread_id : undefined;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return undefined;
|
|
28
|
-
} }
|
|
29
|
-
function parseCodexAssistantMessage(line) {
|
|
22
|
+
function parseThreadStarted(line) {
|
|
30
23
|
try {
|
|
31
24
|
const value = JSON.parse(line);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
|
|
37
|
-
return item.text.trim() || undefined;
|
|
25
|
+
return value.type === 'thread.started' && typeof value.thread_id === 'string' ? value.thread_id : undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
38
29
|
}
|
|
39
|
-
catch { /* non-JSON diagnostic output is ignored on successful Codex runs */ }
|
|
40
|
-
return undefined;
|
|
41
30
|
}
|
|
42
31
|
function appendTail(current, chunk, maxBytes = 16384) {
|
|
43
32
|
const combined = current + chunk;
|
|
44
|
-
return Buffer.byteLength(combined) <= maxBytes
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
shell;
|
|
48
|
-
name = 'shell';
|
|
49
|
-
id = 'shell';
|
|
50
|
-
capabilities = ['shell', 'command'];
|
|
51
|
-
constructor(shell = false) {
|
|
52
|
-
this.shell = shell;
|
|
53
|
-
}
|
|
54
|
-
launch(task, signal) { const parts = task.input.split(' ').filter(Boolean); const child = this.shell ? spawn(task.input, { shell: true }) : spawn(parts[0] ?? 'true', parts.slice(1)); let output = ''; child.stdout?.on('data', (x) => { output += x.toString(); }); child.stderr?.on('data', (x) => { output += x.toString(); }); return commandExecution(child, () => output, signal, randomUUID()); }
|
|
33
|
+
return Buffer.byteLength(combined) <= maxBytes
|
|
34
|
+
? combined
|
|
35
|
+
: Buffer.from(combined).subarray(-maxBytes).toString('utf8');
|
|
55
36
|
}
|
|
56
37
|
export class CodexAdapter {
|
|
57
38
|
command;
|
|
58
|
-
sessionsRoot;
|
|
59
39
|
name = 'codex';
|
|
60
40
|
id = 'codex';
|
|
61
41
|
capabilities = ['model', 'codex'];
|
|
62
|
-
constructor(command = 'codex'
|
|
42
|
+
constructor(command = 'codex') {
|
|
63
43
|
this.command = command;
|
|
64
|
-
this.sessionsRoot = sessionsRoot;
|
|
65
44
|
}
|
|
66
45
|
launch(task, signal) {
|
|
67
46
|
if (!task.routing)
|
|
68
47
|
throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
48
|
+
const child = spawn(this.command, buildCodexArgs(task.routing, task.input), {
|
|
49
|
+
shell: false,
|
|
50
|
+
cwd: task.routing.cwd,
|
|
51
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
52
|
+
});
|
|
72
53
|
let stdoutBuffer = '';
|
|
73
|
-
let finalAssistantMessage = '';
|
|
74
54
|
let stderrTail = '';
|
|
75
55
|
let bufferedThreadId;
|
|
76
56
|
let execution;
|
|
77
57
|
const consumeLine = (line) => {
|
|
78
58
|
const threadId = parseThreadStarted(line);
|
|
79
|
-
if (threadId)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
59
|
+
if (!threadId)
|
|
60
|
+
return;
|
|
61
|
+
bufferedThreadId = threadId;
|
|
62
|
+
if (execution) {
|
|
63
|
+
execution.threadId = threadId;
|
|
64
|
+
execution.onThreadStarted?.(threadId);
|
|
85
65
|
}
|
|
86
|
-
const assistantMessage = parseCodexAssistantMessage(line);
|
|
87
|
-
if (assistantMessage)
|
|
88
|
-
finalAssistantMessage = assistantMessage;
|
|
89
66
|
};
|
|
90
|
-
child.stdout?.on('data', (
|
|
91
|
-
stdoutBuffer +=
|
|
67
|
+
child.stdout?.on('data', (chunk) => {
|
|
68
|
+
stdoutBuffer += chunk.toString();
|
|
92
69
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
93
70
|
stdoutBuffer = lines.pop() ?? '';
|
|
94
71
|
for (const line of lines)
|
|
95
72
|
consumeLine(line);
|
|
96
73
|
});
|
|
97
|
-
child.stderr?.on('data', (
|
|
98
|
-
|
|
74
|
+
child.stderr?.on('data', (chunk) => {
|
|
75
|
+
stderrTail = appendTail(stderrTail, chunk.toString());
|
|
76
|
+
});
|
|
99
77
|
const promise = new Promise((resolve, reject) => {
|
|
100
78
|
child.on('error', reject);
|
|
101
79
|
child.on('close', code => {
|
|
102
80
|
if (stdoutBuffer)
|
|
103
81
|
consumeLine(stdoutBuffer);
|
|
104
|
-
if (code === 0)
|
|
105
|
-
resolve({ summary:
|
|
106
|
-
|
|
82
|
+
if (code === 0) {
|
|
83
|
+
resolve({ summary: 'Codex runtime exited after task execution; terminal task state is event-owned' });
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
107
86
|
reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
|
|
87
|
+
}
|
|
108
88
|
});
|
|
109
89
|
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
110
90
|
});
|
|
111
|
-
execution = { id, promise, cancel: () =>
|
|
91
|
+
execution = { id: randomUUID(), promise, cancel: () => child.kill('SIGTERM') };
|
|
112
92
|
execution.threadId = bufferedThreadId;
|
|
113
93
|
return execution;
|
|
114
94
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return null;
|
|
118
|
-
let entries;
|
|
119
|
-
try {
|
|
120
|
-
entries = await readdir(this.sessionsRoot, { recursive: true, encoding: 'utf8' });
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
const filename = entries.find(entry => entry.endsWith(`${task.threadId}.jsonl`));
|
|
126
|
-
if (!filename)
|
|
127
|
-
return null;
|
|
128
|
-
let lines;
|
|
129
|
-
try {
|
|
130
|
-
lines = (await readFile(join(this.sessionsRoot, filename), 'utf8')).split(/\r?\n/);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
136
|
-
const line = lines[index];
|
|
137
|
-
if (!line)
|
|
138
|
-
continue;
|
|
139
|
-
try {
|
|
140
|
-
const value = JSON.parse(line);
|
|
141
|
-
if (value.type !== 'event_msg')
|
|
142
|
-
continue;
|
|
143
|
-
const payload = value.payload;
|
|
144
|
-
if (payload?.type !== 'task_complete')
|
|
145
|
-
continue;
|
|
146
|
-
const summary = typeof payload.last_agent_message === 'string' ? payload.last_agent_message.trim() : '';
|
|
147
|
-
return { summary: summary || 'Codex task completed', data: { recoveredFromThread: task.threadId } };
|
|
148
|
-
}
|
|
149
|
-
catch { /* ignore malformed session rows */ }
|
|
150
|
-
}
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
95
|
+
}
|
|
96
|
+
export class LaunchFailure extends Error {
|
|
153
97
|
}
|
|
154
98
|
function browserWorkerCandidates(explicit) {
|
|
155
99
|
const roots = (process.env.AGENTS_RELAY_SKILL_ROOTS ?? '').split(':').filter(Boolean);
|
|
156
|
-
|
|
100
|
+
const packageSkills = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
101
|
+
return [
|
|
102
|
+
explicit,
|
|
103
|
+
...roots,
|
|
104
|
+
packageSkills,
|
|
105
|
+
join(homedir(), '.codex', 'skills'),
|
|
106
|
+
join(homedir(), '.agents', 'skills'),
|
|
107
|
+
join(process.cwd(), 'skills'),
|
|
108
|
+
]
|
|
157
109
|
.filter((value) => Boolean(value))
|
|
158
|
-
.map(value => value.endsWith('browser-worker.agent.md')
|
|
110
|
+
.map(value => value.endsWith('browser-worker.agent.md')
|
|
111
|
+
? value
|
|
112
|
+
: join(value, 'chatgpt-browser-worker', 'agents', 'browser-worker.agent.md'));
|
|
159
113
|
}
|
|
160
|
-
async function
|
|
114
|
+
async function loadBrowserWorkerRuntime(explicit) {
|
|
161
115
|
for (const candidate of browserWorkerCandidates(explicit)) {
|
|
162
116
|
try {
|
|
163
117
|
await access(candidate);
|
|
164
|
-
return readFile(candidate, 'utf8');
|
|
118
|
+
return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Try the next installed skill root.
|
|
165
122
|
}
|
|
166
|
-
catch { /* try the next installed skill root */ }
|
|
167
123
|
}
|
|
168
124
|
throw new Error('chatgpt-browser-worker agent definition not found; set AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT');
|
|
169
125
|
}
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
throw new Error('browser-worker returned non-JSON output');
|
|
126
|
+
function parseDriverJson(output) {
|
|
127
|
+
const lines = output.trim().split(/\r?\n/).filter(Boolean);
|
|
128
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
129
|
+
try {
|
|
130
|
+
const value = JSON.parse(lines[index]);
|
|
131
|
+
if (value.operation !== 'submit' || value.status !== 'submitted')
|
|
132
|
+
continue;
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Browser-harness diagnostics may precede the final JSON observation.
|
|
137
|
+
}
|
|
185
138
|
}
|
|
186
|
-
|
|
187
|
-
throw new Error('browser-worker returned an invalid JSON response');
|
|
188
|
-
const response = value;
|
|
189
|
-
if (!response.operation || !response.status)
|
|
190
|
-
throw new Error('browser-worker response omitted operation or status');
|
|
191
|
-
return response;
|
|
139
|
+
throw new Error('browser-worker driver returned no submitted receipt');
|
|
192
140
|
}
|
|
193
|
-
function
|
|
194
|
-
return
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const child = spawn(
|
|
141
|
+
function directBrowserWorkerRunner(root) {
|
|
142
|
+
return (_definition, request, _route, signal) => {
|
|
143
|
+
const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt];
|
|
144
|
+
for (const file of request.files ?? [])
|
|
145
|
+
args.push('--file', file);
|
|
146
|
+
const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
199
147
|
let stdout = '';
|
|
200
148
|
let stderr = '';
|
|
201
|
-
child.stdout?.on('data', (chunk) => {
|
|
202
|
-
|
|
149
|
+
child.stdout?.on('data', (chunk) => {
|
|
150
|
+
stdout += chunk.toString();
|
|
151
|
+
});
|
|
152
|
+
child.stderr?.on('data', (chunk) => {
|
|
153
|
+
stderr = appendTail(stderr, chunk.toString());
|
|
154
|
+
});
|
|
203
155
|
const promise = new Promise((resolve, reject) => {
|
|
204
156
|
child.on('error', reject);
|
|
205
|
-
child.on('close', code =>
|
|
157
|
+
child.on('close', code => {
|
|
158
|
+
if (code !== 0) {
|
|
159
|
+
reject(new Error(stderr.trim() || stdout.trim() || `browser-worker driver exited ${code}`));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
resolve(JSON.stringify(parseDriverJson(stdout)));
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
reject(error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
206
169
|
if (signal.aborted)
|
|
207
170
|
child.kill('SIGTERM');
|
|
208
171
|
else
|
|
209
172
|
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
210
173
|
});
|
|
211
|
-
return { promise, cancel: () =>
|
|
174
|
+
return { promise, cancel: () => child.kill('SIGTERM') };
|
|
212
175
|
};
|
|
213
176
|
}
|
|
214
|
-
function
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 */ }
|
|
177
|
+
function parseBrowserWorkerResponse(output) {
|
|
178
|
+
const text = output.trim().replace(/^\`\`\`(?:json)?\s*|\s*\`\`\`$/g, '').trim();
|
|
179
|
+
let value;
|
|
180
|
+
try {
|
|
181
|
+
value = JSON.parse(text);
|
|
227
182
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
183
|
+
catch {
|
|
184
|
+
throw new Error('browser-worker returned non-JSON output');
|
|
185
|
+
}
|
|
186
|
+
if (!value || typeof value !== 'object')
|
|
187
|
+
throw new Error('browser-worker returned an invalid JSON response');
|
|
188
|
+
const response = value;
|
|
189
|
+
if (response.operation !== 'submit' || response.status !== 'submitted') {
|
|
190
|
+
throw new Error('browser-worker did not return a submitted receipt');
|
|
191
|
+
}
|
|
192
|
+
if (response.verified !== true)
|
|
193
|
+
throw new Error('browser-worker submission was not verified');
|
|
194
|
+
return response;
|
|
233
195
|
}
|
|
234
196
|
export class ChatGptAdapter {
|
|
235
197
|
name = 'chatgpt';
|
|
236
198
|
id = 'chatgpt/browser-worker';
|
|
237
199
|
capabilities = ['model', 'chatgpt', 'browser-harness'];
|
|
238
200
|
agentPath;
|
|
239
|
-
harness;
|
|
240
201
|
runner;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
async run(request, route, signal) {
|
|
245
|
-
const definition = await loadBrowserWorkerDefinition(this.agentPath);
|
|
246
|
-
if (signal.aborted)
|
|
247
|
-
throw new Error('browser-worker execution aborted');
|
|
248
|
-
const run = (this.runner ?? defaultChatGptAgentRunner(this.harness))(definition, request, route, signal);
|
|
249
|
-
const output = await new Promise((resolve, reject) => {
|
|
250
|
-
const abort = () => { run.cancel(); reject(new Error('browser-worker execution aborted')); };
|
|
251
|
-
if (signal.aborted) {
|
|
252
|
-
abort();
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
signal.addEventListener('abort', abort, { once: true });
|
|
256
|
-
run.promise.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
|
|
257
|
-
});
|
|
258
|
-
return parseBrowserWorkerResponse(output);
|
|
259
|
-
}
|
|
260
|
-
project(task, route) {
|
|
261
|
-
return { name: task?.projectName ?? route.projectId ?? 'default', id: route.projectId };
|
|
262
|
-
}
|
|
263
|
-
exposeThread(execution, expected, response) {
|
|
264
|
-
const threadId = typeof response.thread_id === 'string' && response.thread_id.trim() ? response.thread_id : undefined;
|
|
265
|
-
if (!threadId)
|
|
266
|
-
throw new Error('browser-worker response omitted thread_id');
|
|
267
|
-
if (expected && threadId !== expected)
|
|
268
|
-
throw new Error('browser-worker returned a different thread_id');
|
|
269
|
-
execution.threadId = threadId;
|
|
270
|
-
execution.onThreadStarted?.(threadId);
|
|
271
|
-
return threadId;
|
|
272
|
-
}
|
|
273
|
-
validateOperation(response, operation) {
|
|
274
|
-
if (response.operation !== operation)
|
|
275
|
-
throw new Error(`browser-worker response operation mismatch: expected ${operation}, got ${response.operation}`);
|
|
276
|
-
if (response.status === 'blocked' || response.status === 'failed')
|
|
277
|
-
throw browserWorkerError(response);
|
|
278
|
-
}
|
|
279
|
-
async deleteThread(threadId, task) {
|
|
280
|
-
if (this.deleted.has(threadId))
|
|
281
|
-
return;
|
|
282
|
-
const route = task?.routing ?? this.routes.get(threadId);
|
|
283
|
-
if (!route)
|
|
284
|
-
throw new Error(`Task routing metadata is required to delete browser-worker thread ${threadId}`);
|
|
285
|
-
const response = await this.run({ operation: 'delete', thread_id: threadId, project: this.project(task, route), state: null }, route, new AbortController().signal);
|
|
286
|
-
if (response.operation !== 'delete')
|
|
287
|
-
throw new Error(`browser-worker response operation mismatch: expected delete, got ${response.operation}`);
|
|
288
|
-
if (response.thread_id && response.thread_id !== threadId)
|
|
289
|
-
throw new Error('browser-worker returned a different thread_id');
|
|
290
|
-
if (response.status !== 'deleted' && response.status !== 'not_found')
|
|
291
|
-
throw browserWorkerError(response);
|
|
292
|
-
this.deleted.add(threadId);
|
|
202
|
+
constructor(options = {}) {
|
|
203
|
+
this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT;
|
|
204
|
+
this.runner = options.agentRunner;
|
|
293
205
|
}
|
|
294
206
|
launch(task, signal) {
|
|
295
207
|
if (!task.routing)
|
|
@@ -301,45 +213,32 @@ export class ChatGptAdapter {
|
|
|
301
213
|
abort();
|
|
302
214
|
else
|
|
303
215
|
signal.addEventListener('abort', abort, { once: true });
|
|
304
|
-
const execution = {
|
|
305
|
-
|
|
216
|
+
const execution = {
|
|
217
|
+
id: randomUUID(),
|
|
218
|
+
promise: Promise.resolve({ summary: '' }),
|
|
219
|
+
cancel: abort,
|
|
220
|
+
};
|
|
306
221
|
execution.promise = (async () => {
|
|
222
|
+
const runtime = await loadBrowserWorkerRuntime(this.agentPath);
|
|
223
|
+
if (controller.signal.aborted)
|
|
224
|
+
throw new Error('browser-worker execution aborted');
|
|
225
|
+
const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input }, route, controller.signal);
|
|
307
226
|
try {
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
227
|
+
const output = await run.promise;
|
|
228
|
+
const response = parseBrowserWorkerResponse(output);
|
|
229
|
+
return {
|
|
230
|
+
summary: 'ChatGPT task submitted; terminal task state is event-owned',
|
|
231
|
+
data: {
|
|
232
|
+
submitted: true,
|
|
233
|
+
diagnosticThreadId: response.diagnostic_thread_id ?? null,
|
|
234
|
+
userMessageId: response.user_message_id ?? null,
|
|
235
|
+
},
|
|
314
236
|
};
|
|
315
|
-
const response = await this.run(request, route, controller.signal);
|
|
316
|
-
const threadId = this.exposeThread(execution, expectedThreadId, response);
|
|
317
|
-
this.routes.set(threadId, route);
|
|
318
|
-
this.validateOperation(response, operation);
|
|
319
|
-
let status = response.status;
|
|
320
|
-
while (status !== 'awaiting_result' && status !== 'completed') {
|
|
321
|
-
if (controller.signal.aborted)
|
|
322
|
-
throw new Error('browser-worker execution aborted');
|
|
323
|
-
await new Promise((resolve, reject) => {
|
|
324
|
-
const timer = setTimeout(resolve, 25);
|
|
325
|
-
controller.signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('browser-worker execution aborted')); }, { once: true });
|
|
326
|
-
});
|
|
327
|
-
const statusResponse = await this.run({ operation: 'status', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
|
|
328
|
-
if (statusResponse.thread_id && statusResponse.thread_id !== threadId)
|
|
329
|
-
throw new Error('browser-worker returned a different thread_id');
|
|
330
|
-
this.validateOperation(statusResponse, 'status');
|
|
331
|
-
status = statusResponse.status;
|
|
332
|
-
}
|
|
333
|
-
const resultResponse = await this.run({ operation: 'result', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
|
|
334
|
-
if (resultResponse.thread_id && resultResponse.thread_id !== threadId)
|
|
335
|
-
throw new Error('browser-worker returned a different thread_id');
|
|
336
|
-
this.validateOperation(resultResponse, 'result');
|
|
337
|
-
if (resultResponse.status !== 'completed' || resultResponse.result?.verified !== true || !resultResponse.result.message_id || !resultResponse.result.text?.trim())
|
|
338
|
-
throw browserWorkerError(resultResponse);
|
|
339
|
-
return { summary: resultResponse.result.text.trim(), data: { threadId, messageId: resultResponse.result.message_id, verified: true, provider: route.provider, model: route.model } };
|
|
340
237
|
}
|
|
341
|
-
|
|
342
|
-
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (controller.signal.aborted)
|
|
240
|
+
throw error;
|
|
241
|
+
throw new LaunchFailure(error instanceof Error ? error.message : String(error));
|
|
343
242
|
}
|
|
344
243
|
})();
|
|
345
244
|
return execution;
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
7
7
|
import { dashboardManagedJobs, InMemoryStore, GitHubStore, resolvePullRequest, listManagedGitHubJobs } from './store.js';
|
|
8
8
|
import { githubAuthContext } from './github-auth.js';
|
|
9
9
|
import { Reconciler } from './reconciler.js';
|
|
10
|
-
import {
|
|
10
|
+
import { CodexAdapter, ChatGptAdapter } from './adapters.js';
|
|
11
11
|
import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
|
|
12
12
|
import { NatsEventBus, eventFor } from './events.js';
|
|
13
13
|
import { serveDashboard } from './dashboard.js';
|
|
@@ -95,10 +95,10 @@ Example:
|
|
|
95
95
|
submit: `Queue a task for a managed job.
|
|
96
96
|
|
|
97
97
|
Required: --repo OWNER/REPO, --pr NUMBER, --id JOB_ID, and --input TEXT.
|
|
98
|
-
|
|
98
|
+
Required for worker tasks: --adapter codex|chatgpt, --model MODEL, and --output task-pr|file. File output also requires --output-path PATH.\nCommon: --task-id ID, --priority P0|P1|P2|P3, --max-attempts N. Model-backed execution requires --events nats in the running reconciler/daemon.
|
|
99
99
|
|
|
100
100
|
Example:
|
|
101
|
-
npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 --task-id build --input "
|
|
101
|
+
npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 --task-id build --adapter codex --provider openai --model gpt-5-6 --output task-pr --input "Run the required tests and report the result through events."`,
|
|
102
102
|
record: `Backfill work completed outside the relay into durable history.
|
|
103
103
|
|
|
104
104
|
Required: --file PATH or --repo/--pr/--id, plus --summary TEXT.
|
|
@@ -269,8 +269,8 @@ async function runTaskCommand(action, args) {
|
|
|
269
269
|
throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
|
|
270
270
|
if (hasArg(args, '--adapter')) {
|
|
271
271
|
const adapter = arg(args, '--adapter');
|
|
272
|
-
if (!['
|
|
273
|
-
throw new Error('--adapter must be
|
|
272
|
+
if (!['codex', 'chatgpt', 'orchestrator'].includes(adapter))
|
|
273
|
+
throw new Error('--adapter must be codex, chatgpt, or orchestrator');
|
|
274
274
|
task.adapter = adapter;
|
|
275
275
|
}
|
|
276
276
|
if (hasArg(args, '--priority'))
|
|
@@ -346,7 +346,7 @@ async function runJobCommand(action, args) {
|
|
|
346
346
|
console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state }, null, 2));
|
|
347
347
|
}
|
|
348
348
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
349
|
-
export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new
|
|
349
|
+
export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, eventBus: bus, emit }); }
|
|
350
350
|
export async function createService(args) {
|
|
351
351
|
const loaded = await storeFor(args);
|
|
352
352
|
const upstream = eventBus(args);
|
|
@@ -490,8 +490,19 @@ async function main() {
|
|
|
490
490
|
if (command === 'submit') {
|
|
491
491
|
const now = new Date().toISOString();
|
|
492
492
|
const model = arg(args, '--model');
|
|
493
|
+
const adapter = arg(args, '--adapter', 'codex');
|
|
494
|
+
const outputKind = arg(args, '--output');
|
|
495
|
+
const outputPath = arg(args, '--output-path');
|
|
496
|
+
if (!['codex', 'chatgpt'].includes(adapter))
|
|
497
|
+
throw new Error('submit adapter must be codex or chatgpt');
|
|
498
|
+
if (!model)
|
|
499
|
+
throw new Error('submit requires --model for codex/chatgpt tasks');
|
|
500
|
+
if (outputKind !== 'task-pr' && outputKind !== 'file')
|
|
501
|
+
throw new Error('submit requires --output task-pr|file');
|
|
502
|
+
if (outputKind === 'file' && !outputPath)
|
|
503
|
+
throw new Error('--output file requires --output-path');
|
|
493
504
|
const routing = model ? { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now } : undefined;
|
|
494
|
-
const task = { jobId: job.id, id: arg(args, '--task-id', randomUUID()), priority: priority(args, job.priority ?? 'P2'), projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent') || undefined, parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: arg(args, '--capabilities').split(',').filter(Boolean), adapter: arg(args, '--
|
|
505
|
+
const task = { jobId: job.id, id: arg(args, '--task-id', randomUUID()), priority: priority(args, job.priority ?? 'P2'), projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent') || undefined, parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: arg(args, '--capabilities').split(',').filter(Boolean), adapter, input: arg(args, '--input', 'true'), output: outputKind === 'file' ? { kind: 'file', path: outputPath } : { kind: 'task_pr' }, routing, continuation: continuation(args), continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: Number(arg(args, '--max-attempts', '3')), leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, error: null, timeoutMs: Number(arg(args, '--timeout', '300000')), createdAt: now, updatedAt: now };
|
|
495
506
|
await store.appendTask(task);
|
|
496
507
|
job.tasks.push(task);
|
|
497
508
|
if (job.state === 'COMPLETED') {
|