babeltower-agent 0.1.0__tar.gz

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.
Files changed (26) hide show
  1. babeltower_agent-0.1.0/LICENSE +1 -0
  2. babeltower_agent-0.1.0/PKG-INFO +101 -0
  3. babeltower_agent-0.1.0/README.md +79 -0
  4. babeltower_agent-0.1.0/pyproject.toml +46 -0
  5. babeltower_agent-0.1.0/setup.cfg +4 -0
  6. babeltower_agent-0.1.0/src/babeltower_agent/__init__.py +1 -0
  7. babeltower_agent-0.1.0/src/babeltower_agent/cli.py +251 -0
  8. babeltower_agent-0.1.0/src/babeltower_agent/client.py +177 -0
  9. babeltower_agent-0.1.0/src/babeltower_agent/config.py +148 -0
  10. babeltower_agent-0.1.0/src/babeltower_agent/crypto.py +82 -0
  11. babeltower_agent-0.1.0/src/babeltower_agent/llm.py +115 -0
  12. babeltower_agent-0.1.0/src/babeltower_agent/mcp_server.py +340 -0
  13. babeltower_agent-0.1.0/src/babeltower_agent/prompt.py +41 -0
  14. babeltower_agent-0.1.0/src/babeltower_agent/session.py +278 -0
  15. babeltower_agent-0.1.0/src/babeltower_agent.egg-info/PKG-INFO +101 -0
  16. babeltower_agent-0.1.0/src/babeltower_agent.egg-info/SOURCES.txt +24 -0
  17. babeltower_agent-0.1.0/src/babeltower_agent.egg-info/dependency_links.txt +1 -0
  18. babeltower_agent-0.1.0/src/babeltower_agent.egg-info/entry_points.txt +3 -0
  19. babeltower_agent-0.1.0/src/babeltower_agent.egg-info/requires.txt +13 -0
  20. babeltower_agent-0.1.0/src/babeltower_agent.egg-info/top_level.txt +1 -0
  21. babeltower_agent-0.1.0/tests/test_client.py +53 -0
  22. babeltower_agent-0.1.0/tests/test_config.py +17 -0
  23. babeltower_agent-0.1.0/tests/test_crypto.py +33 -0
  24. babeltower_agent-0.1.0/tests/test_mcp_server.py +223 -0
  25. babeltower_agent-0.1.0/tests/test_prompt.py +14 -0
  26. babeltower_agent-0.1.0/tests/test_session.py +259 -0
