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.
- babeltower_agent-0.1.0/LICENSE +1 -0
- babeltower_agent-0.1.0/PKG-INFO +101 -0
- babeltower_agent-0.1.0/README.md +79 -0
- babeltower_agent-0.1.0/pyproject.toml +46 -0
- babeltower_agent-0.1.0/setup.cfg +4 -0
- babeltower_agent-0.1.0/src/babeltower_agent/__init__.py +1 -0
- babeltower_agent-0.1.0/src/babeltower_agent/cli.py +251 -0
- babeltower_agent-0.1.0/src/babeltower_agent/client.py +177 -0
- babeltower_agent-0.1.0/src/babeltower_agent/config.py +148 -0
- babeltower_agent-0.1.0/src/babeltower_agent/crypto.py +82 -0
- babeltower_agent-0.1.0/src/babeltower_agent/llm.py +115 -0
- babeltower_agent-0.1.0/src/babeltower_agent/mcp_server.py +340 -0
- babeltower_agent-0.1.0/src/babeltower_agent/prompt.py +41 -0
- babeltower_agent-0.1.0/src/babeltower_agent/session.py +278 -0
- babeltower_agent-0.1.0/src/babeltower_agent.egg-info/PKG-INFO +101 -0
- babeltower_agent-0.1.0/src/babeltower_agent.egg-info/SOURCES.txt +24 -0
- babeltower_agent-0.1.0/src/babeltower_agent.egg-info/dependency_links.txt +1 -0
- babeltower_agent-0.1.0/src/babeltower_agent.egg-info/entry_points.txt +3 -0
- babeltower_agent-0.1.0/src/babeltower_agent.egg-info/requires.txt +13 -0
- babeltower_agent-0.1.0/src/babeltower_agent.egg-info/top_level.txt +1 -0
- babeltower_agent-0.1.0/tests/test_client.py +53 -0
- babeltower_agent-0.1.0/tests/test_config.py +17 -0
- babeltower_agent-0.1.0/tests/test_crypto.py +33 -0
- babeltower_agent-0.1.0/tests/test_mcp_server.py +223 -0
- babeltower_agent-0.1.0/tests/test_prompt.py +14 -0
- 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 @@
|
|
|
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
|