agents-relay 1.0.4 → 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 +1 -1
- package/dist/adapters.js +80 -31
- package/dist/reconciler.js +6 -4
- package/package.json +1 -1
- 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
|
@@ -103,7 +103,7 @@ npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
|
|
|
103
103
|
--input "Research the dashboard UX and return implementation guidance."
|
|
104
104
|
~~~
|
|
105
105
|
|
|
106
|
-
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.
|
|
107
107
|
|
|
108
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.
|
|
109
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/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
|
@@ -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).
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# ChatGPT browser-worker contract
|
|
2
|
+
|
|
3
|
+
This document is the stable boundary between Neo orchestration and a
|
|
4
|
+
`browser-harness` adapter. It is intentionally independent of ChatGPT's DOM,
|
|
5
|
+
URL layout, or internal network calls.
|
|
6
|
+
|
|
7
|
+
## Requests
|
|
8
|
+
|
|
9
|
+
All requests contain an operation and no credentials:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"operation": "create",
|
|
14
|
+
"project": {"name": "neo", "id": "project-optional"},
|
|
15
|
+
"prompt": "Run the assigned task.",
|
|
16
|
+
"thinking_level": "high"
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`create` requires `project.name`, `prompt`, and `thinking_level`.
|
|
21
|
+
`resume` requires `thread_id` and `project`; its thinking level is optional and
|
|
22
|
+
defaults to the persisted request. `status`, `result`, and `delete` require
|
|
23
|
+
only `thread_id`.
|
|
24
|
+
|
|
25
|
+
Allowed requested thinking levels are `default`, `low`, `medium`, and `high`.
|
|
26
|
+
The adapter may expose a current UI label in an observation, but it must map it
|
|
27
|
+
to one of these values or `unknown` rather than guessing.
|
|
28
|
+
|
|
29
|
+
## Durable thread state
|
|
30
|
+
|
|
31
|
+
The state file is the only persisted worker identity. It may look like:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"schema_version": 1,
|
|
36
|
+
"thread_id": "chatgpt-conversation-id",
|
|
37
|
+
"conversation_url": "https://chatgpt.com/c/chatgpt-conversation-id",
|
|
38
|
+
"project": {"id": "project-id", "name": "neo"},
|
|
39
|
+
"status": "awaiting_result",
|
|
40
|
+
"requested_thinking_level": "high",
|
|
41
|
+
"effective_thinking_level": "high",
|
|
42
|
+
"created_at": "2026-09-19T12:00:00Z",
|
|
43
|
+
"updated_at": "2026-09-19T12:01:00Z",
|
|
44
|
+
"last_error": null
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Required fields are `schema_version`, `thread_id`, `project.name`, `status`,
|
|
49
|
+
`requested_thinking_level`, and `effective_thinking_level`. `project.id` and
|
|
50
|
+
`conversation_url` are optional because the UI may not expose them at every
|
|
51
|
+
boundary, but the adapter must preserve them when observed.
|
|
52
|
+
|
|
53
|
+
The only valid statuses are:
|
|
54
|
+
|
|
55
|
+
| Status | Meaning |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `created` | identity exists; no prompt has been sent yet |
|
|
58
|
+
| `running` | a prompt was sent and work is in progress |
|
|
59
|
+
| `awaiting_result` | the UI indicates a response may be read |
|
|
60
|
+
| `completed` | an assistant result was observed and normalized |
|
|
61
|
+
| `failed` | the operation failed; recovery may resume this identity |
|
|
62
|
+
| `blocked` | human/authentication/ambiguity decision is required |
|
|
63
|
+
| `deleted` | cleanup was verified; identity must not be reused |
|
|
64
|
+
|
|
65
|
+
Valid transitions are:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
create -> created -> running -> awaiting_result -> completed
|
|
69
|
+
| |
|
|
70
|
+
+-> failed +-> running (follow-up)
|
|
71
|
+
any live state -> blocked
|
|
72
|
+
any live state -> deleted (only after verified cleanup)
|
|
73
|
+
failed/blocked -> running (only after an explicit recovery operation)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`deleted` is terminal. A new conversation gets a new `thread_id`.
|
|
77
|
+
|
|
78
|
+
## Results
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"thread_id": "chatgpt-conversation-id",
|
|
83
|
+
"status": "completed",
|
|
84
|
+
"text": "The assistant's normalized final answer.",
|
|
85
|
+
"message_id": "observed-message-id",
|
|
86
|
+
"observed_at": "2026-09-19T12:02:00Z"
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`text` is required only for `completed`. Partial or streaming text is not a
|
|
91
|
+
completed result. A result with no observed assistant message is an error, not
|
|
92
|
+
success.
|
|
93
|
+
|
|
94
|
+
## Browser adapter port
|
|
95
|
+
|
|
96
|
+
The adapter is tested through a fake port with these semantic calls:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
select_project(project) -> observed_project
|
|
100
|
+
open_thread(thread_id) -> observed_thread
|
|
101
|
+
set_thinking_level(level) -> observation
|
|
102
|
+
send_prompt(prompt) -> observation
|
|
103
|
+
read_status() -> status_observation
|
|
104
|
+
read_result() -> result_observation
|
|
105
|
+
delete_thread() -> cleanup_observation
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`cleanup_observation` must contain the requested `thread_id` (or omit it only
|
|
109
|
+
when the browser has verified the thread is absent), `outcome` equal to
|
|
110
|
+
`deleted` or `not_found`, and `verified: true`. `not_found` is a successful
|
|
111
|
+
idempotent retry. Archive/undo controls may be reported as observational
|
|
112
|
+
metadata, but they do not turn a delete into a recoverable state: a deleted
|
|
113
|
+
tombstone remains terminal and must never be reused.
|
|
114
|
+
|
|
115
|
+
These names describe the boundary, not a required Python class or browser
|
|
116
|
+
selector implementation. Each call must return observed values or a typed
|
|
117
|
+
failure. The contract layer must remain usable with a fake port and must not
|
|
118
|
+
import or launch `browser-harness` itself.
|
|
119
|
+
|
|
120
|
+
## Testable safety boundaries
|
|
121
|
+
|
|
122
|
+
- no request can omit the Project on `create` or `resume`;
|
|
123
|
+
- Project IDs are compared when both browser boundaries expose them; a
|
|
124
|
+
name-only observation remains valid because the UI may hide opaque IDs;
|
|
125
|
+
- no state can omit a non-empty `thread_id` or valid status;
|
|
126
|
+
- `resume` rejects an observed Project mismatch;
|
|
127
|
+
- requested and effective thinking levels are distinct fields;
|
|
128
|
+
- unknown effective level stays `unknown`;
|
|
129
|
+
- only an observed assistant message yields `completed`;
|
|
130
|
+
- delete is terminal and cannot be followed by resume;
|
|
131
|
+
- browser, login, and ambiguity failures preserve the thread identity and are
|
|
132
|
+
classified as `failed` or `blocked`.
|