agents-relay 1.0.5 → 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 +107 -257
- package/dist/cli.js +18 -7
- package/dist/planner.js +9 -4
- package/dist/reconciler.js +128 -100
- 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 +56 -96
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +30 -101
- package/skills/chatgpt-browser-worker/scripts/_temporary_bh.py +171 -0
- package/skills/chatgpt-browser-worker/scripts/{create_bh.py → temporary_bh.py} +5 -8
- package/skills/chatgpt-browser-worker/references/contract.md +0 -132
- package/skills/chatgpt-browser-worker/references/orchestration.md +0 -101
- package/skills/chatgpt-browser-worker/scripts/_create_bh.py +0 -158
- package/skills/chatgpt-browser-worker/scripts/_operate_bh.py +0 -198
- package/skills/chatgpt-browser-worker/scripts/contract.py +0 -151
- package/skills/chatgpt-browser-worker/scripts/create.py +0 -84
- package/skills/chatgpt-browser-worker/scripts/operate_bh.py +0 -36
- package/skills/chatgpt-browser-worker/scripts/operations.py +0 -179
- package/skills/chatgpt-browser-worker/tests/fixtures/relay_lifecycle.json +0 -21
- package/skills/chatgpt-browser-worker/tests/test_agent_definition.py +0 -27
- package/skills/chatgpt-browser-worker/tests/test_contract.py +0 -315
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 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.
|
|
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,10 +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
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
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'); } }; }
|
|
8
7
|
export function buildCodexArgs(route, input) {
|
|
9
8
|
const args = ['exec', '--json'];
|
|
10
9
|
if (route.profile)
|
|
@@ -20,146 +19,97 @@ export function buildCodexArgs(route, input) {
|
|
|
20
19
|
args.push('--', input);
|
|
21
20
|
return args;
|
|
22
21
|
}
|
|
23
|
-
function parseThreadStarted(line) {
|
|
24
|
-
const value = JSON.parse(line);
|
|
25
|
-
return value.type === 'thread.started' && typeof value.thread_id === 'string' ? value.thread_id : undefined;
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return undefined;
|
|
29
|
-
} }
|
|
30
|
-
function parseCodexAssistantMessage(line) {
|
|
22
|
+
function parseThreadStarted(line) {
|
|
31
23
|
try {
|
|
32
24
|
const value = JSON.parse(line);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
|
|
38
|
-
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;
|
|
39
29
|
}
|
|
40
|
-
catch { /* non-JSON diagnostic output is ignored on successful Codex runs */ }
|
|
41
|
-
return undefined;
|
|
42
30
|
}
|
|
43
31
|
function appendTail(current, chunk, maxBytes = 16384) {
|
|
44
32
|
const combined = current + chunk;
|
|
45
|
-
return Buffer.byteLength(combined) <= maxBytes
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
shell;
|
|
49
|
-
name = 'shell';
|
|
50
|
-
id = 'shell';
|
|
51
|
-
capabilities = ['shell', 'command'];
|
|
52
|
-
constructor(shell = false) {
|
|
53
|
-
this.shell = shell;
|
|
54
|
-
}
|
|
55
|
-
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');
|
|
56
36
|
}
|
|
57
37
|
export class CodexAdapter {
|
|
58
38
|
command;
|
|
59
|
-
sessionsRoot;
|
|
60
39
|
name = 'codex';
|
|
61
40
|
id = 'codex';
|
|
62
41
|
capabilities = ['model', 'codex'];
|
|
63
|
-
constructor(command = 'codex'
|
|
42
|
+
constructor(command = 'codex') {
|
|
64
43
|
this.command = command;
|
|
65
|
-
this.sessionsRoot = sessionsRoot;
|
|
66
44
|
}
|
|
67
45
|
launch(task, signal) {
|
|
68
46
|
if (!task.routing)
|
|
69
47
|
throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
});
|
|
73
53
|
let stdoutBuffer = '';
|
|
74
|
-
let finalAssistantMessage = '';
|
|
75
54
|
let stderrTail = '';
|
|
76
55
|
let bufferedThreadId;
|
|
77
56
|
let execution;
|
|
78
57
|
const consumeLine = (line) => {
|
|
79
58
|
const threadId = parseThreadStarted(line);
|
|
80
|
-
if (threadId)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
59
|
+
if (!threadId)
|
|
60
|
+
return;
|
|
61
|
+
bufferedThreadId = threadId;
|
|
62
|
+
if (execution) {
|
|
63
|
+
execution.threadId = threadId;
|
|
64
|
+
execution.onThreadStarted?.(threadId);
|
|
86
65
|
}
|
|
87
|
-
const assistantMessage = parseCodexAssistantMessage(line);
|
|
88
|
-
if (assistantMessage)
|
|
89
|
-
finalAssistantMessage = assistantMessage;
|
|
90
66
|
};
|
|
91
|
-
child.stdout?.on('data', (
|
|
92
|
-
stdoutBuffer +=
|
|
67
|
+
child.stdout?.on('data', (chunk) => {
|
|
68
|
+
stdoutBuffer += chunk.toString();
|
|
93
69
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
94
70
|
stdoutBuffer = lines.pop() ?? '';
|
|
95
71
|
for (const line of lines)
|
|
96
72
|
consumeLine(line);
|
|
97
73
|
});
|
|
98
|
-
child.stderr?.on('data', (
|
|
99
|
-
|
|
74
|
+
child.stderr?.on('data', (chunk) => {
|
|
75
|
+
stderrTail = appendTail(stderrTail, chunk.toString());
|
|
76
|
+
});
|
|
100
77
|
const promise = new Promise((resolve, reject) => {
|
|
101
78
|
child.on('error', reject);
|
|
102
79
|
child.on('close', code => {
|
|
103
80
|
if (stdoutBuffer)
|
|
104
81
|
consumeLine(stdoutBuffer);
|
|
105
|
-
if (code === 0)
|
|
106
|
-
resolve({ summary:
|
|
107
|
-
|
|
82
|
+
if (code === 0) {
|
|
83
|
+
resolve({ summary: 'Codex runtime exited after task execution; terminal task state is event-owned' });
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
108
86
|
reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
|
|
87
|
+
}
|
|
109
88
|
});
|
|
110
89
|
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
111
90
|
});
|
|
112
|
-
execution = { id, promise, cancel: () =>
|
|
91
|
+
execution = { id: randomUUID(), promise, cancel: () => child.kill('SIGTERM') };
|
|
113
92
|
execution.threadId = bufferedThreadId;
|
|
114
93
|
return execution;
|
|
115
94
|
}
|
|
116
|
-
async recover(task) {
|
|
117
|
-
if (!task.threadId)
|
|
118
|
-
return null;
|
|
119
|
-
let entries;
|
|
120
|
-
try {
|
|
121
|
-
entries = await readdir(this.sessionsRoot, { recursive: true, encoding: 'utf8' });
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
const filename = entries.find(entry => entry.endsWith(`${task.threadId}.jsonl`));
|
|
127
|
-
if (!filename)
|
|
128
|
-
return null;
|
|
129
|
-
let lines;
|
|
130
|
-
try {
|
|
131
|
-
lines = (await readFile(join(this.sessionsRoot, filename), 'utf8')).split(/\r?\n/);
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
137
|
-
const line = lines[index];
|
|
138
|
-
if (!line)
|
|
139
|
-
continue;
|
|
140
|
-
try {
|
|
141
|
-
const value = JSON.parse(line);
|
|
142
|
-
if (value.type !== 'event_msg')
|
|
143
|
-
continue;
|
|
144
|
-
const payload = value.payload;
|
|
145
|
-
if (payload?.type !== 'task_complete')
|
|
146
|
-
continue;
|
|
147
|
-
const summary = typeof payload.last_agent_message === 'string' ? payload.last_agent_message.trim() : '';
|
|
148
|
-
return { summary: summary || 'Codex task completed', data: { recoveredFromThread: task.threadId } };
|
|
149
|
-
}
|
|
150
|
-
catch { /* ignore malformed session rows */ }
|
|
151
|
-
}
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
95
|
}
|
|
155
96
|
export class LaunchFailure extends Error {
|
|
156
97
|
}
|
|
157
98
|
function browserWorkerCandidates(explicit) {
|
|
158
99
|
const roots = (process.env.AGENTS_RELAY_SKILL_ROOTS ?? '').split(':').filter(Boolean);
|
|
159
100
|
const packageSkills = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
160
|
-
return [
|
|
101
|
+
return [
|
|
102
|
+
explicit,
|
|
103
|
+
...roots,
|
|
104
|
+
packageSkills,
|
|
105
|
+
join(homedir(), '.codex', 'skills'),
|
|
106
|
+
join(homedir(), '.agents', 'skills'),
|
|
107
|
+
join(process.cwd(), 'skills'),
|
|
108
|
+
]
|
|
161
109
|
.filter((value) => Boolean(value))
|
|
162
|
-
.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'));
|
|
163
113
|
}
|
|
164
114
|
async function loadBrowserWorkerRuntime(explicit) {
|
|
165
115
|
for (const candidate of browserWorkerCandidates(explicit)) {
|
|
@@ -167,58 +117,41 @@ async function loadBrowserWorkerRuntime(explicit) {
|
|
|
167
117
|
await access(candidate);
|
|
168
118
|
return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
|
|
169
119
|
}
|
|
170
|
-
catch {
|
|
120
|
+
catch {
|
|
121
|
+
// Try the next installed skill root.
|
|
122
|
+
}
|
|
171
123
|
}
|
|
172
124
|
throw new Error('chatgpt-browser-worker agent definition not found; set AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT');
|
|
173
125
|
}
|
|
174
|
-
function thinkingLevel(reasoning) {
|
|
175
|
-
if (reasoning === 'low' || reasoning === 'medium' || reasoning === 'high')
|
|
176
|
-
return reasoning;
|
|
177
|
-
if (reasoning === 'minimal')
|
|
178
|
-
return 'low';
|
|
179
|
-
return 'default';
|
|
180
|
-
}
|
|
181
|
-
function parseBrowserWorkerResponse(output) {
|
|
182
|
-
const text = output.trim().replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
|
|
183
|
-
let value;
|
|
184
|
-
try {
|
|
185
|
-
value = JSON.parse(text);
|
|
186
|
-
}
|
|
187
|
-
catch {
|
|
188
|
-
throw new Error('browser-worker returned non-JSON output');
|
|
189
|
-
}
|
|
190
|
-
if (!value || typeof value !== 'object')
|
|
191
|
-
throw new Error('browser-worker returned an invalid JSON response');
|
|
192
|
-
const response = value;
|
|
193
|
-
if (!response.operation || !response.status)
|
|
194
|
-
throw new Error('browser-worker response omitted operation or status');
|
|
195
|
-
return response;
|
|
196
|
-
}
|
|
197
126
|
function parseDriverJson(output) {
|
|
198
127
|
const lines = output.trim().split(/\r?\n/).filter(Boolean);
|
|
199
128
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
200
129
|
try {
|
|
201
130
|
const value = JSON.parse(lines[index]);
|
|
202
|
-
if (value
|
|
203
|
-
|
|
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.
|
|
204
137
|
}
|
|
205
|
-
catch { /* browser-harness diagnostics may precede the final JSON observation */ }
|
|
206
138
|
}
|
|
207
|
-
throw new Error('browser-worker driver returned no
|
|
139
|
+
throw new Error('browser-worker driver returned no submitted receipt');
|
|
208
140
|
}
|
|
209
141
|
function directBrowserWorkerRunner(root) {
|
|
210
142
|
return (_definition, request, _route, signal) => {
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
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 ?? ''] : [])];
|
|
143
|
+
const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt];
|
|
144
|
+
for (const file of request.files ?? [])
|
|
145
|
+
args.push('--file', file);
|
|
217
146
|
const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
218
147
|
let stdout = '';
|
|
219
148
|
let stderr = '';
|
|
220
|
-
child.stdout?.on('data', (chunk) => {
|
|
221
|
-
|
|
149
|
+
child.stdout?.on('data', (chunk) => {
|
|
150
|
+
stdout += chunk.toString();
|
|
151
|
+
});
|
|
152
|
+
child.stderr?.on('data', (chunk) => {
|
|
153
|
+
stderr = appendTail(stderr, chunk.toString());
|
|
154
|
+
});
|
|
222
155
|
const promise = new Promise((resolve, reject) => {
|
|
223
156
|
child.on('error', reject);
|
|
224
157
|
child.on('close', code => {
|
|
@@ -227,33 +160,7 @@ function directBrowserWorkerRunner(root) {
|
|
|
227
160
|
return;
|
|
228
161
|
}
|
|
229
162
|
try {
|
|
230
|
-
|
|
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));
|
|
163
|
+
resolve(JSON.stringify(parseDriverJson(stdout)));
|
|
257
164
|
}
|
|
258
165
|
catch (error) {
|
|
259
166
|
reject(error);
|
|
@@ -264,12 +171,27 @@ function directBrowserWorkerRunner(root) {
|
|
|
264
171
|
else
|
|
265
172
|
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
266
173
|
});
|
|
267
|
-
return { promise, cancel: () =>
|
|
174
|
+
return { promise, cancel: () => child.kill('SIGTERM') };
|
|
268
175
|
};
|
|
269
176
|
}
|
|
270
|
-
function
|
|
271
|
-
const
|
|
272
|
-
|
|
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);
|
|
182
|
+
}
|
|
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;
|
|
273
195
|
}
|
|
274
196
|
export class ChatGptAdapter {
|
|
275
197
|
name = 'chatgpt';
|
|
@@ -277,58 +199,9 @@ export class ChatGptAdapter {
|
|
|
277
199
|
capabilities = ['model', 'chatgpt', 'browser-harness'];
|
|
278
200
|
agentPath;
|
|
279
201
|
runner;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
async run(request, route, signal) {
|
|
284
|
-
const runtime = await loadBrowserWorkerRuntime(this.agentPath);
|
|
285
|
-
if (signal.aborted)
|
|
286
|
-
throw new Error('browser-worker execution aborted');
|
|
287
|
-
const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, request, route, signal);
|
|
288
|
-
const output = await new Promise((resolve, reject) => {
|
|
289
|
-
const abort = () => { run.cancel(); reject(new Error('browser-worker execution aborted')); };
|
|
290
|
-
if (signal.aborted) {
|
|
291
|
-
abort();
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
signal.addEventListener('abort', abort, { once: true });
|
|
295
|
-
run.promise.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
|
|
296
|
-
});
|
|
297
|
-
return parseBrowserWorkerResponse(output);
|
|
298
|
-
}
|
|
299
|
-
project(task, route) {
|
|
300
|
-
return { name: task?.projectName ?? route.projectId ?? 'default', id: route.projectId };
|
|
301
|
-
}
|
|
302
|
-
exposeThread(execution, expected, response) {
|
|
303
|
-
const threadId = typeof response.thread_id === 'string' && response.thread_id.trim() ? response.thread_id : undefined;
|
|
304
|
-
if (!threadId)
|
|
305
|
-
throw new Error('browser-worker response omitted thread_id');
|
|
306
|
-
if (expected && threadId !== expected)
|
|
307
|
-
throw new Error('browser-worker returned a different thread_id');
|
|
308
|
-
execution.threadId = threadId;
|
|
309
|
-
execution.onThreadStarted?.(threadId);
|
|
310
|
-
return threadId;
|
|
311
|
-
}
|
|
312
|
-
validateOperation(response, operation) {
|
|
313
|
-
if (response.operation !== operation)
|
|
314
|
-
throw new Error(`browser-worker response operation mismatch: expected ${operation}, got ${response.operation}`);
|
|
315
|
-
if (response.status === 'blocked' || response.status === 'failed')
|
|
316
|
-
throw browserWorkerError(response);
|
|
317
|
-
}
|
|
318
|
-
async deleteThread(threadId, task) {
|
|
319
|
-
if (this.deleted.has(threadId))
|
|
320
|
-
return;
|
|
321
|
-
const route = task?.routing ?? this.routes.get(threadId);
|
|
322
|
-
if (!route)
|
|
323
|
-
throw new Error(`Task routing metadata is required to delete browser-worker thread ${threadId}`);
|
|
324
|
-
const response = await this.run({ operation: 'delete', thread_id: threadId, project: this.project(task, route), state: null }, route, new AbortController().signal);
|
|
325
|
-
if (response.operation !== 'delete')
|
|
326
|
-
throw new Error(`browser-worker response operation mismatch: expected delete, got ${response.operation}`);
|
|
327
|
-
if (response.thread_id && response.thread_id !== threadId)
|
|
328
|
-
throw new Error('browser-worker returned a different thread_id');
|
|
329
|
-
if (response.status !== 'deleted' && response.status !== 'not_found')
|
|
330
|
-
throw browserWorkerError(response);
|
|
331
|
-
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;
|
|
332
205
|
}
|
|
333
206
|
launch(task, signal) {
|
|
334
207
|
if (!task.routing)
|
|
@@ -340,55 +213,32 @@ export class ChatGptAdapter {
|
|
|
340
213
|
abort();
|
|
341
214
|
else
|
|
342
215
|
signal.addEventListener('abort', abort, { once: true });
|
|
343
|
-
const execution = {
|
|
344
|
-
|
|
216
|
+
const execution = {
|
|
217
|
+
id: randomUUID(),
|
|
218
|
+
promise: Promise.resolve({ summary: '' }),
|
|
219
|
+
cancel: abort,
|
|
220
|
+
};
|
|
345
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);
|
|
346
226
|
try {
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
+
},
|
|
353
236
|
};
|
|
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
|
-
}
|
|
365
|
-
const threadId = this.exposeThread(execution, expectedThreadId, response);
|
|
366
|
-
this.routes.set(threadId, route);
|
|
367
|
-
this.validateOperation(response, operation);
|
|
368
|
-
let status = response.status;
|
|
369
|
-
while (status !== 'awaiting_result' && status !== 'completed') {
|
|
370
|
-
if (controller.signal.aborted)
|
|
371
|
-
throw new Error('browser-worker execution aborted');
|
|
372
|
-
await new Promise((resolve, reject) => {
|
|
373
|
-
const timer = setTimeout(resolve, 25);
|
|
374
|
-
controller.signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('browser-worker execution aborted')); }, { once: true });
|
|
375
|
-
});
|
|
376
|
-
const statusResponse = await this.run({ operation: 'status', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
|
|
377
|
-
if (statusResponse.thread_id && statusResponse.thread_id !== threadId)
|
|
378
|
-
throw new Error('browser-worker returned a different thread_id');
|
|
379
|
-
this.validateOperation(statusResponse, 'status');
|
|
380
|
-
status = statusResponse.status;
|
|
381
|
-
}
|
|
382
|
-
const resultResponse = await this.run({ operation: 'result', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
|
|
383
|
-
if (resultResponse.thread_id && resultResponse.thread_id !== threadId)
|
|
384
|
-
throw new Error('browser-worker returned a different thread_id');
|
|
385
|
-
this.validateOperation(resultResponse, 'result');
|
|
386
|
-
if (resultResponse.status !== 'completed' || resultResponse.result?.verified !== true || !resultResponse.result.message_id || !resultResponse.result.text?.trim())
|
|
387
|
-
throw browserWorkerError(resultResponse);
|
|
388
|
-
return { summary: resultResponse.result.text.trim(), data: { threadId, messageId: resultResponse.result.message_id, verified: true, provider: route.provider, model: route.model } };
|
|
389
237
|
}
|
|
390
|
-
|
|
391
|
-
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (controller.signal.aborted)
|
|
240
|
+
throw error;
|
|
241
|
+
throw new LaunchFailure(error instanceof Error ? error.message : String(error));
|
|
392
242
|
}
|
|
393
243
|
})();
|
|
394
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') {
|