agents-relay 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -0,0 +1,84 @@
1
+ """Create a ChatGPT thread through an injected browser-harness port."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Callable, Protocol
6
+
7
+ try:
8
+ from .contract import ContractError, project_matches, validate_request
9
+ except ImportError: # Loaded directly by the lightweight skill test harness.
10
+ from contract import ContractError, project_matches, validate_request
11
+
12
+
13
+ class BrowserPort(Protocol):
14
+ def select_project(self, project: dict[str, str]) -> dict[str, Any]: ...
15
+ def set_thinking_level(self, level: str) -> dict[str, Any]: ...
16
+ def send_prompt(self, prompt: str) -> dict[str, Any]: ...
17
+
18
+
19
+ def _now() -> str:
20
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
21
+
22
+
23
+ def _effective_level(observation: Any) -> str:
24
+ if not isinstance(observation, dict):
25
+ return "unknown"
26
+ value = observation.get("effective_thinking_level", observation.get("level"))
27
+ return value if value in {"default", "low", "medium", "high"} else "unknown"
28
+
29
+
30
+ def create_thread(
31
+ port: BrowserPort,
32
+ request: Any,
33
+ *,
34
+ persist: Callable[[dict[str, Any]], None] | None = None,
35
+ now: Callable[[], str] = _now,
36
+ ) -> dict[str, Any]:
37
+ """Select the requested Project, send the first prompt, and return state.
38
+
39
+ The port returns observations from the browser. It is deliberately not
40
+ responsible for durable state or contract validation.
41
+ """
42
+ request = validate_request(request)
43
+ if request["operation"] != "create":
44
+ raise ContractError("create_thread requires operation=create")
45
+
46
+ selected = port.select_project(request["project"])
47
+ if not project_matches(request["project"], selected):
48
+ raise ContractError("browser observed a different Project after selection")
49
+
50
+ thinking_observation: dict[str, Any] = {"requested": "default", "changed": False}
51
+ effective = "unknown"
52
+ if request["thinking_level"] != "default":
53
+ raw = port.set_thinking_level(request["thinking_level"])
54
+ thinking_observation = dict(raw) if isinstance(raw, dict) else {"observed": raw}
55
+ thinking_observation["requested"] = request["thinking_level"]
56
+ thinking_observation["changed"] = True
57
+ effective = _effective_level(raw)
58
+
59
+ sent = port.send_prompt(request["prompt"])
60
+ if not isinstance(sent, dict) or not isinstance(sent.get("thread_id"), str) or not sent["thread_id"].strip():
61
+ raise ContractError("browser did not return an observed thread_id")
62
+
63
+ timestamp = now()
64
+ state: dict[str, Any] = {
65
+ "schema_version": 1,
66
+ "thread_id": sent["thread_id"].strip(),
67
+ "project": dict(selected),
68
+ "status": "running",
69
+ "requested_thinking_level": request["thinking_level"],
70
+ "effective_thinking_level": effective,
71
+ "created_at": timestamp,
72
+ "updated_at": timestamp,
73
+ "last_error": None,
74
+ "evidence": {
75
+ "project": dict(selected),
76
+ "thinking": thinking_observation,
77
+ "thread": {key: value for key, value in sent.items() if key != "text"},
78
+ },
79
+ }
80
+ if "conversation_url" in sent:
81
+ state["conversation_url"] = sent["conversation_url"]
82
+ if persist is not None:
83
+ persist(state)
84
+ return state
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env python3
2
+ """Create one ChatGPT thread using the authenticated browser-harness session."""
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__)), "_create_bh.py")
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--project", required=True)
18
+ parser.add_argument("--prompt", required=True)
19
+ parser.add_argument("--thinking-level", choices=("default", "low", "medium", "high"), default="default")
20
+ args = parser.parse_args()
21
+ config = {"project": {"name": args.project}, "prompt": args.prompt, "thinking_level": args.thinking_level}
22
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as handle:
23
+ json.dump(config, handle, ensure_ascii=False)
24
+ config_path = handle.name
25
+ try:
26
+ code = open(BH_SCRIPT, encoding="utf-8").read().replace("__CFG_PATH__", config_path)
27
+ result = subprocess.run(["browser-harness"], input=code, text=True, timeout=180)
28
+ return result.returncode
29
+ finally:
30
+ os.unlink(config_path)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ raise SystemExit(main())
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env python3
2
+ """Run one existing-thread operation through the authenticated 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__)), "_operate_bh.py")
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("operation", choices=("resume", "continue", "status", "result", "delete"))
18
+ parser.add_argument("--thread-id", required=True)
19
+ parser.add_argument("--project", required=True)
20
+ parser.add_argument("--prompt")
21
+ args = parser.parse_args()
22
+ if args.operation == "continue" and not args.prompt:
23
+ parser.error("continue requires --prompt")
24
+ config = {key: value for key, value in vars(args).items() if value is not None}
25
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as handle:
26
+ json.dump(config, handle, ensure_ascii=False)
27
+ config_path = handle.name
28
+ try:
29
+ code = open(BH_SCRIPT, encoding="utf-8").read().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())
@@ -0,0 +1,179 @@
1
+ """Existing-thread operations for an injected browser-harness port."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Callable, Protocol
6
+
7
+ try:
8
+ from .contract import ContractError, transition, validate_cleanup_observation, validate_project, validate_request, validate_result, validate_state
9
+ except ImportError:
10
+ from contract import ContractError, transition, validate_cleanup_observation, validate_project, validate_request, validate_result, validate_state
11
+
12
+
13
+ class BrowserPort(Protocol):
14
+ def open_thread(self, thread_id: str) -> dict[str, Any]: ...
15
+ def send_prompt(self, prompt: str) -> dict[str, Any]: ...
16
+ def read_status(self) -> dict[str, Any]: ...
17
+ def read_result(self) -> dict[str, Any]: ...
18
+ def delete_thread(self) -> dict[str, Any]: ...
19
+
20
+
21
+ def _now() -> str:
22
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
23
+
24
+
25
+ def _project(observation: Any) -> dict[str, Any]:
26
+ if not isinstance(observation, dict):
27
+ raise ContractError("browser did not return an observed thread")
28
+ project = observation.get("project", observation.get("selected_project"))
29
+ if project is None:
30
+ raise ContractError("browser did not expose the thread Project")
31
+ return project
32
+
33
+
34
+ def _status(observation: Any, default: str = "awaiting_result") -> str:
35
+ value = observation.get("status") if isinstance(observation, dict) else None
36
+ return value if value in {"created", "running", "awaiting_result", "completed", "failed", "blocked"} else default
37
+
38
+
39
+ def _same_project(left: Any, right: Any) -> bool:
40
+ """Compare names always; compare IDs only when both UI boundaries expose them."""
41
+ expected = validate_project(left)
42
+ observed = validate_project(right)
43
+ return expected["name"] == observed["name"] and (
44
+ "id" not in expected or "id" not in observed or expected["id"] == observed["id"]
45
+ )
46
+
47
+
48
+ def _save(state: dict[str, Any], persist: Callable[[dict[str, Any]], None] | None, now: Callable[[], str]) -> dict[str, Any]:
49
+ state["updated_at"] = now()
50
+ if persist is not None:
51
+ persist(state)
52
+ return state
53
+
54
+
55
+ def _opened(port: BrowserPort, state: dict[str, Any], *, expected_project: dict[str, Any]) -> dict[str, Any]:
56
+ opened = port.open_thread(state["thread_id"])
57
+ if not isinstance(opened, dict):
58
+ raise ContractError("browser did not return an observed thread")
59
+ observed_id = opened.get("thread_id", state["thread_id"])
60
+ if observed_id != state["thread_id"]:
61
+ raise ContractError("browser opened a different thread")
62
+ observed_project = _project(opened)
63
+ if not _same_project(expected_project, observed_project):
64
+ raise ContractError("observed Project does not match durable Project binding")
65
+ return opened
66
+
67
+
68
+ def resume_thread(
69
+ port: BrowserPort,
70
+ request: Any,
71
+ *,
72
+ state: Any | None = None,
73
+ persist: Callable[[dict[str, Any]], None] | None = None,
74
+ now: Callable[[], str] = _now,
75
+ ) -> dict[str, Any]:
76
+ """Open and verify an existing thread without sending a new prompt."""
77
+ request = validate_request(request)
78
+ if request["operation"] != "resume":
79
+ raise ContractError("resume_thread requires operation=resume")
80
+ current = validate_state(state) if state is not None else {
81
+ "schema_version": 1,
82
+ "thread_id": request["thread_id"],
83
+ "project": request["project"],
84
+ "status": "created",
85
+ "requested_thinking_level": request.get("thinking_level", "default"),
86
+ "effective_thinking_level": "unknown",
87
+ }
88
+ if current["thread_id"] != request["thread_id"]:
89
+ raise ContractError("resume request does not match durable thread identity")
90
+ if current["status"] == "deleted":
91
+ raise ContractError("deleted thread is terminal")
92
+ if not _same_project(current["project"], request["project"]):
93
+ raise ContractError("resume Project does not match durable Project binding")
94
+ opened = _opened(port, current, expected_project=current["project"])
95
+ observed_status = _status(opened)
96
+ if current["status"] == "created":
97
+ current = transition(current, "running")
98
+ if observed_status != current["status"]:
99
+ current = transition(current, observed_status)
100
+ current["project"] = dict(_project(opened))
101
+ current.setdefault("evidence", {})["open"] = opened
102
+ return _save(current, persist, now)
103
+
104
+
105
+ def continue_thread(
106
+ port: BrowserPort,
107
+ state: Any,
108
+ prompt: str,
109
+ *,
110
+ persist: Callable[[dict[str, Any]], None] | None = None,
111
+ now: Callable[[], str] = _now,
112
+ ) -> dict[str, Any]:
113
+ """Open a durable thread, send one follow-up prompt, and persist running state."""
114
+ current = validate_state(state)
115
+ if not isinstance(prompt, str) or not prompt.strip():
116
+ raise ContractError("prompt must be a non-empty string")
117
+ _opened(port, current, expected_project=current["project"])
118
+ if current["status"] != "running":
119
+ current = transition(current, "running")
120
+ sent = port.send_prompt(prompt.strip())
121
+ if not isinstance(sent, dict):
122
+ raise ContractError("browser did not acknowledge the follow-up prompt")
123
+ current.setdefault("evidence", {})["prompt"] = sent
124
+ return _save(current, persist, now)
125
+
126
+
127
+ def inspect_thread(
128
+ port: BrowserPort,
129
+ state: Any,
130
+ *,
131
+ persist: Callable[[dict[str, Any]], None] | None = None,
132
+ now: Callable[[], str] = _now,
133
+ ) -> dict[str, Any]:
134
+ """Read the observable UI status/progress and persist its state transition."""
135
+ current = validate_state(state)
136
+ if current["status"] == "deleted":
137
+ raise ContractError("deleted thread is terminal")
138
+ observation = port.read_status()
139
+ observed_status = _status(observation, default="awaiting_result")
140
+ if observed_status != current["status"]:
141
+ current = transition(current, observed_status)
142
+ current.setdefault("evidence", {})["status"] = observation
143
+ return _save(current, persist, now)
144
+
145
+
146
+ def result_thread(
147
+ port: BrowserPort,
148
+ state: Any,
149
+ *,
150
+ persist: Callable[[dict[str, Any]], None] | None = None,
151
+ now: Callable[[], str] = _now,
152
+ ) -> dict[str, Any]:
153
+ """Return only an observed, normalized assistant result and persist completion."""
154
+ current = validate_state(state)
155
+ if current["status"] == "deleted":
156
+ raise ContractError("deleted thread is terminal")
157
+ result = validate_result(port.read_result(), thread_id=current["thread_id"])
158
+ if current["status"] != "completed":
159
+ current = transition(current, "completed")
160
+ current.setdefault("evidence", {})["result"] = result
161
+ _save(current, persist, now)
162
+ return result
163
+
164
+
165
+ def delete_thread(
166
+ port: BrowserPort,
167
+ state: Any,
168
+ *,
169
+ persist: Callable[[dict[str, Any]], None] | None = None,
170
+ now: Callable[[], str] = _now,
171
+ ) -> dict[str, Any]:
172
+ """Delete one durable thread and persist its terminal tombstone."""
173
+ current = validate_state(state)
174
+ if current["status"] == "deleted":
175
+ return current
176
+ observation = validate_cleanup_observation(port.delete_thread(), thread_id=current["thread_id"])
177
+ current = transition(current, "deleted")
178
+ current.setdefault("evidence", {})["cleanup"] = observation
179
+ return _save(current, persist, now)
@@ -0,0 +1,21 @@
1
+ {
2
+ "job_id": "neo-chatgpt-browser-worker-20260919",
3
+ "task_id": "chatgpt-thread-1",
4
+ "project": {"id": "project-1", "name": "neo"},
5
+ "create": {
6
+ "operation": "create",
7
+ "project": {"name": "neo"},
8
+ "prompt": "Run the assigned task.",
9
+ "thinking_level": "high"
10
+ },
11
+ "thread": {
12
+ "thread_id": "thread-1",
13
+ "conversation_url": "https://chatgpt.com/c/thread-1"
14
+ },
15
+ "result": {
16
+ "thread_id": "thread-1",
17
+ "status": "completed",
18
+ "text": "The assigned task is complete.",
19
+ "message_id": "message-1"
20
+ }
21
+ }
@@ -0,0 +1,27 @@
1
+ from pathlib import Path
2
+
3
+
4
+ AGENT = Path(__file__).parents[1] / "agents" / "browser-worker.agent.md"
5
+
6
+
7
+ def test_browser_worker_agent_defines_typed_lifecycle_and_verified_result():
8
+ text = AGENT.read_text()
9
+
10
+ for operation in ("create", "resume", "continue", "status", "result", "delete"):
11
+ assert f"`{operation}`" in text
12
+ for field in ("thread_id", "project", "message_id", "verified", "observed_at"):
13
+ assert f'"{field}"' in text
14
+ assert "browser-harness" in text
15
+ assert "authentication_required" in text
16
+ assert "Do not report success" in text
17
+
18
+
19
+ def test_callers_are_directed_to_launch_agent_not_helper_scripts():
20
+ skill = (AGENT.parents[1] / "SKILL.md").read_text()
21
+ orchestration = (AGENT.parents[1] / "references" / "orchestration.md").read_text()
22
+
23
+ assert "Callers launch that" in skill
24
+ assert "agent with a typed lifecycle intent" in skill
25
+ assert "must not directly call" in skill
26
+ assert "launches the `browser-worker` agent" in orchestration
27
+ assert "must not directly invoke" in orchestration