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
|
@@ -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))
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Pure contract helpers for a ChatGPT browser-worker adapter.
|
|
2
|
+
|
|
3
|
+
This module deliberately has no browser, network, or ChatGPT API dependency.
|
|
4
|
+
An adapter built on browser-harness can use these helpers at its boundary and
|
|
5
|
+
unit-test them with ordinary dictionaries.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
THINKING_LEVELS = frozenset({"default", "low", "medium", "high"})
|
|
13
|
+
EFFECTIVE_LEVELS = THINKING_LEVELS | {"unknown"}
|
|
14
|
+
STATUSES = frozenset(
|
|
15
|
+
{"created", "running", "awaiting_result", "completed", "failed", "blocked", "deleted"}
|
|
16
|
+
)
|
|
17
|
+
OPERATIONS = frozenset({"create", "resume", "status", "result", "delete"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContractError(ValueError):
|
|
21
|
+
"""Raised when an input crosses the worker boundary incorrectly."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _text(value: Any, field: str) -> str:
|
|
25
|
+
if not isinstance(value, str) or not value.strip():
|
|
26
|
+
raise ContractError(f"{field} must be a non-empty string")
|
|
27
|
+
return value.strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_project(project: Any) -> dict[str, str]:
|
|
31
|
+
if not isinstance(project, dict):
|
|
32
|
+
raise ContractError("project must be an object")
|
|
33
|
+
result = {"name": _text(project.get("name"), "project.name")}
|
|
34
|
+
if project.get("id") is not None:
|
|
35
|
+
result["id"] = _text(project["id"], "project.id")
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_request(request: Any) -> dict[str, Any]:
|
|
40
|
+
if not isinstance(request, dict):
|
|
41
|
+
raise ContractError("request must be an object")
|
|
42
|
+
operation = _text(request.get("operation"), "operation")
|
|
43
|
+
if operation not in OPERATIONS:
|
|
44
|
+
raise ContractError(f"unsupported operation: {operation}")
|
|
45
|
+
result: dict[str, Any] = {"operation": operation}
|
|
46
|
+
if operation in {"create", "resume"}:
|
|
47
|
+
result["project"] = validate_project(request.get("project"))
|
|
48
|
+
if operation == "create":
|
|
49
|
+
result["prompt"] = _text(request.get("prompt"), "prompt")
|
|
50
|
+
level = _text(request.get("thinking_level"), "thinking_level")
|
|
51
|
+
if level not in THINKING_LEVELS:
|
|
52
|
+
raise ContractError(f"unsupported thinking_level: {level}")
|
|
53
|
+
result["thinking_level"] = level
|
|
54
|
+
elif operation == "resume" and request.get("thinking_level") is not None:
|
|
55
|
+
level = _text(request["thinking_level"], "thinking_level")
|
|
56
|
+
if level not in THINKING_LEVELS:
|
|
57
|
+
raise ContractError(f"unsupported thinking_level: {level}")
|
|
58
|
+
result["thinking_level"] = level
|
|
59
|
+
if operation != "create":
|
|
60
|
+
result["thread_id"] = _text(request.get("thread_id"), "thread_id")
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_state(state: Any) -> dict[str, Any]:
|
|
65
|
+
if not isinstance(state, dict):
|
|
66
|
+
raise ContractError("state must be an object")
|
|
67
|
+
if state.get("schema_version") != 1:
|
|
68
|
+
raise ContractError("schema_version must be 1")
|
|
69
|
+
result = deepcopy(state)
|
|
70
|
+
result["thread_id"] = _text(state.get("thread_id"), "thread_id")
|
|
71
|
+
result["project"] = validate_project(state.get("project"))
|
|
72
|
+
status = _text(state.get("status"), "status")
|
|
73
|
+
if status not in STATUSES:
|
|
74
|
+
raise ContractError(f"unsupported status: {status}")
|
|
75
|
+
result["status"] = status
|
|
76
|
+
requested = _text(state.get("requested_thinking_level"), "requested_thinking_level")
|
|
77
|
+
if requested not in THINKING_LEVELS:
|
|
78
|
+
raise ContractError(f"unsupported requested_thinking_level: {requested}")
|
|
79
|
+
result["requested_thinking_level"] = requested
|
|
80
|
+
effective = _text(state.get("effective_thinking_level"), "effective_thinking_level")
|
|
81
|
+
if effective not in EFFECTIVE_LEVELS:
|
|
82
|
+
raise ContractError(f"unsupported effective_thinking_level: {effective}")
|
|
83
|
+
result["effective_thinking_level"] = effective
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_result(result: Any, *, thread_id: str | None = None) -> dict[str, Any]:
|
|
88
|
+
"""Accept only a normalized, observed assistant result."""
|
|
89
|
+
if not isinstance(result, dict):
|
|
90
|
+
raise ContractError("result must be an object")
|
|
91
|
+
result_thread_id = _text(result.get("thread_id"), "result.thread_id")
|
|
92
|
+
if thread_id is not None and result_thread_id != _text(thread_id, "thread_id"):
|
|
93
|
+
raise ContractError("result belongs to a different thread")
|
|
94
|
+
if result.get("status") != "completed":
|
|
95
|
+
raise ContractError("only a completed observed result is reportable")
|
|
96
|
+
normalized = deepcopy(result)
|
|
97
|
+
normalized["thread_id"] = result_thread_id
|
|
98
|
+
normalized["text"] = _text(result.get("text"), "result.text")
|
|
99
|
+
normalized["message_id"] = _text(result.get("message_id"), "result.message_id")
|
|
100
|
+
if result.get("observed_at") is not None:
|
|
101
|
+
normalized["observed_at"] = _text(result["observed_at"], "result.observed_at")
|
|
102
|
+
return normalized
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_cleanup_observation(observation: Any, *, thread_id: str) -> dict[str, Any]:
|
|
106
|
+
"""Accept browser evidence that exactly one requested thread is gone."""
|
|
107
|
+
if not isinstance(observation, dict):
|
|
108
|
+
raise ContractError("cleanup observation must be an object")
|
|
109
|
+
expected_id = _text(thread_id, "thread_id")
|
|
110
|
+
observed_id = observation.get("thread_id")
|
|
111
|
+
if observed_id is not None and _text(observed_id, "cleanup.thread_id") != expected_id:
|
|
112
|
+
raise ContractError("cleanup observation belongs to a different thread")
|
|
113
|
+
outcome = _text(observation.get("outcome"), "cleanup.outcome")
|
|
114
|
+
if outcome not in {"deleted", "not_found"}:
|
|
115
|
+
raise ContractError("cleanup was not verified")
|
|
116
|
+
if observation.get("verified") is not True:
|
|
117
|
+
raise ContractError("cleanup observation is not verified")
|
|
118
|
+
return deepcopy(observation)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def project_matches(expected: Any, observed: Any) -> bool:
|
|
122
|
+
expected_project = validate_project(expected)
|
|
123
|
+
observed_project = validate_project(observed)
|
|
124
|
+
if expected_project["name"] != observed_project["name"]:
|
|
125
|
+
return False
|
|
126
|
+
# A browser boundary may expose only the visible Project name. Compare
|
|
127
|
+
# opaque IDs only when both sides actually observed one.
|
|
128
|
+
return not (expected_project.get("id") and observed_project.get("id")) or expected_project["id"] == observed_project["id"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def transition(state: Any, new_status: str, *, observed_project: Any | None = None) -> dict[str, Any]:
|
|
132
|
+
current = validate_state(state)
|
|
133
|
+
new_status = _text(new_status, "status")
|
|
134
|
+
if new_status not in STATUSES:
|
|
135
|
+
raise ContractError(f"unsupported status: {new_status}")
|
|
136
|
+
if current["status"] == "deleted":
|
|
137
|
+
raise ContractError("deleted thread is terminal")
|
|
138
|
+
if observed_project is not None and not project_matches(current["project"], observed_project):
|
|
139
|
+
raise ContractError("observed Project does not match durable Project binding")
|
|
140
|
+
allowed = {
|
|
141
|
+
"created": {"running", "blocked", "failed", "deleted"},
|
|
142
|
+
"running": {"awaiting_result", "completed", "blocked", "failed", "deleted"},
|
|
143
|
+
"awaiting_result": {"completed", "running", "blocked", "failed", "deleted"},
|
|
144
|
+
"completed": {"running", "blocked", "deleted"},
|
|
145
|
+
"failed": {"running", "blocked", "deleted"},
|
|
146
|
+
"blocked": {"running", "blocked", "failed", "deleted"},
|
|
147
|
+
}
|
|
148
|
+
if new_status not in allowed[current["status"]]:
|
|
149
|
+
raise ContractError(f"invalid transition: {current['status']} -> {new_status}")
|
|
150
|
+
current["status"] = new_status
|
|
151
|
+
return current
|