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.
- package/README.md +9 -1
- package/dist/adapters.js +80 -31
- package/dist/cli.js +89 -2
- package/dist/reconciler.js +6 -4
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +2 -0
- 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,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
|