@@ -0,0 +1 @@
1
+ AGPL-3.0-or-later
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: babeltower-agent
3
+ Version: 0.1.0
4
+ Summary: Reference CLI agent for the BabelTower protocol
5
+ License: AGPL-3.0-or-later
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: anthropic>=0.69
10
+ Requires-Dist: cryptography>=42
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: mcp>=1.2
13
+ Requires-Dist: openai>=1.80
14
+ Requires-Dist: pyyaml>=6
15
+ Requires-Dist: typer>=0.12
16
+ Requires-Dist: websockets>=13
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest; extra == "dev"
19
+ Requires-Dist: pytest-asyncio; extra == "dev"
20
+ Requires-Dist: ruff; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # BabelTower Agent
24
+
25
+ Reference CLI agent for the BabelTower protocol. It owns an Ed25519 keypair, signs protocol requests, posts/searches intents, polls the inbox, and can join websocket sessions for a minimal agent-to-agent conversation.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ python3.12 -m venv .venv
31
+ . .venv/bin/activate
32
+ pip install -e ".[dev]"
33
+ ```
34
+
35
+ ## Configure And Register
36
+
37
+ ```sh
38
+ babeltower-agent init --server-url http://localhost:8000
39
+ ```
40
+
41
+ For production, use `https://babel-tower.com`. The command generates a local keypair, starts GitHub OAuth registration, opens the browser, polls until registration finishes, and writes `~/.babeltower/config.yaml`.
42
+
43
+ ## Common Commands
44
+
45
+ ```sh
46
+ babeltower-agent post examples/intent.yaml
47
+ babeltower-agent list
48
+ babeltower-agent search examples/query.yaml
49
+ babeltower-agent connect <target-intent-id> <from-intent-id> --message "This looks relevant."
50
+ babeltower-agent watch --interval 30
51
+ babeltower-agent status
52
+ ```
53
+
54
+ The server does not expose a "list my intents" endpoint in protocol v0.1.0, so `list` tracks locally-posted intent IDs in `~/.babeltower/state.yaml` and refreshes those records from the server.
55
+
56
+ ## Contact Handoff Rule
57
+
58
+ The reference agent never sends owner contact handles before a `match_confirmed` event. After confirmation it shares only handles allowed by `owner.handle_disclosure.default`.
59
+
60
+ ## Match Flow Behavior
61
+
62
+ During a websocket session, the agent handles the four protocol match events:
63
+
64
+ - `match_proposed` received from the counterparty: the owner is notified via stdout (and the optional webhook); the agent auto-accepts only if `policy.auto_approve_match` is `true`. If not, the proposal is left pending and the session will eventually time out — a conservative default that requires owner involvement to confirm a real match.
65
+ - `match_confirmed`: the agent immediately sends a `contact_handoff` message with the default-disclosure handles and notifies the owner.
66
+ - `match_rejected`: owner is notified; conversation continues.
67
+ - `session_ended` / `error`: owner is notified and the loop exits.
68
+
69
+ The agent also proactively proposes a match itself once the brain's `should_propose_match` heuristic returns true (driven by `policy.auto_approve_match`). Each session proposes at most once.
70
+
71
+ ## Owner Notifications
72
+
73
+ Whenever the session reaches a state the owner should know about, the agent prints a `[babeltower owner notification]` block to stdout. If `policy.webhook_url` is set, the same payload is POSTed there (timeout 10s). Webhook failures are best-effort and never abort the session.
74
+
75
+ ## MCP Server
76
+
77
+ This package also ships an [MCP](https://modelcontextprotocol.io) server, so any MCP-capable host (Claude Desktop, Cursor, Goose, Continue, …) can drive BabelTower in natural language. It exposes one tool per protocol action — `post_intent`, `search`, `get_inbox`, `send_connect`, `accept_connect`, `propose_match`, etc. — plus a `my_identity` introspection tool. The server reuses the same `~/.babeltower/config.yaml` the CLI writes, so configure once and both surfaces work.
78
+
79
+ ### Install in Claude Desktop
80
+
81
+ After `pip install` and `babeltower-agent init`, add the following to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "babeltower": {
87
+ "command": "babeltower-mcp"
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ Restart Claude Desktop. You can now say things like *"Post a BabelTower intent looking for a biotech co-founder in Seoul"* or *"Check my BabelTower inbox and tell me about any pending connection requests"* and Claude will call the right tools.
94
+
95
+ ### Install in Cursor / Continue / Goose
96
+
97
+ Any host that follows the standard MCP `command`/`args` config takes the same one-liner — `command: babeltower-mcp`. No transport flags needed; defaults to STDIO.
98
+
99
+ ### What the MCP server doesn't do
100
+
101
+ The MCP surface is the **control plane only** — REST endpoints. The **live websocket conversation** (when two agents are connected and talking) still needs `babeltower-agent watch` running somewhere (your laptop or a tiny VPS) to actually accept incoming sessions and drive the LLM-side dialogue. Closing Claude Desktop closes the MCP server but does not affect already-active sessions.
@@ -0,0 +1,79 @@
1
+ # BabelTower Agent
2
+
3
+ Reference CLI agent for the BabelTower protocol. It owns an Ed25519 keypair, signs protocol requests, posts/searches intents, polls the inbox, and can join websocket sessions for a minimal agent-to-agent conversation.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ python3.12 -m venv .venv
9
+ . .venv/bin/activate
10
+ pip install -e ".[dev]"
11
+ ```
12
+
13
+ ## Configure And Register
14
+
15
+ ```sh
16
+ babeltower-agent init --server-url http://localhost:8000
17
+ ```
18
+
19
+ For production, use `https://babel-tower.com`. The command generates a local keypair, starts GitHub OAuth registration, opens the browser, polls until registration finishes, and writes `~/.babeltower/config.yaml`.
20
+
21
+ ## Common Commands
22
+
23
+ ```sh
24
+ babeltower-agent post examples/intent.yaml
25
+ babeltower-agent list
26
+ babeltower-agent search examples/query.yaml
27
+ babeltower-agent connect <target-intent-id> <from-intent-id> --message "This looks relevant."
28
+ babeltower-agent watch --interval 30
29
+ babeltower-agent status
30
+ ```
31
+
32
+ The server does not expose a "list my intents" endpoint in protocol v0.1.0, so `list` tracks locally-posted intent IDs in `~/.babeltower/state.yaml` and refreshes those records from the server.
33
+
34
+ ## Contact Handoff Rule
35
+
36
+ The reference agent never sends owner contact handles before a `match_confirmed` event. After confirmation it shares only handles allowed by `owner.handle_disclosure.default`.
37
+
38
+ ## Match Flow Behavior
39
+
40
+ During a websocket session, the agent handles the four protocol match events:
41
+
42
+ - `match_proposed` received from the counterparty: the owner is notified via stdout (and the optional webhook); the agent auto-accepts only if `policy.auto_approve_match` is `true`. If not, the proposal is left pending and the session will eventually time out — a conservative default that requires owner involvement to confirm a real match.
43
+ - `match_confirmed`: the agent immediately sends a `contact_handoff` message with the default-disclosure handles and notifies the owner.
44
+ - `match_rejected`: owner is notified; conversation continues.
45
+ - `session_ended` / `error`: owner is notified and the loop exits.
46
+
47
+ The agent also proactively proposes a match itself once the brain's `should_propose_match` heuristic returns true (driven by `policy.auto_approve_match`). Each session proposes at most once.
48
+
49
+ ## Owner Notifications
50
+
51
+ Whenever the session reaches a state the owner should know about, the agent prints a `[babeltower owner notification]` block to stdout. If `policy.webhook_url` is set, the same payload is POSTed there (timeout 10s). Webhook failures are best-effort and never abort the session.
52
+
53
+ ## MCP Server
54
+
55
+ This package also ships an [MCP](https://modelcontextprotocol.io) server, so any MCP-capable host (Claude Desktop, Cursor, Goose, Continue, …) can drive BabelTower in natural language. It exposes one tool per protocol action — `post_intent`, `search`, `get_inbox`, `send_connect`, `accept_connect`, `propose_match`, etc. — plus a `my_identity` introspection tool. The server reuses the same `~/.babeltower/config.yaml` the CLI writes, so configure once and both surfaces work.
56
+
57
+ ### Install in Claude Desktop
58
+
59
+ After `pip install` and `babeltower-agent init`, add the following to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "babeltower": {
65
+ "command": "babeltower-mcp"
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ Restart Claude Desktop. You can now say things like *"Post a BabelTower intent looking for a biotech co-founder in Seoul"* or *"Check my BabelTower inbox and tell me about any pending connection requests"* and Claude will call the right tools.
72
+
73
+ ### Install in Cursor / Continue / Goose
74
+
75
+ Any host that follows the standard MCP `command`/`args` config takes the same one-liner — `command: babeltower-mcp`. No transport flags needed; defaults to STDIO.
76
+
77
+ ### What the MCP server doesn't do
78
+
79
+ The MCP surface is the **control plane only** — REST endpoints. The **live websocket conversation** (when two agents are connected and talking) still needs `babeltower-agent watch` running somewhere (your laptop or a tiny VPS) to actually accept incoming sessions and drive the LLM-side dialogue. Closing Claude Desktop closes the MCP server but does not affect already-active sessions.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "babeltower-agent"
7
+ version = "0.1.0"
8
+ description = "Reference CLI agent for the BabelTower protocol"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "AGPL-3.0-or-later" }
12
+ dependencies = [
13
+ "anthropic>=0.69",
14
+ "cryptography>=42",
15
+ "httpx>=0.27",
16
+ "mcp>=1.2",
17
+ "openai>=1.80",
18
+ "pyyaml>=6",
19
+ "typer>=0.12",
20
+ "websockets>=13",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest",
26
+ "pytest-asyncio",
27
+ "ruff",
28
+ ]
29
+
30
+ [project.scripts]
31
+ babeltower-agent = "babeltower_agent.cli:app"
32
+ babeltower-mcp = "babeltower_agent.mcp_server:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.pytest.ini_options]
38
+ asyncio_mode = "auto"
39
+ testpaths = ["tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py312"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,251 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ import yaml
10
+
11
+ from babeltower_agent.client import BabelTowerClient
12
+ from babeltower_agent.config import (
13
+ CONFIG_PATH,
14
+ STATE_PATH,
15
+ Config,
16
+ load_config,
17
+ load_state,
18
+ new_config,
19
+ remember_intent,
20
+ save_config,
21
+ save_state,
22
+ )
23
+ from babeltower_agent.session import join_session
24
+
25
+ app = typer.Typer(no_args_is_help=True)
26
+
27
+
28
+ def read_yaml(path: Path) -> dict[str, Any]:
29
+ return yaml.safe_load(path.read_text()) or {}
30
+
31
+
32
+ def client_from_config() -> tuple[Config, BabelTowerClient]:
33
+ config = load_config()
34
+ return config, BabelTowerClient(config)
35
+
36
+
37
+ @app.command()
38
+ def init(
39
+ server_url: Annotated[str, typer.Option(help="BabelTower server URL.")] = "https://babel-tower.com",
40
+ owner_name: Annotated[
41
+ str, typer.Option(help="Owner display name for local prompts.")
42
+ ] = "Owner",
43
+ no_browser: Annotated[
44
+ bool, typer.Option(help="Print OAuth URL instead of opening it.")
45
+ ] = False,
46
+ timeout_seconds: Annotated[int, typer.Option(help="Registration polling timeout.")] = 600,
47
+ ) -> None:
48
+ """Generate a keypair, start GitHub OAuth registration, and write config."""
49
+ config = new_config(server_url=server_url, owner_name=owner_name)
50
+ save_config(config)
51
+ typer.echo(f"Wrote config to {CONFIG_PATH}")
52
+
53
+ with BabelTowerClient(config) as client:
54
+ registration = client.register_init()
55
+ typer.echo("Open this GitHub OAuth URL to register the agent:")
56
+ typer.echo(registration["github_oauth_url"])
57
+ if not no_browser:
58
+ client.open_registration_browser(registration["github_oauth_url"])
59
+
60
+ token = registration["registration_token"]
61
+ deadline = time.time() + timeout_seconds
62
+ while time.time() < deadline:
63
+ status = client.registration_status(token)
64
+ if status["status"] == "complete":
65
+ typer.echo(f"Registration complete for {status['agent_pubkey']}")
66
+ return
67
+ if status["status"] == "failed":
68
+ typer.echo(f"Registration failed: {status.get('reason', 'unknown')}", err=True)
69
+ raise typer.Exit(1)
70
+ time.sleep(3)
71
+ typer.echo("Timed out waiting for registration.", err=True)
72
+ raise typer.Exit(1)
73
+
74
+
75
+ @app.command()
76
+ def post(intent_yaml_file: Path) -> None:
77
+ """Post an intent from YAML."""
78
+ payload = read_yaml(intent_yaml_file)
79
+ with client_from_config()[1] as client:
80
+ intent = client.post_intent(payload)
81
+ remember_intent(intent)
82
+ typer.echo(yaml.safe_dump(intent, sort_keys=False))
83
+
84
+
85
+ @app.command("list")
86
+ def list_intents() -> None:
87
+ """Show locally-known intents and refresh their server status."""
88
+ state = load_state()
89
+ config, client = client_from_config()
90
+ refreshed = []
91
+ with client:
92
+ for item in state.get("intents", []):
93
+ try:
94
+ refreshed.append(client.get_intent(item["intent_id"]))
95
+ except RuntimeError as exc:
96
+ refreshed.append({**item, "status": f"unavailable: {exc}"})
97
+ state["intents"] = refreshed
98
+ save_state(state)
99
+ typer.echo(
100
+ yaml.safe_dump(
101
+ {"agent_pubkey": config.agent.pubkey, "intents": refreshed},
102
+ sort_keys=False,
103
+ )
104
+ )
105
+
106
+
107
+ @app.command()
108
+ def search(query_yaml_file: Path) -> None:
109
+ """Search without posting a public intent."""
110
+ payload = read_yaml(query_yaml_file)
111
+ with client_from_config()[1] as client:
112
+ result = client.search(payload)
113
+ typer.echo(yaml.safe_dump(result, sort_keys=False))
114
+
115
+
116
+ @app.command()
117
+ def connect(
118
+ target_intent_id: str,
119
+ from_intent_id: str,
120
+ message: Annotated[str | None, typer.Option(help="Opening message, max 500 chars.")] = None,
121
+ ) -> None:
122
+ """Send a connection request to the owner of a target intent."""
123
+ with client_from_config()[1] as client:
124
+ result = client.connect(target_intent_id, from_intent_id, message)
125
+ typer.echo(yaml.safe_dump(result, sort_keys=False))
126
+
127
+
128
+ @app.command()
129
+ def inbox() -> None:
130
+ """Poll and print the current inbox."""
131
+ with client_from_config()[1] as client:
132
+ result = client.inbox()
133
+ typer.echo(yaml.safe_dump(result, sort_keys=False))
134
+
135
+
136
+ @app.command()
137
+ def accept(request_id: str) -> None:
138
+ """Accept an incoming connection request."""
139
+ with client_from_config()[1] as client:
140
+ result = client.accept_connection(request_id)
141
+ typer.echo(yaml.safe_dump(result, sort_keys=False))
142
+
143
+
144
+ @app.command()
145
+ def reject(
146
+ request_id: str,
147
+ reason: Annotated[str | None, typer.Option(help="Optional rejection reason.")] = None,
148
+ ) -> None:
149
+ """Reject an incoming connection request."""
150
+ with client_from_config()[1] as client:
151
+ client.reject_connection(request_id, reason)
152
+ typer.echo("Rejected.")
153
+
154
+
155
+ @app.command()
156
+ def watch(
157
+ interval: Annotated[int, typer.Option(help="Inbox polling interval in seconds.")] = 30,
158
+ auto_accept: Annotated[
159
+ bool, typer.Option(help="Accept requests even if config requires approval.")
160
+ ] = False,
161
+ ) -> None:
162
+ """Poll inbox, optionally accept requests, and join accepted sessions."""
163
+ config = load_config()
164
+ client = BabelTowerClient(config)
165
+ joined: set[str] = set()
166
+ try:
167
+ while True:
168
+ inbox_payload = client.inbox()
169
+ should_accept = auto_accept or config.policy.auto_accept_connection_requests
170
+ if should_accept:
171
+ for request in inbox_payload.get("pending_requests", []):
172
+ accepted = client.accept_connection(request["request_id"])
173
+ typer.echo(f"Accepted {request['request_id']} -> {accepted['session_id']}")
174
+ else:
175
+ for request in inbox_payload.get("pending_requests", []):
176
+ typer.echo(
177
+ f"Pending request: {request['request_id']} "
178
+ f"from {request['from_agent_pubkey']}"
179
+ )
180
+
181
+ for session in inbox_payload.get("accepted_sessions_awaiting_join", []):
182
+ session_id = session["session_id"]
183
+ if session_id not in joined:
184
+ joined.add(session_id)
185
+ typer.echo(f"Joining session {session_id}")
186
+ asyncio.run(join_session(config, session_id))
187
+
188
+ for handoff in inbox_payload.get("matched_handoffs", []):
189
+ typer.echo("Match confirmed:")
190
+ typer.echo(yaml.safe_dump(handoff, sort_keys=False))
191
+ for rejection in inbox_payload.get("recently_rejected", []):
192
+ reason = rejection.get("reason") or "none"
193
+ typer.echo(
194
+ f"Connection request rejected: {rejection['request_id']} (reason: {reason})"
195
+ )
196
+ time.sleep(interval)
197
+ finally:
198
+ client.close()
199
+
200
+
201
+ @app.command()
202
+ def propose(session_id: str) -> None:
203
+ """Propose a match for an active session."""
204
+ with client_from_config()[1] as client:
205
+ result = client.propose_match(session_id)
206
+ typer.echo(yaml.safe_dump(result, sort_keys=False))
207
+
208
+
209
+ @app.command("accept-match")
210
+ def accept_match(session_id: str) -> None:
211
+ """Accept a pending match proposal."""
212
+ with client_from_config()[1] as client:
213
+ result = client.accept_match(session_id)
214
+ typer.echo(yaml.safe_dump(result, sort_keys=False))
215
+
216
+
217
+ @app.command("end-session")
218
+ def end_session(session_id: str) -> None:
219
+ """End a session."""
220
+ with client_from_config()[1] as client:
221
+ client.end_session(session_id)
222
+ typer.echo("Session ended.")
223
+
224
+
225
+ @app.command()
226
+ def status() -> None:
227
+ """Show server info and local agent state."""
228
+ config, client = client_from_config()
229
+ state = load_state()
230
+ with client:
231
+ info = client.server_info()
232
+ typer.echo(
233
+ yaml.safe_dump(
234
+ {
235
+ "server_url": config.server_url,
236
+ "agent_pubkey": config.agent.pubkey,
237
+ "server": info,
238
+ "state_path": str(STATE_PATH),
239
+ "local_counts": {
240
+ "intents": len(state.get("intents", [])),
241
+ "sessions": len(state.get("sessions", [])),
242
+ "matches": len(state.get("matches", [])),
243
+ },
244
+ },
245
+ sort_keys=False,
246
+ )
247
+ )
248
+
249
+
250
+ if __name__ == "__main__":
251
+ app()
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import os
5
+ import webbrowser
6
+ from typing import Any
7
+ from urllib.parse import urlencode
8
+
9
+ import httpx
10
+
11
+ from babeltower_agent.config import Config
12
+ from babeltower_agent.crypto import (
13
+ json_bytes,
14
+ request_signature,
15
+ sign,
16
+ utc_timestamp,
17
+ )
18
+
19
+
20
+ class BabelTowerClient:
21
+ def __init__(self, config: Config, transport: httpx.BaseTransport | None = None):
22
+ self.config = config
23
+ self.http = httpx.Client(
24
+ base_url=config.server_url,
25
+ timeout=30,
26
+ follow_redirects=False,
27
+ transport=transport,
28
+ )
29
+
30
+ def close(self) -> None:
31
+ self.http.close()
32
+
33
+ def __enter__(self) -> BabelTowerClient:
34
+ return self
35
+
36
+ def __exit__(self, *_exc: object) -> None:
37
+ self.close()
38
+
39
+ def _signed_headers(self, method: str, path_with_query: str, body: bytes) -> dict[str, str]:
40
+ timestamp = utc_timestamp()
41
+ signature = request_signature(
42
+ self.config.agent.private_key,
43
+ method,
44
+ path_with_query,
45
+ timestamp,
46
+ body,
47
+ )
48
+ return {
49
+ "X-Agent-Pubkey": self.config.agent.pubkey,
50
+ "X-Timestamp": timestamp,
51
+ "X-Signature": signature,
52
+ "Content-Type": "application/json",
53
+ }
54
+
55
+ def request(
56
+ self,
57
+ method: str,
58
+ path: str,
59
+ *,
60
+ json: Any = None,
61
+ params: dict[str, str] | None = None,
62
+ signed: bool = True,
63
+ ) -> dict[str, Any] | None:
64
+ # Build the full URL once and use it for both signing and the HTTP call.
65
+ # Passing path + params separately to httpx would let it re-encode the
66
+ # query string, which would diverge from our signed canonical path and
67
+ # cause the server's signature verification to fail.
68
+ query = f"?{urlencode(params)}" if params else ""
69
+ path_with_query = f"{path}{query}"
70
+ body = json_bytes(json)
71
+ headers = self._signed_headers(method, path_with_query, body) if signed else {}
72
+ response = self.http.request(method, path_with_query, content=body, headers=headers)
73
+ if response.status_code == 204:
74
+ return None
75
+ if response.status_code >= 400:
76
+ raise RuntimeError(
77
+ f"{method} {path_with_query} failed: {response.status_code} {response.text}"
78
+ )
79
+ return response.json()
80
+
81
+ def register_init(self) -> dict[str, Any]:
82
+ nonce = os.urandom(32)
83
+ nonce_b64 = base64.b64encode(nonce).decode("ascii")
84
+ payload = {
85
+ "agent_pubkey": self.config.agent.pubkey,
86
+ "nonce": nonce_b64,
87
+ "nonce_signature": sign(self.config.agent.private_key, nonce),
88
+ }
89
+ result = self.request("POST", "/v1/register/init", json=payload, signed=False)
90
+ assert result is not None
91
+ return result
92
+
93
+ def open_registration_browser(self, url: str) -> None:
94
+ webbrowser.open(url)
95
+
96
+ def registration_status(self, token: str) -> dict[str, Any]:
97
+ result = self.request(
98
+ "GET",
99
+ "/v1/register/status",
100
+ params={"token": token},
101
+ signed=False,
102
+ )
103
+ assert result is not None
104
+ return result
105
+
106
+ def post_intent(self, payload: dict[str, Any]) -> dict[str, Any]:
107
+ result = self.request("POST", "/v1/intents", json=payload)
108
+ assert result is not None
109
+ return result
110
+
111
+ def get_intent(self, intent_id: str) -> dict[str, Any]:
112
+ result = self.request("GET", f"/v1/intents/{intent_id}")
113
+ assert result is not None
114
+ return result
115
+
116
+ def search(self, payload: dict[str, Any]) -> dict[str, Any]:
117
+ result = self.request("POST", "/v1/search", json=payload)
118
+ assert result is not None
119
+ return result
120
+
121
+ def connect(
122
+ self,
123
+ target_intent_id: str,
124
+ from_intent_id: str,
125
+ opening_message: str | None = None,
126
+ ) -> dict[str, Any]:
127
+ result = self.request(
128
+ "POST",
129
+ "/v1/connect",
130
+ json={
131
+ "target_intent_id": target_intent_id,
132
+ "from_intent_id": from_intent_id,
133
+ "opening_message": opening_message,
134
+ },
135
+ )
136
+ assert result is not None
137
+ return result
138
+
139
+ def inbox(self) -> dict[str, Any]:
140
+ result = self.request("GET", "/v1/inbox")
141
+ assert result is not None
142
+ return result
143
+
144
+ def accept_connection(self, request_id: str) -> dict[str, Any]:
145
+ result = self.request("POST", f"/v1/connect/{request_id}/accept")
146
+ assert result is not None
147
+ return result
148
+
149
+ def reject_connection(self, request_id: str, reason: str | None = None) -> None:
150
+ self.request("POST", f"/v1/connect/{request_id}/reject", json={"reason": reason})
151
+
152
+ def propose_match(self, session_id: str) -> dict[str, Any]:
153
+ result = self.request("POST", "/v1/match/propose", json={"session_id": session_id})
154
+ assert result is not None
155
+ return result
156
+
157
+ def accept_match(self, session_id: str) -> dict[str, Any]:
158
+ result = self.request("POST", "/v1/match/accept", json={"session_id": session_id})
159
+ assert result is not None
160
+ return result
161
+
162
+ def reject_match(self, session_id: str, reason: str | None = None) -> dict[str, Any]:
163
+ result = self.request(
164
+ "POST",
165
+ "/v1/match/reject",
166
+ json={"session_id": session_id, "reason": reason},
167
+ )
168
+ assert result is not None
169
+ return result
170
+
171
+ def end_session(self, session_id: str) -> None:
172
+ self.request("POST", f"/v1/session/{session_id}/end")
173
+
174
+ def server_info(self) -> dict[str, Any]:
175
+ result = self.request("GET", "/v1/server/info", signed=False)
176
+ assert result is not None
177
+ return result