agents-relay 1.0.30 → 1.0.32

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.
@@ -1,298 +0,0 @@
1
- import atexit
2
- import json
3
- import os
4
- import re
5
- import time
6
-
7
- from browser_harness import *
8
-
9
-
10
- CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
11
- _OWNED_TABS = []
12
- _KEEP_OWNED_TAB_OPEN = CFG.get("close_policy", "after-start") == "never"
13
-
14
-
15
- def _new_owned_tab(url):
16
- target_id = cdp("Target.createTarget", url="about:blank", background=True)["targetId"]
17
- switch_tab(target_id)
18
- _OWNED_TABS.append(target_id)
19
- if url != "about:blank":
20
- goto_url(url)
21
- return target_id
22
-
23
-
24
- def _close_owned_tabs():
25
- if _KEEP_OWNED_TAB_OPEN:
26
- return
27
- while _OWNED_TABS:
28
- try:
29
- close_tab(_OWNED_TABS.pop())
30
- except Exception:
31
- pass
32
-
33
-
34
- atexit.register(_close_owned_tabs)
35
-
36
-
37
- def _composer():
38
- selector = js("""(() => {
39
- const preferred = document.querySelector('#prompt-textarea');
40
- if (preferred) {
41
- const r=preferred.getBoundingClientRect();
42
- if (r.width>0 && r.height>0) return '#prompt-textarea';
43
- }
44
- const fallback = [...document.querySelectorAll('textarea,[contenteditable="true"]')]
45
- .find(e => { const r=e.getBoundingClientRect(); return r.width>0 && r.height>0 && !e.disabled; });
46
- if (!fallback) return null;
47
- return fallback.tagName === 'TEXTAREA' ? 'textarea' : '[contenteditable="true"]';
48
- })()""")
49
- if not selector:
50
- raise RuntimeError("Temporary Chat composer was not observed")
51
- return selector
52
-
53
-
54
- def _composer_text(selector):
55
- return js(f"""(() => {{
56
- const e=document.querySelector({json.dumps(selector)});
57
- return e ? ((e.innerText ?? e.value) || '') : '';
58
- }})()""") or ""
59
-
60
-
61
- def _wait_for_composer(timeout=30):
62
- def read_selector():
63
- try:
64
- return _composer()
65
- except RuntimeError:
66
- return None
67
-
68
- return wait_until_stable(
69
- lambda: {"selector": read_selector()},
70
- lambda state: bool(state["selector"]),
71
- timeout=timeout,
72
- phase="composer readiness",
73
- )["selector"]
74
-
75
-
76
- def _same_text(observed, expected):
77
- normalize = lambda value: re.sub(r"\\s+", " ", value or "").strip()
78
- return normalize(expected) in normalize(observed)
79
-
80
-
81
- def _normalized_text(value):
82
- return re.sub(r"\s+", " ", value or "").strip()
83
-
84
-
85
- def _attachment_names():
86
- return js(r"""(() => [...document.querySelectorAll('button[aria-label^="Remove file"]')]
87
- .map(b => (b.getAttribute('aria-label') || '').replace(/^Remove file\s+\d+:\s*/, ''))
88
- .filter(Boolean))()""") or []
89
-
90
-
91
- def _attachment_state():
92
- return js(r"""(() => {
93
- const names = [...document.querySelectorAll('button[aria-label^="Remove file"]')]
94
- .map(b => (b.getAttribute('aria-label') || '').replace(/^Remove file\s+\d+:\s*/, ''))
95
- .filter(Boolean);
96
- const pending = [...document.querySelectorAll(
97
- '[role="progressbar"], [aria-busy="true"], [data-state="loading"]'
98
- )].some(e => {
99
- const r=e.getBoundingClientRect();
100
- return r.width > 0 && r.height > 0;
101
- });
102
- return {names, pending};
103
- })()""") or {"names": [], "pending": True}
104
-
105
-
106
- def _send_state():
107
- return js(r"""(() => {
108
- const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"]');
109
- return {present: !!b, enabled: !!b && !b.disabled && b.getAttribute('aria-disabled') !== 'true'};
110
- })()""") or {"present": False, "enabled": False}
111
-
112
-
113
- def _wait_for_attachments(expected, timeout=45):
114
- expected = set(expected)
115
- wait_until_stable(
116
- _attachment_state,
117
- lambda state: expected.issubset(state["names"]) and not state["pending"],
118
- timeout=timeout,
119
- phase="attachment upload readiness",
120
- )
121
-
122
-
123
- def _upload_files(paths):
124
- if not paths:
125
- return []
126
- selector = js("""(() => {
127
- for (const s of ['#upload-files','#upload-media','input[name="upload-media"]','input[type="file"]'])
128
- if (document.querySelector(s)) return s;
129
- return null;
130
- })()""")
131
- if not selector:
132
- raise RuntimeError("ChatGPT file input was not observed")
133
- for path in paths:
134
- if not os.path.isfile(path):
135
- raise RuntimeError(f"attachment does not exist: {path}")
136
- upload_file(selector, path)
137
- expected = [os.path.basename(path) for path in paths]
138
- if expected:
139
- # Presence is a selection signal; upload readiness is checked separately.
140
- wait_until_stable(
141
- _attachment_state,
142
- lambda state: set(expected).issubset(state["names"]),
143
- timeout=30,
144
- phase="attachment presence",
145
- )
146
- _wait_for_attachments(expected)
147
- return expected
148
-
149
-
150
- def _user_turns():
151
- return js("""(() => [...document.querySelectorAll('[data-message-author-role="user"]')]
152
- .map(e => ({text:e.innerText.trim(), id:e.getAttribute('data-message-id') || ''}))
153
- .filter(e => e.text))()""") or []
154
-
155
-
156
- def _click_send():
157
- ok = js("""(() => {
158
- const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"]');
159
- if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
160
- b.click(); return true;
161
- })()""")
162
- if not ok:
163
- raise RuntimeError("Temporary Chat send readiness changed before click")
164
-
165
-
166
- def _wait_for_send_ready(selector, prompt, attachments):
167
- expected = set(attachments)
168
-
169
- def read_state():
170
- return {
171
- "text": _composer_text(selector),
172
- "attachments": _attachment_state(),
173
- "send": _send_state(),
174
- }
175
-
176
- wait_until_stable(
177
- read_state,
178
- lambda state: (
179
- _normalized_text(prompt) in _normalized_text(state["text"])
180
- and expected.issubset(state["attachments"]["names"])
181
- and not state["attachments"]["pending"]
182
- and state["send"]["enabled"]
183
- ),
184
- timeout=45 if expected else 15,
185
- phase="send readiness",
186
- )
187
-
188
-
189
- def _wait_user_turn(before_count, prompt, timeout=20):
190
- deadline = time.time() + timeout
191
- while time.time() < deadline:
192
- turns = _user_turns()
193
- for turn in turns[before_count:]:
194
- if _normalized_text(prompt) in _normalized_text(turn["text"]):
195
- return turn
196
- time.sleep(.25)
197
- raise RuntimeError("Temporary Chat submission verification did not observe a new user turn")
198
-
199
-
200
- def _diagnostic_thread_id():
201
- match = re.search(r"/c/([^/?#]+)", page_info().get("url", ""))
202
- return match.group(1) if match else None
203
-
204
-
205
- _new_owned_tab("https://chatgpt.com/")
206
- wait_for_load()
207
-
208
- enabled = js("""(() => {
209
- const matches=[...document.querySelectorAll('button')].filter(
210
- b => (b.getAttribute('aria-label') || '').trim() === 'Temporary chat'
211
- );
212
- if (matches.length !== 1) return false;
213
- matches[0].click();
214
- return true;
215
- })()""")
216
- if not enabled:
217
- raise RuntimeError("Temporary Chat toggle was not uniquely observed")
218
-
219
- deadline = time.time() + 20
220
- while time.time() < deadline:
221
- if "temporary-chat=true" in page_info().get("url", ""):
222
- try:
223
- break
224
- except RuntimeError:
225
- pass
226
- time.sleep(.25)
227
- else:
228
- raise RuntimeError("Temporary Chat composer readiness was not observed")
229
-
230
- attachments = _upload_files(CFG.get("file", []))
231
- selector = _wait_for_composer()
232
- before_count = len(_user_turns())
233
- prompt = CFG["prompt"]
234
- is_contenteditable = bool(js(f"""(() => {{
235
- const e=document.querySelector({json.dumps(selector)});
236
- return !!e && e.getAttribute('contenteditable') === 'true';
237
- }})()"""))
238
- if is_contenteditable:
239
- cleared = js(f"""(() => {{
240
- const e=document.querySelector({json.dumps(selector)});
241
- if (!e) return false;
242
- e.focus();
243
- const sel=window.getSelection();
244
- const range=document.createRange();
245
- range.selectNodeContents(e);
246
- sel.removeAllRanges();
247
- sel.addRange(range);
248
- document.execCommand('delete', false, null);
249
- return true;
250
- }})()""")
251
- if not cleared:
252
- raise RuntimeError("Temporary Chat contenteditable composer could not be cleared")
253
- for offset in range(0, len(prompt), 256):
254
- chunk = prompt[offset:offset + 256]
255
- inserted = js(f"""(() => {{
256
- const e=document.querySelector({json.dumps(selector)});
257
- if (!e) return false;
258
- e.focus();
259
- const ok=document.execCommand('insertText', false, {json.dumps(chunk)});
260
- e.dispatchEvent(new InputEvent('input', {{bubbles:true,inputType:'insertText',data:{json.dumps(chunk)}}}));
261
- return ok;
262
- }})()""")
263
- if not inserted:
264
- raise RuntimeError(f"Temporary Chat composer rejected prompt chunk at offset {offset}")
265
- else:
266
- fill_input(selector, prompt, clear_first=True)
267
-
268
- wait_until_stable(
269
- lambda: {"text": _composer_text(selector)},
270
- lambda state: _normalized_text(CFG["prompt"]) in _normalized_text(state["text"]),
271
- timeout=30,
272
- phase="composer readiness",
273
- )
274
-
275
- _wait_for_send_ready(selector, CFG["prompt"], attachments)
276
- _click_send()
277
- user_turn = _wait_user_turn(before_count, CFG["prompt"])
278
-
279
- print(json.dumps({
280
- "operation": "submit",
281
- "status": "submitted",
282
- "temporary": True,
283
- "attachments": attachments,
284
- "diagnostic_thread_id": _diagnostic_thread_id(),
285
- "user_message_id": user_turn.get("id") or None,
286
- "verified": True,
287
- }, ensure_ascii=False), flush=True)
288
-
289
- # Keep the owned Temporary Chat tab alive until Relay observes the worker's
290
- # post-launch task.started acknowledgement. The parent releases this file;
291
- # atexit then closes only this worker-owned tab.
292
- release_file = CFG.get("release_file")
293
- if not release_file:
294
- raise RuntimeError("worker release file was not configured")
295
- if CFG.get("close_policy", "after-start") == "never":
296
- raise SystemExit(0)
297
- while not os.path.exists(release_file):
298
- time.sleep(.1)
@@ -1,36 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Submit one isolated Temporary Chat task through browser-harness."""
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import json
7
- import os
8
- import subprocess
9
- import tempfile
10
-
11
-
12
- BH_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_temporary_bh.py")
13
- READINESS_HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_submit_readiness.py")
14
-
15
-
16
- def main() -> int:
17
- parser = argparse.ArgumentParser()
18
- parser.add_argument("--prompt", required=True)
19
- parser.add_argument("--file", action="append", default=[])
20
- parser.add_argument("--release-file", required=True)
21
- parser.add_argument("--close-policy", choices=["after-start", "never", "after-terminal"], default="after-start")
22
- args = parser.parse_args()
23
- with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as handle:
24
- json.dump(vars(args), handle, ensure_ascii=False)
25
- config_path = handle.name
26
- try:
27
- helper = open(READINESS_HELPER, encoding="utf-8").read()
28
- code = helper + "\n" + open(BH_SCRIPT, encoding="utf-8").read()
29
- code = code.replace("__CFG_PATH__", config_path)
30
- return subprocess.run(["browser-harness"], input=code, text=True, timeout=180).returncode
31
- finally:
32
- os.unlink(config_path)
33
-
34
-
35
- if __name__ == "__main__":
36
- raise SystemExit(main())