agents-relay 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/adapters.js +80 -31
- package/dist/cli.js +89 -2
- package/dist/reconciler.js +6 -4
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +2 -0
- package/skills/chatgpt-browser-worker/SKILL.md +102 -0
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +113 -0
- package/skills/chatgpt-browser-worker/references/contract.md +132 -0
- package/skills/chatgpt-browser-worker/references/orchestration.md +101 -0
- package/skills/chatgpt-browser-worker/scripts/_create_bh.py +158 -0
- package/skills/chatgpt-browser-worker/scripts/_operate_bh.py +198 -0
- package/skills/chatgpt-browser-worker/scripts/contract.py +151 -0
- package/skills/chatgpt-browser-worker/scripts/create.py +84 -0
- package/skills/chatgpt-browser-worker/scripts/create_bh.py +34 -0
- package/skills/chatgpt-browser-worker/scripts/operate_bh.py +36 -0
- package/skills/chatgpt-browser-worker/scripts/operations.py +179 -0
- package/skills/chatgpt-browser-worker/tests/fixtures/relay_lifecycle.json +21 -0
- package/skills/chatgpt-browser-worker/tests/test_agent_definition.py +27 -0
- package/skills/chatgpt-browser-worker/tests/test_contract.py +315 -0
|
@@ -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`.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Neo/Leo orchestration integration
|
|
2
|
+
|
|
3
|
+
This skill is a worker capability, not an orchestrator or a browser session
|
|
4
|
+
manager. The `browser-worker` agent owns adaptive browser execution. Agents
|
|
5
|
+
Relay (or Leo's owning orchestrator) owns the task, correlation ID, retries,
|
|
6
|
+
events, and durable run location. The worker owns one ChatGPT thread state
|
|
7
|
+
record. `browser-harness` is the only layer that touches the authenticated
|
|
8
|
+
browser.
|
|
9
|
+
|
|
10
|
+
## Responsibility split
|
|
11
|
+
|
|
12
|
+
| Layer | Responsibility | Must not do |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| Agents Relay / Leo | Create the child task, pass the exact `job_id` and `task_id`, consume lifecycle events, and reconcile terminal state | Select DOM elements, infer a thread from a tab, or replace a deleted thread |
|
|
15
|
+
| Browser-worker agent | Load both skills, execute typed lifecycle intents, adapt to ordinary UI drift by re-observing, preserve `thread_id`/Project identity, and return verified observations | Bypass authentication, MFA, consent, ambiguity, or unverifiable results |
|
|
16
|
+
| This skill | Validate requests, preserve `thread_id` state, enforce Project and status transitions, and normalize observed results | Launch a second browser stack, call a ChatGPT API, or depend on MacBridge as a ChatGPT runtime |
|
|
17
|
+
| `browser-harness` adapter | Perform the observable browser actions and return semantic observations | Persist orchestration state, invent success from a process exit, or hide authentication/ambiguity failures |
|
|
18
|
+
|
|
19
|
+
MacBridge may be the transport used by an outer Leo deployment to start a
|
|
20
|
+
worker, but it is not part of this ChatGPT runtime path. A local worker can
|
|
21
|
+
invoke `browser-harness` directly. No MacBridge ChatGPT MCP tool, copied
|
|
22
|
+
cookie, or ChatGPT API is required.
|
|
23
|
+
|
|
24
|
+
## Task handoff
|
|
25
|
+
|
|
26
|
+
The parent creates one child task with the same `job_id` and a task-specific
|
|
27
|
+
`task_id` (nested work also receives `parent_task_id`). The task input contains
|
|
28
|
+
an operation request and a run-local state path; it does not contain browser
|
|
29
|
+
credentials or cookies:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"job_id": "neo-chatgpt-browser-worker-20260919",
|
|
34
|
+
"task_id": "chatgpt-thread-1",
|
|
35
|
+
"operation": "create",
|
|
36
|
+
"state_path": "runs/2026-09-19-chatgpt-thread/state.json",
|
|
37
|
+
"request": {
|
|
38
|
+
"project": {"name": "neo"},
|
|
39
|
+
"prompt": "Run the assigned task.",
|
|
40
|
+
"thinking_level": "high"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The exact literal IDs must be present in both the worker environment and its
|
|
46
|
+
prompt. Runtime state, prompts containing private data, logs, and browser
|
|
47
|
+
session details stay under the caller's ignored `runs/` directory.
|
|
48
|
+
|
|
49
|
+
## Lifecycle invocation
|
|
50
|
+
|
|
51
|
+
The orchestrator launches the `browser-worker` agent with one serial typed
|
|
52
|
+
operation at a time and persists the returned state before acknowledging
|
|
53
|
+
success. It must not directly invoke `create_bh.py` or `operate_bh.py`; those
|
|
54
|
+
are agent implementation helpers. The durable identity is always the
|
|
55
|
+
`thread_id` in `state.json`, never a URL, title, or tab index.
|
|
56
|
+
|
|
57
|
+
| Intent | Request | Browser-port calls | Success evidence |
|
|
58
|
+
| --- | --- | --- | --- |
|
|
59
|
+
| Start | `create` with `project`, `prompt`, `thinking_level` | `select_project` → optional `set_thinking_level` → `send_prompt` | observed non-empty `thread_id` and Project |
|
|
60
|
+
| Reattach | `resume` with `thread_id`, `project` | `open_thread` and Project verification | opened matching thread and Project |
|
|
61
|
+
| Fetch | `result` with the durable state | `read_result` | normalized assistant message with `message_id` |
|
|
62
|
+
| Clean up | `delete` with the durable state | exact-thread delete plus confirmation | verified `deleted` or idempotent `not_found` |
|
|
63
|
+
|
|
64
|
+
`status` is the optional polling operation between `resume` and `result`.
|
|
65
|
+
`continue` is a follow-up prompt operation and is not a substitute for
|
|
66
|
+
`resume`. A retry resumes the existing state; it never calls `create` for a
|
|
67
|
+
missing or failed browser action. A `deleted` tombstone is terminal.
|
|
68
|
+
|
|
69
|
+
The pure entry points are `create_thread`, `resume_thread`, `result_thread`,
|
|
70
|
+
and `delete_thread` in `scripts/create.py` and `scripts/operations.py`. The
|
|
71
|
+
`*_bh.py` scripts are thin helpers the agent may call or replace while running
|
|
72
|
+
one operation through `browser-harness`; they are not an alternate state store,
|
|
73
|
+
orchestrator, or caller-facing runtime.
|
|
74
|
+
|
|
75
|
+
## Events and terminal handling
|
|
76
|
+
|
|
77
|
+
The owning orchestrator emits `task.started`, meaningful milestones, and one
|
|
78
|
+
terminal task event using the shared events-bus contract. A worker should
|
|
79
|
+
report routine success as `task.completed` with `visibility: orchestrator`;
|
|
80
|
+
the parent then reconciles the state file and emits the user-visible result.
|
|
81
|
+
Failures involving login, MFA, consent, ambiguous Project selection, or an
|
|
82
|
+
unverified result are `task.failed` or `task.blocked`, not successful relay
|
|
83
|
+
completion. Event payloads contain IDs, statuses, and evidence summaries—not
|
|
84
|
+
credentials, cookies, or full private prompts.
|
|
85
|
+
|
|
86
|
+
## Minimal relay loop
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
watch(job_id)
|
|
90
|
+
launch child with job_id/task_id and operation=create
|
|
91
|
+
persist returned thread state
|
|
92
|
+
resume(thread_id, project) when reattaching
|
|
93
|
+
repeat status(thread_id) until the UI permits a result
|
|
94
|
+
result(thread_id) and validate the normalized assistant message
|
|
95
|
+
delete(thread_id) in the explicit cleanup phase
|
|
96
|
+
consume events until the parent task reaches one terminal state
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The browser adapter may fail after a thread has been created. In that case,
|
|
100
|
+
preserve the last known `thread_id` and state, classify the transition as
|
|
101
|
+
`failed` or `blocked`, and let the orchestrator decide when to retry `resume`.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from browser_harness import *
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ax_nodes():
|
|
12
|
+
return cdp("Accessibility.getFullAXTree").get("nodes", [])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _click_exact(name, roles=()):
|
|
16
|
+
matches = [node for node in _ax_nodes() if node.get("name", {}).get("value") == name and (not roles or node.get("role", {}).get("value") in roles)]
|
|
17
|
+
if len(matches) != 1:
|
|
18
|
+
raise RuntimeError(f"expected one accessible {name!r}, found {len(matches)}")
|
|
19
|
+
backend_id = matches[0].get("backendDOMNodeId")
|
|
20
|
+
box = cdp("DOM.getBoxModel", backendNodeId=backend_id).get("model", {}).get("content")
|
|
21
|
+
if not box:
|
|
22
|
+
raise RuntimeError(f"accessible {name!r} has no visible box")
|
|
23
|
+
click_at_xy(sum(box[0::2]) / 4, sum(box[1::2]) / 4)
|
|
24
|
+
return {"name": name, "role": matches[0].get("role", {}).get("value")}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _click_if_present(name, roles=()):
|
|
28
|
+
matches = [node for node in _ax_nodes() if node.get("name", {}).get("value") == name and (not roles or node.get("role", {}).get("value") in roles)]
|
|
29
|
+
if len(matches) > 1:
|
|
30
|
+
raise RuntimeError(f"ambiguous accessible {name!r}: found {len(matches)}")
|
|
31
|
+
if matches:
|
|
32
|
+
return _click_exact(name, roles)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _find_composer():
|
|
37
|
+
selector = js("""(() => {
|
|
38
|
+
const candidates = [...document.querySelectorAll('textarea,[contenteditable="true"]')];
|
|
39
|
+
const node = candidates.find(e => {
|
|
40
|
+
const r = e.getBoundingClientRect();
|
|
41
|
+
return r.width > 0 && r.height > 0 && !e.disabled;
|
|
42
|
+
});
|
|
43
|
+
if (!node) return null;
|
|
44
|
+
if (node.id) return '#' + CSS.escape(node.id);
|
|
45
|
+
node.setAttribute('data-agents-relay-composer', 'true');
|
|
46
|
+
return '[data-agents-relay-composer="true"]';
|
|
47
|
+
})()""")
|
|
48
|
+
if not selector:
|
|
49
|
+
raise RuntimeError("ChatGPT composer was not observed")
|
|
50
|
+
return selector
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _thread_id(existing_hrefs, timeout=20.0):
|
|
54
|
+
deadline = time.time() + timeout
|
|
55
|
+
existing_ids = set()
|
|
56
|
+
for href in existing_hrefs or []:
|
|
57
|
+
match = re.search(r"/c/([^/?#]+)", href or "")
|
|
58
|
+
if match:
|
|
59
|
+
existing_ids.add(match.group(1))
|
|
60
|
+
while time.time() < deadline:
|
|
61
|
+
match = re.search(r"/c/([^/?#]+)", page_info().get("url", ""))
|
|
62
|
+
if match:
|
|
63
|
+
return match.group(1)
|
|
64
|
+
links = js("""(() => [...document.querySelectorAll('a[href*="/g/"][href*="/c/"]')].map(a => ({href:a.href,text:(a.textContent||'').trim()})))()""") or []
|
|
65
|
+
prompt_hint = (CFG.get("prompt") or "").strip()[:60]
|
|
66
|
+
matching = []
|
|
67
|
+
for link in links:
|
|
68
|
+
href = link.get("href") or ""
|
|
69
|
+
link_match = re.search(r"/c/([^/?#]+)", href)
|
|
70
|
+
if link_match and prompt_hint and prompt_hint in (link.get("text") or ""):
|
|
71
|
+
matching.append(link_match.group(1))
|
|
72
|
+
unique_matching = list(dict.fromkeys(matching))
|
|
73
|
+
if len(unique_matching) == 1:
|
|
74
|
+
return unique_matching[0]
|
|
75
|
+
if len(unique_matching) > 1:
|
|
76
|
+
new_matching = [value for value in unique_matching if value not in existing_ids]
|
|
77
|
+
if len(new_matching) == 1:
|
|
78
|
+
return new_matching[0]
|
|
79
|
+
raise RuntimeError("multiple prompt-matching durable thread ids were observed after submission")
|
|
80
|
+
time.sleep(0.25)
|
|
81
|
+
raise RuntimeError("ChatGPT did not expose a new durable thread id before timeout")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
ensure_real_tab()
|
|
85
|
+
if "chatgpt.com" not in page_info().get("url", ""):
|
|
86
|
+
new_tab("https://chatgpt.com/")
|
|
87
|
+
wait_for_load()
|
|
88
|
+
project = CFG["project"]
|
|
89
|
+
selected = {"name": project["name"], "action": "default_chat"}
|
|
90
|
+
if project["name"].casefold() not in ("unspecified", "default"):
|
|
91
|
+
# Project rows expose a generic "Open project home" action, so bind that
|
|
92
|
+
# action to the visible row whose marquee text exactly matches the requested
|
|
93
|
+
# Project. This avoids relying on the project label itself being clickable.
|
|
94
|
+
opened_project = js("""(() => {
|
|
95
|
+
const wanted = %s;
|
|
96
|
+
const labels = [...document.querySelectorAll('[data-marquee-text]')].filter(e => (e.textContent || '').trim() === wanted);
|
|
97
|
+
for (const label of labels) {
|
|
98
|
+
const button = label.closest('li')?.querySelector('button[aria-label="Open project home"]');
|
|
99
|
+
if (!button) continue;
|
|
100
|
+
const r = button.getBoundingClientRect();
|
|
101
|
+
if (r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < innerHeight) { button.click(); return true; }
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
})()""" % json.dumps(project["name"]))
|
|
105
|
+
if not opened_project:
|
|
106
|
+
raise RuntimeError(f"requested Project {project['name']!r} has no visible project-home action")
|
|
107
|
+
deadline = time.time() + 8
|
|
108
|
+
visible = ""
|
|
109
|
+
while time.time() < deadline:
|
|
110
|
+
info = page_info()
|
|
111
|
+
visible = js("document.body.innerText") or ""
|
|
112
|
+
if project["name"].casefold() in visible.casefold() and project["name"].casefold() in info.get("title", "").casefold() and "/project" in info.get("url", ""):
|
|
113
|
+
break
|
|
114
|
+
time.sleep(0.25)
|
|
115
|
+
else:
|
|
116
|
+
raise RuntimeError("requested Project home was not observed after navigation")
|
|
117
|
+
selected = {"name": project["name"], "action": "Open project home"}
|
|
118
|
+
|
|
119
|
+
thinking = {"requested": CFG["thinking_level"], "effective_thinking_level": "unknown", "changed": False}
|
|
120
|
+
if CFG["thinking_level"] != "default":
|
|
121
|
+
label = {"low": "Low", "medium": "Medium", "high": "High"}[CFG["thinking_level"]]
|
|
122
|
+
level_buttons = []
|
|
123
|
+
deadline = time.time() + 5
|
|
124
|
+
while time.time() < deadline and len(level_buttons) != 1:
|
|
125
|
+
level_buttons = [node for node in _ax_nodes() if node.get("role", {}).get("value") == "button" and node.get("name", {}).get("value") in ("Low", "Medium", "High")]
|
|
126
|
+
if len(level_buttons) != 1:
|
|
127
|
+
time.sleep(0.25)
|
|
128
|
+
if len(level_buttons) == 1:
|
|
129
|
+
current = level_buttons[0].get("name", {}).get("value")
|
|
130
|
+
if current != label:
|
|
131
|
+
_click_exact(current, roles=("button",))
|
|
132
|
+
time.sleep(0.25)
|
|
133
|
+
try:
|
|
134
|
+
_click_exact(label, roles=("button", "menuitem", "option"))
|
|
135
|
+
thinking = {"requested": CFG["thinking_level"], "effective_thinking_level": CFG["thinking_level"], "changed": True}
|
|
136
|
+
except RuntimeError:
|
|
137
|
+
thinking = {"requested": CFG["thinking_level"], "effective_thinking_level": "unknown", "changed": False}
|
|
138
|
+
else:
|
|
139
|
+
thinking = {"requested": CFG["thinking_level"], "effective_thinking_level": CFG["thinking_level"], "changed": False}
|
|
140
|
+
|
|
141
|
+
existing_hrefs = js("""(() => [...document.querySelectorAll('a[href*="/g/"][href*="/c/"]')].map(a => a.href))()""") or []
|
|
142
|
+
composer = _find_composer()
|
|
143
|
+
fill_input(composer, CFG["prompt"], clear_first=True)
|
|
144
|
+
deadline = time.time() + 5
|
|
145
|
+
sent = False
|
|
146
|
+
while time.time() < deadline:
|
|
147
|
+
sent = bool(js("""(() => { const button = document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"]'); if (!button) return false; const r=button.getBoundingClientRect(); if (!(r.width > 0 && r.height > 0)) return false; button.click(); return true; })()"""))
|
|
148
|
+
if sent: break
|
|
149
|
+
time.sleep(0.1)
|
|
150
|
+
if not sent: raise RuntimeError("ChatGPT Send prompt button was not observed after filling composer")
|
|
151
|
+
thread_id = _thread_id(existing_hrefs)
|
|
152
|
+
print(json.dumps({
|
|
153
|
+
"thread_id": thread_id,
|
|
154
|
+
"conversation_url": page_info().get("url"),
|
|
155
|
+
"selected_project": selected,
|
|
156
|
+
"thinking": thinking,
|
|
157
|
+
"prompt_sent": True,
|
|
158
|
+
}, ensure_ascii=False))
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from browser_harness import *
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
|
|
8
|
+
THREAD_ID = CFG["thread_id"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ax_nodes():
|
|
12
|
+
return cdp("Accessibility.getFullAXTree").get("nodes", [])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _composer():
|
|
16
|
+
selector = js("""(() => {
|
|
17
|
+
const nodes = [...document.querySelectorAll('textarea,[contenteditable="true"]')];
|
|
18
|
+
const node = nodes.find(e => { const r = e.getBoundingClientRect(); return r.width && r.height && !e.disabled; });
|
|
19
|
+
if (!node) return null;
|
|
20
|
+
if (node.id) return '#' + CSS.escape(node.id);
|
|
21
|
+
if (node.getAttribute('aria-label')) return `${node.tagName.toLowerCase()}[aria-label=${JSON.stringify(node.getAttribute('aria-label'))}]`;
|
|
22
|
+
return node.tagName === 'TEXTAREA' ? 'textarea' : 'div[contenteditable="true"]';
|
|
23
|
+
})()""")
|
|
24
|
+
if not selector:
|
|
25
|
+
raise RuntimeError("ChatGPT composer was not observed")
|
|
26
|
+
return selector
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _assistant_messages():
|
|
30
|
+
return js("""(() => [...document.querySelectorAll('[data-message-author-role="assistant"]')]
|
|
31
|
+
.map(e => ({text: e.innerText.trim(), id: e.getAttribute('data-message-id') || ''}))
|
|
32
|
+
.filter(e => e.text))()""") or []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_generating():
|
|
36
|
+
return bool(js('!!document.querySelector(\'button[aria-label*="Stop" i],button[data-testid*="stop" i]\')'))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _click_accessible(name, prefer_rightmost=False):
|
|
40
|
+
visible = []
|
|
41
|
+
info = page_info()
|
|
42
|
+
for node in _ax_nodes():
|
|
43
|
+
if node.get("name", {}).get("value") != name:
|
|
44
|
+
continue
|
|
45
|
+
backend_id = node.get("backendDOMNodeId")
|
|
46
|
+
if not backend_id:
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
box = cdp("DOM.getBoxModel", backendNodeId=backend_id).get("model", {}).get("content")
|
|
50
|
+
except Exception:
|
|
51
|
+
continue
|
|
52
|
+
if not box:
|
|
53
|
+
continue
|
|
54
|
+
x, y = sum(box[0::2]) / 4, sum(box[1::2]) / 4
|
|
55
|
+
if 0 <= x <= info.get("w", 0) and 0 <= y <= info.get("h", 0):
|
|
56
|
+
visible.append((x, y))
|
|
57
|
+
if not visible:
|
|
58
|
+
raise RuntimeError(f"expected a visible accessible {name!r}, found 0")
|
|
59
|
+
if len(visible) > 1 and not prefer_rightmost:
|
|
60
|
+
raise RuntimeError(f"expected one visible accessible {name!r}, found {len(visible)}")
|
|
61
|
+
click_at_xy(*(max(visible, key=lambda point: point[0]) if prefer_rightmost else visible[0]))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stable_assistant_result(delay=0.75):
|
|
65
|
+
if _is_generating():
|
|
66
|
+
return None
|
|
67
|
+
first = _assistant_messages()
|
|
68
|
+
if not first:
|
|
69
|
+
return None
|
|
70
|
+
candidate = first[-1]
|
|
71
|
+
time.sleep(delay)
|
|
72
|
+
if _is_generating():
|
|
73
|
+
return None
|
|
74
|
+
second = _assistant_messages()
|
|
75
|
+
if not second:
|
|
76
|
+
return None
|
|
77
|
+
latest = second[-1]
|
|
78
|
+
if latest.get("id") != candidate.get("id") or latest.get("text") != candidate.get("text"):
|
|
79
|
+
return None
|
|
80
|
+
return latest
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wait_stable_assistant_result(timeout=15.0):
|
|
85
|
+
deadline = time.time() + timeout
|
|
86
|
+
while time.time() < deadline:
|
|
87
|
+
message = _stable_assistant_result(delay=0.5)
|
|
88
|
+
if message:
|
|
89
|
+
return message
|
|
90
|
+
time.sleep(0.25)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _wait_for_requested_thread(timeout=12.0):
|
|
95
|
+
deadline = time.time() + timeout
|
|
96
|
+
last_url = ""
|
|
97
|
+
while time.time() < deadline:
|
|
98
|
+
info = page_info()
|
|
99
|
+
last_url = info.get("url", "")
|
|
100
|
+
body = js("document.body.innerText") or ""
|
|
101
|
+
title = info.get("title", "")
|
|
102
|
+
if f"/c/{THREAD_ID}" in last_url and (CFG["project"].casefold() in body.casefold() or CFG["project"].casefold() in title.casefold()):
|
|
103
|
+
return body
|
|
104
|
+
time.sleep(0.25)
|
|
105
|
+
raise RuntimeError(f"requested Project/thread was not observed after navigation: {last_url}")
|
|
106
|
+
|
|
107
|
+
def _cleanup():
|
|
108
|
+
"""Delete exactly the requested durable thread and verify disappearance."""
|
|
109
|
+
url = page_info().get("url", "")
|
|
110
|
+
if f"/c/{THREAD_ID}" not in url:
|
|
111
|
+
raise RuntimeError("browser is not on the requested durable thread")
|
|
112
|
+
body = js("document.body.innerText") or ""
|
|
113
|
+
if CFG["project"].casefold() not in body.casefold() and CFG["project"].casefold() not in page_info().get("title", "").casefold():
|
|
114
|
+
lower_body = body.lower()
|
|
115
|
+
missing_markers = ("conversation not found", "conversation doesn't exist", "conversation does not exist", "page not found")
|
|
116
|
+
if any(marker in lower_body for marker in missing_markers):
|
|
117
|
+
return {"thread_id": THREAD_ID, "outcome": "not_found", "verified": True, "url_after": url, "reason": "thread was not observable"}
|
|
118
|
+
raise RuntimeError("requested thread could not be verified; refusing cleanup")
|
|
119
|
+
|
|
120
|
+
opened = js("""(() => {
|
|
121
|
+
const buttons = [...document.querySelectorAll('button[aria-label="More"]')].filter(button => {
|
|
122
|
+
const r = button.getBoundingClientRect();
|
|
123
|
+
return r.width > 0 && r.height > 0 && r.top >= 0 && r.bottom <= innerHeight;
|
|
124
|
+
});
|
|
125
|
+
if (buttons.length !== 1) return false;
|
|
126
|
+
buttons[0].click();
|
|
127
|
+
return true;
|
|
128
|
+
})()""")
|
|
129
|
+
if not opened:
|
|
130
|
+
raise RuntimeError("thread actions menu was not uniquely observed")
|
|
131
|
+
time.sleep(0.2)
|
|
132
|
+
selected = js("""(() => {
|
|
133
|
+
const items = [...document.querySelectorAll('[role="menuitem"]')].filter(item => (item.textContent || '').trim() === 'Delete');
|
|
134
|
+
if (items.length !== 1) return false;
|
|
135
|
+
items[0].click();
|
|
136
|
+
return true;
|
|
137
|
+
})()""")
|
|
138
|
+
if not selected:
|
|
139
|
+
raise RuntimeError("ChatGPT delete action was not uniquely observed")
|
|
140
|
+
|
|
141
|
+
deadline = time.time() + 5
|
|
142
|
+
confirmed = False
|
|
143
|
+
while time.time() < deadline:
|
|
144
|
+
confirmed = bool(js("""(() => {
|
|
145
|
+
const dialogs = [...document.querySelectorAll('[role="dialog"]')].filter(dialog => (dialog.textContent || '').includes('Delete chat?'));
|
|
146
|
+
if (dialogs.length !== 1) return false;
|
|
147
|
+
const buttons = [...dialogs[0].querySelectorAll('button')].filter(button => (button.textContent || '').trim() === 'Delete');
|
|
148
|
+
if (buttons.length !== 1) return false;
|
|
149
|
+
buttons[0].click();
|
|
150
|
+
return true;
|
|
151
|
+
})()"""))
|
|
152
|
+
if confirmed:
|
|
153
|
+
break
|
|
154
|
+
time.sleep(0.2)
|
|
155
|
+
if not confirmed:
|
|
156
|
+
raise RuntimeError("ChatGPT delete confirmation was not observed")
|
|
157
|
+
|
|
158
|
+
deadline = time.time() + 8
|
|
159
|
+
while time.time() < deadline:
|
|
160
|
+
after_url = page_info().get("url", "")
|
|
161
|
+
after_body = js("document.body.innerText") or ""
|
|
162
|
+
if f"/c/{THREAD_ID}" not in after_url or "Undo" in after_body:
|
|
163
|
+
return {"thread_id": THREAD_ID, "outcome": "deleted", "verified": True, "url_after": after_url, "undo_visible": "Undo" in after_body, "archive_visible": "Archive" in after_body}
|
|
164
|
+
time.sleep(0.25)
|
|
165
|
+
raise RuntimeError("requested thread is still observable after delete")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
ensure_real_tab()
|
|
169
|
+
target = f"https://chatgpt.com/c/{THREAD_ID}"
|
|
170
|
+
if f"/c/{THREAD_ID}" not in page_info().get("url", ""):
|
|
171
|
+
new_tab(target)
|
|
172
|
+
wait_for_load()
|
|
173
|
+
body = _wait_for_requested_thread()
|
|
174
|
+
operation = CFG["operation"]
|
|
175
|
+
if operation == "delete":
|
|
176
|
+
print(json.dumps(_cleanup(), ensure_ascii=False))
|
|
177
|
+
raise SystemExit
|
|
178
|
+
|
|
179
|
+
base = {"thread_id": THREAD_ID, "conversation_url": page_info().get("url"), "project": {"name": CFG["project"]}}
|
|
180
|
+
if operation == "continue":
|
|
181
|
+
fill_input(_composer(), CFG["prompt"], clear_first=True)
|
|
182
|
+
time.sleep(0.1)
|
|
183
|
+
_click_accessible("Send prompt")
|
|
184
|
+
print(json.dumps({**base, "prompt_sent": True}, ensure_ascii=False))
|
|
185
|
+
elif operation == "status":
|
|
186
|
+
messages = _assistant_messages()
|
|
187
|
+
stable = _stable_assistant_result()
|
|
188
|
+
status = "completed" if stable else ("running" if _is_generating() or messages else "awaiting_result")
|
|
189
|
+
print(json.dumps({**base, "status": status, "assistant_message_count": len(messages)}, ensure_ascii=False))
|
|
190
|
+
elif operation == "result":
|
|
191
|
+
message = _wait_stable_assistant_result()
|
|
192
|
+
if not message:
|
|
193
|
+
raise RuntimeError("ChatGPT assistant result was not observed stable and final before timeout")
|
|
194
|
+
if not message["id"]:
|
|
195
|
+
raise RuntimeError("assistant result had no observed message id")
|
|
196
|
+
print(json.dumps({**base, "status": "completed", "text": message["text"], "message_id": message["id"]}, ensure_ascii=False))
|
|
197
|
+
else:
|
|
198
|
+
print(json.dumps({**base, "status": "completed" if _assistant_messages() else "awaiting_result"}, ensure_ascii=False))
|