agora-adapter 0.2.0__py3-none-any.whl

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.
agora/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Agora's thin agent adapters. PocketBase owns storage, identity, and realtime."""
@@ -0,0 +1,3 @@
1
+ """Outbound-only local harness supervisor; the room relay remains the data plane."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from agora.adapter.cli import main
2
+
3
+ main()
agora/adapter/cli.py ADDED
@@ -0,0 +1,261 @@
1
+ """Run one room-scoped agent on the operator's machine."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import contextlib
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import signal
11
+ import sqlite3
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+
18
+ from agora.adapter import __version__
19
+ from agora.adapter.client import AccessLost, Hall
20
+ from agora.adapter.diagnostics import Diagnostics
21
+ from agora.adapter.dsh import DSH
22
+ from agora.adapter.harness import Claude, Codex, HarnessError
23
+ from agora.adapter.state import State
24
+ from agora.adapter.supervisor import Supervisor
25
+
26
+
27
+ def parser():
28
+ result = argparse.ArgumentParser(
29
+ description=__doc__,
30
+ epilog="For a hidden token prompt, use: agora-adapter connect --help",
31
+ )
32
+ result.add_argument("--version", action="version", version="%(prog)s " + __version__)
33
+ result.add_argument("--hall", help="Hall origin, e.g. https://hall.example.com")
34
+ result.add_argument("--room", help="15-character room ID")
35
+ result.add_argument("--harness", choices=("claude", "codex", "dsh"))
36
+ result.add_argument("--cwd", type=Path, help="Local workspace exposed to the harness")
37
+ result.add_argument(
38
+ "--token-env",
39
+ default="AGORA_ROOM_TOKEN",
40
+ help="Environment variable holding the agent token",
41
+ )
42
+ result.add_argument("--state", type=Path, help="Private SQLite checkpoint file")
43
+ result.add_argument("--wake-on", choices=("mentions", "human", "controls"), default="mentions")
44
+ result.add_argument(
45
+ "--allow-edits",
46
+ action="store_true",
47
+ help="Allow harness-native workspace editing; never bypass approvals",
48
+ )
49
+ result.add_argument("--model", help="Optional harness model override")
50
+ result.add_argument(
51
+ "--inspect", action="store_true", help="Read local --state without starting a harness"
52
+ )
53
+ result.add_argument(
54
+ "--diagnostics",
55
+ action="store_true",
56
+ help="Include the last private diagnostic records with --inspect",
57
+ )
58
+ result.add_argument(
59
+ "--diagnose",
60
+ action="store_true",
61
+ help="Check Codex/DSH startup/resume without submitting a model turn",
62
+ )
63
+ result.add_argument(
64
+ "--handoff",
65
+ action="store_true",
66
+ help="Wait for the desktop Codex turn to finish before taking over",
67
+ )
68
+ result.add_argument(
69
+ "--handoff-timeout", type=float, default=600, help="Maximum handoff wait in seconds"
70
+ )
71
+ result.add_argument(
72
+ "--fork-session",
73
+ action="store_true",
74
+ help="Explicitly fork existing history into an adapter-owned Codex session",
75
+ )
76
+ result.add_argument(
77
+ "--recover",
78
+ action="store_true",
79
+ help="Replay an uncertain turn after inspecting its side effects",
80
+ )
81
+ return result
82
+
83
+
84
+ def inspect_state(path, *, diagnostics=False):
85
+ with sqlite3.connect(f"file:{path.resolve()}?mode=ro", uri=True) as db:
86
+ meta = {key: json.loads(value) for key, value in db.execute("SELECT key, value FROM meta")}
87
+ result = {key: meta.get(key) for key in ("scope", "session", "attempt", "last_failure")}
88
+ result["inbox"] = [
89
+ dict(zip(("source", "id", "status"), row, strict=True))
90
+ for row in db.execute("SELECT source, id, status FROM inbox WHERE status != 'done'")
91
+ ]
92
+ journal = Path(str(path) + ".diagnostics.jsonl")
93
+ result["diagnostics_path"] = str(journal)
94
+ if diagnostics and journal.exists():
95
+ # The journal is bounded and redacted at write time; no room transcripts are logged.
96
+ result["diagnostics"] = [
97
+ json.loads(line) for line in journal.read_text().splitlines()[-10:]
98
+ ]
99
+ print(json.dumps(result, indent=2))
100
+
101
+
102
+ async def run(args):
103
+ token = os.environ.get(args.token_env, "")
104
+ if not token or any(char.isspace() for char in token):
105
+ raise ValueError("Set the room token in the selected environment variable.")
106
+ cwd = args.cwd.resolve(strict=True)
107
+ if not cwd.is_dir():
108
+ raise ValueError("The workspace must be a directory.")
109
+ hall = Hall(args.hall, token, args.room)
110
+ state = harness = None
111
+ bootstrap = args.state or Path.home() / ".local/state/agora/startup"
112
+ diagnostics = Diagnostics(Path(str(bootstrap) + ".diagnostics.jsonl"), secrets=(token,))
113
+ try:
114
+ await hall.authenticate()
115
+ mcp = await hall.discovery()
116
+ scope = {
117
+ "hall": hall.url,
118
+ "room": hall.room,
119
+ "agent": hall.person["id"],
120
+ "harness": args.harness,
121
+ "cwd": str(cwd),
122
+ }
123
+ key = hashlib.sha256(repr(sorted(scope.items())).encode()).hexdigest()[:24]
124
+ path = args.state or Path.home() / ".local/state/agora" / (key + ".sqlite3")
125
+ diagnostics = Diagnostics(Path(str(path) + ".diagnostics.jsonl"), secrets=(token,))
126
+ state = State(path, scope, args.recover, hold_uncertain=True)
127
+ if state.recovery:
128
+ diagnostics.record("recovery", **state.recovery)
129
+ harness = {"claude": Claude, "codex": Codex, "dsh": DSH}[args.harness](
130
+ cwd=cwd,
131
+ mcp=mcp,
132
+ token=token,
133
+ allow_edits=args.allow_edits,
134
+ model=args.model,
135
+ )
136
+ harness.diagnostics = diagnostics
137
+ supervisor = Supervisor(hall, state, harness, wake_on=args.wake_on)
138
+ task = asyncio.current_task()
139
+ loop = asyncio.get_running_loop()
140
+ for sig in (signal.SIGTERM, signal.SIGINT):
141
+ loop.add_signal_handler(sig, task.cancel)
142
+ logging.info(
143
+ "Listening for room activity. Harness: %s. Checkpoints: %s", args.harness, path
144
+ )
145
+ try:
146
+ session = state.get("session")
147
+ if args.fork_session and session:
148
+ # Reject a blind replay before creating a replacement session.
149
+ if state.unfinished():
150
+ raise HarnessError("Inspect uncertain work and use --recover before forking.")
151
+ session = await harness.fork_session(session, lambda sid: state.set("session", sid))
152
+ state.set("last_failure", None)
153
+ supervisor.retries = 0
154
+ logging.info("Adapter-owned Codex session: %s", session)
155
+ if args.handoff and session:
156
+ logging.info(
157
+ "Handoff waiting for the current Codex turn; do not send more desktop turns."
158
+ )
159
+ await hall.activity("error")
160
+ waiter = asyncio.create_task(harness.wait_for_idle(session, args.handoff_timeout))
161
+ try:
162
+ while not waiter.done():
163
+ try:
164
+ await asyncio.wait_for(asyncio.shield(waiter), 15)
165
+ except TimeoutError:
166
+ if waiter.done():
167
+ raise
168
+ await hall.activity("error")
169
+ await waiter
170
+ finally:
171
+ waiter.cancel()
172
+ with contextlib.suppress(asyncio.CancelledError):
173
+ await waiter
174
+ if args.diagnose:
175
+ if args.harness == "dsh":
176
+ await harness.prepare(session, lambda sid: state.set("session", sid))
177
+ else:
178
+ await harness.start()
179
+ if session:
180
+ await harness.check_idle(session)
181
+ await harness.request("thread/resume", harness.thread_params(session))
182
+ logging.info(
183
+ "%s startup/resume succeeded. No model turn was submitted.", args.harness
184
+ )
185
+ return
186
+ await supervisor.run()
187
+ finally:
188
+ for sig in (signal.SIGTERM, signal.SIGINT):
189
+ loop.remove_signal_handler(sig)
190
+ except Exception as exc:
191
+ if diagnostics:
192
+ incident = diagnostics.record(
193
+ "adapter_failure", exception=type(exc).__name__, message=str(exc)
194
+ )
195
+ logging.error("Adapter failure %s. Diagnostics: %s", incident, diagnostics.path)
196
+ if state and not state.get("last_failure"):
197
+ state.set(
198
+ "last_failure",
199
+ {
200
+ "incident": incident,
201
+ "message": str(exc)
202
+ if isinstance(exc, HarnessError)
203
+ else "Adapter startup or lifecycle failed.",
204
+ "blocked": True,
205
+ },
206
+ )
207
+ # Without the state lock, another adapter may own this identity. Do not
208
+ # overwrite its activity merely because this process failed to start.
209
+ if state:
210
+ try:
211
+ await hall.activity("error")
212
+ except (AccessLost, httpx.HTTPError):
213
+ pass
214
+ raise
215
+ finally:
216
+ if harness:
217
+ await harness.close()
218
+ if state:
219
+ state.close()
220
+ await hall.close()
221
+
222
+
223
+ def main():
224
+ if sys.argv[1:2] == ["connect"]:
225
+ from agora.adapter.launcher import main as connect
226
+
227
+ connect(sys.argv[2:])
228
+ return
229
+ logging.Formatter.converter = time.gmtime
230
+ logging.basicConfig(
231
+ level=logging.INFO,
232
+ format="%(asctime)sZ %(levelname)s %(message)s",
233
+ datefmt="%Y-%m-%dT%H:%M:%S",
234
+ )
235
+ logging.getLogger("httpx").setLevel(logging.WARNING)
236
+ argument_parser = parser()
237
+ args = argument_parser.parse_args()
238
+ if args.inspect:
239
+ if not args.state:
240
+ argument_parser.error("--inspect requires --state")
241
+ inspect_state(args.state, diagnostics=args.diagnostics)
242
+ return
243
+ if not all((args.hall, args.room, args.harness, args.cwd)):
244
+ argument_parser.error("--hall, --room, --harness and --cwd are required")
245
+ if (args.handoff or args.fork_session) and args.harness != "codex":
246
+ argument_parser.error("--handoff and --fork-session require --harness codex")
247
+ if args.diagnose and args.harness not in ("codex", "dsh"):
248
+ argument_parser.error("--diagnose requires --harness codex or dsh")
249
+ if args.handoff_timeout <= 0:
250
+ argument_parser.error("--handoff-timeout must be positive")
251
+ try:
252
+ asyncio.run(run(args))
253
+ except (KeyboardInterrupt, asyncio.CancelledError):
254
+ pass
255
+ except (ValueError, AccessLost, HarnessError) as exc:
256
+ raise SystemExit(str(exc)) from None
257
+ except Exception:
258
+ # Provider and transport exceptions may contain prompts, URLs, or credentials.
259
+ raise SystemExit(
260
+ "Adapter failed; inspect local state and harness logs before restarting."
261
+ ) from None
@@ -0,0 +1,160 @@
1
+ """PocketBase SSE is a hint; reconnects replay the durable room records."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncIterator, Sequence
6
+ from typing import cast
7
+ from urllib.parse import urlsplit
8
+
9
+ import httpx
10
+
11
+ from agora.adapter.interfaces import Activity, InputRecord
12
+ from agora.contracts import Person, Room
13
+ from agora.validation import record_id
14
+
15
+
16
+ class AccessLost(Exception):
17
+ """The adapter must stop its local harness and require fresh authorization."""
18
+
19
+
20
+ def origin_url(value: str) -> str:
21
+ parsed = urlsplit(value)
22
+ if (
23
+ parsed.scheme not in ("http", "https")
24
+ or not parsed.hostname
25
+ or parsed.username
26
+ or parsed.password
27
+ or parsed.query
28
+ or parsed.fragment
29
+ or parsed.path not in ("", "/")
30
+ or (parsed.scheme == "http" and parsed.hostname not in ("127.0.0.1", "localhost", "::1"))
31
+ ):
32
+ raise ValueError("Use an HTTPS hall origin (HTTP is supported only on loopback).")
33
+ return value.rstrip("/")
34
+
35
+
36
+ async def sse_events(response: httpx.Response) -> AsyncIterator[tuple[str, dict]]:
37
+ event = "message"
38
+ data: list[str] = []
39
+ async for line in response.aiter_lines():
40
+ if not line:
41
+ if data:
42
+ yield event, json.loads("\n".join(data))
43
+ event, data = "message", []
44
+ elif line.startswith("event:"):
45
+ event = line[6:].strip()
46
+ elif line.startswith("data:"):
47
+ data.append(line[5:].lstrip(" "))
48
+
49
+
50
+ class Hall:
51
+ def __init__(self, url: str, token: str, room: str, *, transport=None):
52
+ self.url, self.room = origin_url(url), record_id(room)
53
+ self.token = token
54
+ self.http = httpx.AsyncClient(
55
+ base_url=self.url,
56
+ headers={"Authorization": "Bearer " + token},
57
+ timeout=30,
58
+ follow_redirects=False,
59
+ transport=transport,
60
+ )
61
+ self.person: Person | None = None
62
+
63
+ async def request(self, method, path, **kwargs):
64
+ response = await self.http.request(method, path, **kwargs)
65
+ if response.status_code in (401, 403, 404):
66
+ raise AccessLost("Room or agent access is no longer available.")
67
+ response.raise_for_status()
68
+ return response.json() if response.content else None
69
+
70
+ async def authenticate(self) -> Room:
71
+ auth = await self.request("POST", "/api/collections/people/auth-refresh")
72
+ person = auth["record"]
73
+ if person.get("kind") != "agent" or person.get("agent_room") != self.room:
74
+ raise ValueError(
75
+ "Use an invited agent token for this room, never a human or admin token."
76
+ )
77
+ room = await self.request("GET", f"/api/collections/rooms/records/{self.room}")
78
+ if self.person and self.person["id"] != person["id"]:
79
+ raise AccessLost("The authenticated identity changed.")
80
+ self.person = person
81
+ return cast(Room, room)
82
+
83
+ async def discovery(self):
84
+ data = await self.request("GET", "/.well-known/agora.json")
85
+ mcp = data.get("mcp", "")
86
+ parsed = urlsplit(mcp)
87
+ origin_url(f"{parsed.scheme}://{parsed.netloc}")
88
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
89
+ raise ValueError("Invalid MCP discovery URL.")
90
+ return mcp
91
+
92
+ async def activity(self, status: Activity) -> None:
93
+ await self.request(
94
+ "POST", "/api/agora/agents/me/activity", json={"status": status}, timeout=5
95
+ )
96
+
97
+ async def records(
98
+ self, collection: str, cursor: str | int | None = None
99
+ ) -> AsyncIterator[list[InputRecord]]:
100
+ filters = f'room="{self.room}"'
101
+ if collection == "agent_controls":
102
+ if self.person is None:
103
+ raise AccessLost("Authenticate before reading control records.")
104
+ filters += f' && agent="{self.person["id"]}" && sequence > {int(cursor or 0)}'
105
+ sort = "sequence"
106
+ else:
107
+ sort = "created,id"
108
+ if cursor:
109
+ filters += " && created >= " + json.dumps(cursor)
110
+ page = 1
111
+ while True:
112
+ result = await self.request(
113
+ "GET",
114
+ f"/api/collections/{collection}/records",
115
+ params={"filter": filters, "sort": sort, "perPage": 100, "page": page},
116
+ )
117
+ yield result["items"]
118
+ if page >= result["totalPages"]:
119
+ return
120
+ page += 1
121
+
122
+ async def watch(
123
+ self, dirty: asyncio.Event, *, subscriptions: Sequence[str] | None = None
124
+ ) -> None:
125
+ """Subscribe before signalling replay, closing the snapshot/subscription race."""
126
+ delay = 1
127
+ while True:
128
+ try:
129
+ async with self.http.stream(
130
+ "GET", "/api/realtime", timeout=httpx.Timeout(30, read=360)
131
+ ) as response:
132
+ response.raise_for_status()
133
+ async for event, data in sse_events(response):
134
+ if event == "PB_CONNECT":
135
+ await self.request(
136
+ "POST",
137
+ "/api/realtime",
138
+ json={
139
+ "clientId": data["clientId"],
140
+ "subscriptions": subscriptions
141
+ or [
142
+ "messages/*",
143
+ "revisions/*",
144
+ "agent_controls/*",
145
+ f"rooms/{self.room}",
146
+ ],
147
+ },
148
+ )
149
+ delay = 1
150
+ dirty.set()
151
+ elif data.get("record", {}).get("room", self.room) == self.room:
152
+ dirty.set()
153
+ except (httpx.HTTPError, ValueError, KeyError):
154
+ # No upstream exception text: URLs may contain credentials in other contexts.
155
+ pass
156
+ await asyncio.sleep(delay)
157
+ delay = min(delay * 2, 30)
158
+
159
+ async def close(self) -> None:
160
+ await self.http.aclose()
@@ -0,0 +1,76 @@
1
+ """Bounded, private operator diagnostics; never a room transcript or reasoning log."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from uuid import uuid4
10
+
11
+
12
+ class Diagnostics:
13
+ def __init__(self, path: Path, *, secrets=(), limit=1024 * 1024):
14
+ self.path, self.secrets, self.limit = path, tuple(filter(None, secrets)), limit
15
+
16
+ def clean(self, value):
17
+ if isinstance(value, dict):
18
+ return {
19
+ str(key): "[REDACTED]"
20
+ if any(
21
+ word in str(key).lower()
22
+ for word in (
23
+ "token",
24
+ "secret",
25
+ "password",
26
+ "authorization",
27
+ "api_key",
28
+ "apikey",
29
+ "credential",
30
+ "cookie",
31
+ )
32
+ )
33
+ else self.clean(item)
34
+ for key, item in value.items()
35
+ }
36
+ if isinstance(value, (list, tuple)):
37
+ return [self.clean(item) for item in value[:50]]
38
+ if not isinstance(value, str):
39
+ return value
40
+ for secret in self.secrets:
41
+ value = value.replace(secret, "[REDACTED]")
42
+ value = re.sub(r"(?i)\bBearer\s+[^\s\"'<>]+", "Bearer [REDACTED]", value)
43
+ value = re.sub(
44
+ r"(?i)(\b(?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s&,;\"']+",
45
+ r"\1[REDACTED]",
46
+ value,
47
+ )
48
+ value = re.sub(r"\bsk-[\w-]+", "[REDACTED]", value)
49
+ return value[:8000]
50
+
51
+ def record(self, event, **fields):
52
+ record = self.clean(
53
+ {
54
+ "id": uuid4().hex[:12],
55
+ "at": datetime.now(UTC).isoformat(),
56
+ "event": event,
57
+ **fields,
58
+ }
59
+ )
60
+ try:
61
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
62
+ if self.path.exists() and self.path.stat().st_size >= self.limit:
63
+ self.path.replace(self.path.with_suffix(self.path.suffix + ".previous"))
64
+ fd = os.open(self.path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
65
+ with os.fdopen(fd, "w") as handle:
66
+ os.fchmod(handle.fileno(), 0o600)
67
+ handle.write(json.dumps(record, ensure_ascii=False) + "\n")
68
+ handle.flush()
69
+ os.fsync(handle.fileno())
70
+ except OSError as exc:
71
+ # Diagnostic failure must be visible without replacing the original failure.
72
+ logging.error(
73
+ "Cannot persist adapter diagnostics at %s (errno %s).", self.path, exc.errno
74
+ )
75
+ return None
76
+ return record["id"]
@@ -0,0 +1,13 @@
1
+ // Apply the operator's choice before both fresh and restored Agents are published.
2
+ // DSH otherwise preserves a resumed session's earlier permission events.
3
+ export const inject = ['permissionPresets'];
4
+
5
+ export function apply(ctx) {
6
+ const mode = process.env.AGORA_DSH_PERMISSION_MODE;
7
+ if (!['read-only', 'workspace-write'].includes(mode)) {
8
+ throw new Error('Agora requires an explicit read-only or workspace-write policy.');
9
+ }
10
+ ctx.on('agent/prepare', ({ agent }) => {
11
+ ctx.permissionPresets.set(agent.session, mode);
12
+ });
13
+ }
@@ -0,0 +1,21 @@
1
+ # Invocation-only policy; room credentials are passed over ACP, never stored here.
2
+ - id: sandbox-policy
3
+ config:
4
+ mode: !!js process.env.AGORA_DSH_PERMISSION_MODE
5
+ workspaceRoot: !!js process.cwd()
6
+
7
+ - id: approval
8
+ config:
9
+ policy: never
10
+
11
+ - id: permission
12
+ config:
13
+ defaultPreset: !!js process.env.AGORA_DSH_PERMISSION_MODE
14
+ presets:
15
+ read-only: {sandbox: read-only, approval: never}
16
+ workspace-write: {sandbox: workspace-write, approval: never}
17
+ danger-full-access: {sandbox: danger-full-access, approval: never}
18
+
19
+ - insert:
20
+ - id: agora-permission
21
+ name: ./dsh-policy.mjs