agents-relay 1.0.22 → 1.0.24
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/dist/cli.js +7 -2
- package/dist/planner.js +26 -3
- package/dist/reconciler.js +54 -2
- package/dist/scheduler.js +1 -1
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +2 -0
- package/skills/agents-relay/agents/planner.agent.md +1 -0
- package/skills/chatgpt-browser-worker/scripts/_submit_readiness.py +30 -0
- package/skills/chatgpt-browser-worker/scripts/_temporary_bh.py +128 -24
- package/skills/chatgpt-browser-worker/scripts/temporary_bh.py +4 -1
package/dist/cli.js
CHANGED
|
@@ -572,8 +572,8 @@ async function main() {
|
|
|
572
572
|
task.id = arg(args, '--task-id', randomUUID());
|
|
573
573
|
await store.appendTask(task);
|
|
574
574
|
job.tasks.push(task);
|
|
575
|
-
if (job.state
|
|
576
|
-
job.state = '
|
|
575
|
+
if (job.state !== 'RUNNING') {
|
|
576
|
+
job.state = 'RUNNING';
|
|
577
577
|
job.updatedAt = now;
|
|
578
578
|
await store.saveJob(job);
|
|
579
579
|
}
|
|
@@ -597,6 +597,11 @@ async function main() {
|
|
|
597
597
|
task.error = command === 'retry' ? null : 'Cancelled by user';
|
|
598
598
|
task.updatedAt = new Date().toISOString();
|
|
599
599
|
await store.saveTask(task);
|
|
600
|
+
if (command === 'retry' && job.state !== 'RUNNING') {
|
|
601
|
+
job.state = 'RUNNING';
|
|
602
|
+
job.updatedAt = task.updatedAt;
|
|
603
|
+
await store.saveJob(job);
|
|
604
|
+
}
|
|
600
605
|
await saveLocal(localFile, job);
|
|
601
606
|
await publishWake(bus, job, task.id, `${task.id} ${command} requested`);
|
|
602
607
|
console.log(`${id}: ${task.state}`);
|
package/dist/planner.js
CHANGED
|
@@ -21,6 +21,30 @@ function stringArray(value, field, fallback = []) {
|
|
|
21
21
|
throw new Error(`Planner ${field} must be an array of non-empty strings`);
|
|
22
22
|
return [...value];
|
|
23
23
|
}
|
|
24
|
+
function rawValue(value) {
|
|
25
|
+
if (typeof value === 'string')
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
return 'undefined';
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(value) ?? String(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return String(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function normalizePlannerPriority(value, field) {
|
|
37
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 3)
|
|
38
|
+
return `P${value}`;
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const normalized = value.trim();
|
|
41
|
+
if (/^P[0-3]$/i.test(normalized))
|
|
42
|
+
return normalized.toUpperCase();
|
|
43
|
+
if (/^[0-3]$/.test(normalized))
|
|
44
|
+
return `P${normalized}`;
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`Planner ${field} is invalid: ${rawValue(value)}`);
|
|
47
|
+
}
|
|
24
48
|
function routing(value, field) {
|
|
25
49
|
if (value === undefined || value === null)
|
|
26
50
|
return undefined;
|
|
@@ -58,8 +82,7 @@ export function parsePlannerResult(value) {
|
|
|
58
82
|
throw new Error(`Planner next_tasks[${index}].output is required`);
|
|
59
83
|
if (output.kind === 'file' && (typeof output.path !== 'string' || output.path.length === 0))
|
|
60
84
|
throw new Error(`Planner next_tasks[${index}].output.path is required for file output`);
|
|
61
|
-
|
|
62
|
-
throw new Error(`Planner next_tasks[${index}].priority is invalid`);
|
|
85
|
+
const priority = task.priority === undefined ? undefined : normalizePlannerPriority(task.priority, `next_tasks[${index}].priority`);
|
|
63
86
|
const maxAttempts = task.maxAttempts === undefined ? 3 : task.maxAttempts;
|
|
64
87
|
const timeoutMs = task.timeoutMs === undefined ? 300000 : task.timeoutMs;
|
|
65
88
|
if (typeof maxAttempts !== 'number' || !Number.isInteger(maxAttempts) || maxAttempts < 1)
|
|
@@ -67,7 +90,7 @@ export function parsePlannerResult(value) {
|
|
|
67
90
|
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
68
91
|
throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
|
|
69
92
|
return {
|
|
70
|
-
id, input: task.input, priority
|
|
93
|
+
id, input: task.input, priority,
|
|
71
94
|
projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'),
|
|
72
95
|
dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'),
|
|
73
96
|
adapter: adapter, output: output, routing: routing(task.routing, `next_tasks[${index}].routing`),
|
package/dist/reconciler.js
CHANGED
|
@@ -59,7 +59,9 @@ export class Reconciler {
|
|
|
59
59
|
live = new Map();
|
|
60
60
|
plannerLive = new Map();
|
|
61
61
|
terminalTimers = new Map();
|
|
62
|
+
terminalExitGraceTimers = new Map();
|
|
62
63
|
continuationLive = new Set();
|
|
64
|
+
launchFailureClaims = new Set();
|
|
63
65
|
terminalWaiters = new Map();
|
|
64
66
|
reconciling = false;
|
|
65
67
|
constructor(store, options) {
|
|
@@ -67,6 +69,10 @@ export class Reconciler {
|
|
|
67
69
|
this.options = options;
|
|
68
70
|
}
|
|
69
71
|
async handleEvent(event) {
|
|
72
|
+
if (event.type === 'task.started') {
|
|
73
|
+
this.markWorkerStarted(event);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
70
76
|
if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
|
|
71
77
|
await this.applyTerminalEvent(event);
|
|
72
78
|
return;
|
|
@@ -341,7 +347,7 @@ export class Reconciler {
|
|
|
341
347
|
void (execution.launched ?? Promise.resolve()).then(() => this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model }, { agent: 'relay', component: 'agents-relay' }))).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
|
|
342
348
|
if (startAckSetup)
|
|
343
349
|
void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
|
|
344
|
-
this.live.set(task.id, { taskId: task.id, executionId: durableExecutionId, execution, controller });
|
|
350
|
+
this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: Date.now(), workerStarted: false });
|
|
345
351
|
const terminalTimer = setTimeout(() => {
|
|
346
352
|
this.detach(this.timeoutTask(job.id, task.id, durableExecutionId), job.id, task.id, 'worker terminal-event timeout');
|
|
347
353
|
}, Math.max(1, task.timeoutMs));
|
|
@@ -484,8 +490,30 @@ export class Reconciler {
|
|
|
484
490
|
if (timer)
|
|
485
491
|
clearTimeout(timer);
|
|
486
492
|
this.terminalTimers.delete(taskId);
|
|
493
|
+
const graceTimer = this.terminalExitGraceTimers.get(taskId);
|
|
494
|
+
if (graceTimer)
|
|
495
|
+
clearTimeout(graceTimer);
|
|
496
|
+
this.terminalExitGraceTimers.delete(taskId);
|
|
497
|
+
}
|
|
498
|
+
markWorkerStarted(event) {
|
|
499
|
+
const live = this.live.get(event.task_id);
|
|
500
|
+
if (!live || event.job_id !== live.jobId)
|
|
501
|
+
return;
|
|
502
|
+
const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
|
|
503
|
+
const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
|
|
504
|
+
if (sourceAgent === 'relay' || sourceComponent === 'agents-relay')
|
|
505
|
+
return;
|
|
506
|
+
if (event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
|
|
507
|
+
return;
|
|
508
|
+
live.workerStarted = true;
|
|
487
509
|
}
|
|
488
510
|
async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
|
|
511
|
+
const claim = executionId ?? `${jobId}:${taskId}`;
|
|
512
|
+
if (this.launchFailureClaims.has(claim))
|
|
513
|
+
return;
|
|
514
|
+
this.launchFailureClaims.add(claim);
|
|
515
|
+
const cleanup = setTimeout(() => this.launchFailureClaims.delete(claim), 60000);
|
|
516
|
+
cleanup.unref();
|
|
489
517
|
const job = await this.store.load(jobId);
|
|
490
518
|
const task = job.tasks.find(item => item.id === taskId);
|
|
491
519
|
if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
|
|
@@ -496,13 +524,37 @@ export class Reconciler {
|
|
|
496
524
|
}
|
|
497
525
|
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
|
498
526
|
const live = this.live.get(taskId);
|
|
527
|
+
const workerStarted = live?.executionId === executionId && live.workerStarted;
|
|
499
528
|
if (live?.executionId === executionId)
|
|
500
529
|
this.live.delete(taskId);
|
|
501
530
|
const job = await this.store.load(jobId);
|
|
502
531
|
const task = job.tasks.find(item => item.id === taskId);
|
|
503
|
-
if (!task || task.executionId !== executionId
|
|
532
|
+
if (!task || task.executionId !== executionId)
|
|
504
533
|
return;
|
|
505
534
|
await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
|
|
535
|
+
if (task.state === 'RUNNING' && task.adapter === 'codex')
|
|
536
|
+
this.scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted === true);
|
|
537
|
+
}
|
|
538
|
+
scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
539
|
+
const graceMs = Math.max(1, this.options.terminalExitGraceMs ?? 1000);
|
|
540
|
+
const timer = setTimeout(() => {
|
|
541
|
+
this.terminalExitGraceTimers.delete(taskId);
|
|
542
|
+
this.detach(this.failUnexpectedExit(jobId, taskId, executionId, workerStarted), jobId, taskId, 'unexpected worker process exit');
|
|
543
|
+
}, graceMs);
|
|
544
|
+
this.terminalExitGraceTimers.set(taskId, timer);
|
|
545
|
+
}
|
|
546
|
+
async failUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
547
|
+
const job = await this.store.load(jobId);
|
|
548
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
549
|
+
if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
|
|
550
|
+
return;
|
|
551
|
+
const reason = workerStarted ? 'worker_exit_without_terminal_event' : 'worker_exit_before_start';
|
|
552
|
+
const message = workerStarted
|
|
553
|
+
? `Worker ${taskId} exited after task.started without publishing a terminal task event`
|
|
554
|
+
: `Worker ${taskId} exited before publishing task.started`;
|
|
555
|
+
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', message, 'user', { reason, terminalGraceMs: this.options.terminalExitGraceMs ?? 1000 }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
556
|
+
await this.applyTerminalEvent(event);
|
|
557
|
+
await this.emit(event);
|
|
506
558
|
}
|
|
507
559
|
async timeoutTask(jobId, taskId, executionId) {
|
|
508
560
|
const live = this.live.get(taskId);
|
package/dist/scheduler.js
CHANGED
|
@@ -31,7 +31,7 @@ export function schedule(job) {
|
|
|
31
31
|
const waiting = job.tasks.some(t => ['QUEUED', 'WAITING'].includes(t.state));
|
|
32
32
|
const failed = job.tasks.some(t => t.state === 'FAILED');
|
|
33
33
|
const cancelled = job.tasks.every(t => t.state === 'CANCELLED');
|
|
34
|
-
const next =
|
|
34
|
+
const next = active ? 'RUNNING' : failed ? 'FAILED' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
|
|
35
35
|
if (job.state !== next) {
|
|
36
36
|
assertJobTransition(job.state, next);
|
|
37
37
|
job.state = next;
|
package/package.json
CHANGED
|
@@ -47,6 +47,8 @@ Agents Relay maps those terminal events to durable state:
|
|
|
47
47
|
|
|
48
48
|
A model-backed task must not launch without an event bus. If no terminal event arrives before the task timeout, Relay records an explicit timeout failure. A Codex process exit or ChatGPT submission receipt alone never changes the task to a terminal state.
|
|
49
49
|
|
|
50
|
+
For Codex, an unexpected process exit after the worker's `task.started` is given only a short bounded terminal grace window. If no exact correlated worker terminal event arrives in that window, Relay records `task.failed` with `worker_exit_without_terminal_event` as an explicit lifecycle-contract failure. This watchdog failure is still distinct from `task.process.exited`; it never treats process exit as task success and never duplicates an already-applied terminal event. An exit before `task.started` is likewise recorded as `worker_exit_before_start`.
|
|
51
|
+
|
|
50
52
|
## Managed GitHub jobs
|
|
51
53
|
|
|
52
54
|
For repository work, create/adopt/repair the PR-backed job through the Agents Relay CLI. Do not create the managed PR through a parallel raw GitHub path.
|
|
@@ -17,6 +17,7 @@ Inspect the durable objective and task tree, then decide whether the objective i
|
|
|
17
17
|
- `{"kind":"file","path":"..."}`.
|
|
18
18
|
- Include routing metadata for every model-backed child task.
|
|
19
19
|
- Do not call an execution agent "codex"; agent identity describes responsibility, while the adapter describes runtime.
|
|
20
|
+
- If a child task includes `priority`, emit the canonical string `P0`, `P1`, `P2`, or `P3` exactly. Do not emit numeric priorities, lowercase aliases, or labels such as `high`/`medium`/`low`.
|
|
20
21
|
- Return no prose outside the JSON result.
|
|
21
22
|
|
|
22
23
|
## Output
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Bounded polling helpers for ChatGPT submit readiness."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def wait_until_stable(
|
|
9
|
+
read_state: Callable[[], Mapping[str, object]],
|
|
10
|
+
ready: Callable[[Mapping[str, object]], bool],
|
|
11
|
+
*,
|
|
12
|
+
timeout: float,
|
|
13
|
+
interval: float = 0.25,
|
|
14
|
+
stable_samples: int = 2,
|
|
15
|
+
phase: str,
|
|
16
|
+
) -> Mapping[str, object]:
|
|
17
|
+
"""Return only after the observable state is ready for stable_samples polls."""
|
|
18
|
+
deadline = time.monotonic() + timeout
|
|
19
|
+
stable = 0
|
|
20
|
+
last_state: Mapping[str, object] = {}
|
|
21
|
+
while time.monotonic() < deadline:
|
|
22
|
+
last_state = read_state()
|
|
23
|
+
if ready(last_state):
|
|
24
|
+
stable += 1
|
|
25
|
+
if stable >= stable_samples:
|
|
26
|
+
return last_state
|
|
27
|
+
else:
|
|
28
|
+
stable = 0
|
|
29
|
+
time.sleep(interval)
|
|
30
|
+
raise RuntimeError(f"Temporary Chat {phase} was not observed ready")
|
|
@@ -58,6 +58,21 @@ def _composer_text(selector):
|
|
|
58
58
|
}})()""") or ""
|
|
59
59
|
|
|
60
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
|
+
|
|
61
76
|
def _same_text(observed, expected):
|
|
62
77
|
normalize = lambda value: re.sub(r"\\s+", " ", value or "").strip()
|
|
63
78
|
return normalize(expected) in normalize(observed)
|
|
@@ -73,6 +88,38 @@ def _attachment_names():
|
|
|
73
88
|
.filter(Boolean))()""") or []
|
|
74
89
|
|
|
75
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
|
+
|
|
76
123
|
def _upload_files(paths):
|
|
77
124
|
if not paths:
|
|
78
125
|
return []
|
|
@@ -87,13 +134,17 @@ def _upload_files(paths):
|
|
|
87
134
|
if not os.path.isfile(path):
|
|
88
135
|
raise RuntimeError(f"attachment does not exist: {path}")
|
|
89
136
|
upload_file(selector, path)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
97
148
|
|
|
98
149
|
|
|
99
150
|
def _user_turns():
|
|
@@ -109,17 +160,41 @@ def _click_send():
|
|
|
109
160
|
b.click(); return true;
|
|
110
161
|
})()""")
|
|
111
162
|
if not ok:
|
|
112
|
-
raise RuntimeError("Temporary Chat
|
|
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
|
+
)
|
|
113
187
|
|
|
114
188
|
|
|
115
189
|
def _wait_user_turn(before_count, prompt, timeout=20):
|
|
116
190
|
deadline = time.time() + timeout
|
|
117
191
|
while time.time() < deadline:
|
|
118
192
|
turns = _user_turns()
|
|
119
|
-
|
|
120
|
-
|
|
193
|
+
for turn in turns[before_count:]:
|
|
194
|
+
if _normalized_text(prompt) in _normalized_text(turn["text"]):
|
|
195
|
+
return turn
|
|
121
196
|
time.sleep(.25)
|
|
122
|
-
raise RuntimeError("Temporary Chat
|
|
197
|
+
raise RuntimeError("Temporary Chat submission verification did not observe a new user turn")
|
|
123
198
|
|
|
124
199
|
|
|
125
200
|
def _diagnostic_thread_id():
|
|
@@ -145,30 +220,59 @@ deadline = time.time() + 20
|
|
|
145
220
|
while time.time() < deadline:
|
|
146
221
|
if "temporary-chat=true" in page_info().get("url", ""):
|
|
147
222
|
try:
|
|
148
|
-
_composer()
|
|
149
223
|
break
|
|
150
224
|
except RuntimeError:
|
|
151
225
|
pass
|
|
152
226
|
time.sleep(.25)
|
|
153
227
|
else:
|
|
154
|
-
raise RuntimeError("Temporary Chat
|
|
228
|
+
raise RuntimeError("Temporary Chat composer readiness was not observed")
|
|
155
229
|
|
|
156
230
|
attachments = _upload_files(CFG.get("file", []))
|
|
157
|
-
selector =
|
|
231
|
+
selector = _wait_for_composer()
|
|
158
232
|
before_count = len(_user_turns())
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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"""(() => {{
|
|
162
240
|
const e=document.querySelector({json.dumps(selector)});
|
|
163
|
-
|
|
164
|
-
|
|
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;
|
|
165
250
|
}})()""")
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
)
|
|
171
274
|
|
|
275
|
+
_wait_for_send_ready(selector, CFG["prompt"], attachments)
|
|
172
276
|
_click_send()
|
|
173
277
|
user_turn = _wait_user_turn(before_count, CFG["prompt"])
|
|
174
278
|
|
|
@@ -10,6 +10,7 @@ import tempfile
|
|
|
10
10
|
|
|
11
11
|
|
|
12
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")
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def main() -> int:
|
|
@@ -23,7 +24,9 @@ def main() -> int:
|
|
|
23
24
|
json.dump(vars(args), handle, ensure_ascii=False)
|
|
24
25
|
config_path = handle.name
|
|
25
26
|
try:
|
|
26
|
-
|
|
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)
|
|
27
30
|
return subprocess.run(["browser-harness"], input=code, text=True, timeout=180).returncode
|
|
28
31
|
finally:
|
|
29
32
|
os.unlink(config_path)
|