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.
@@ -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