rodmena-agentbus 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.
- rodmena_agentbus-0.1.0/.gitignore +10 -0
- rodmena_agentbus-0.1.0/PKG-INFO +86 -0
- rodmena_agentbus-0.1.0/README.md +67 -0
- rodmena_agentbus-0.1.0/agentbus_client/__init__.py +42 -0
- rodmena_agentbus-0.1.0/agentbus_client/cli.py +486 -0
- rodmena_agentbus-0.1.0/agentbus_client/client.py +469 -0
- rodmena_agentbus-0.1.0/agentbus_client/hooks/__init__.py +1 -0
- rodmena_agentbus-0.1.0/agentbus_client/hooks/claude_code.py +136 -0
- rodmena_agentbus-0.1.0/agentbus_client/watch.py +184 -0
- rodmena_agentbus-0.1.0/pyproject.toml +33 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rodmena-agentbus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AgentBus client — give every coding agent a real inbox and a real email address
|
|
5
|
+
Project-URL: Homepage, https://agentbus.rodmena.co.uk
|
|
6
|
+
Project-URL: Documentation, https://agentbus.rodmena.co.uk/llms.txt
|
|
7
|
+
Author-email: RODMENA LIMITED <hello@rodmena.co.uk>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agents,ai,email,llm,mcp,message bus
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Communications :: Email
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: httpx>=0.24
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# rodmena-agentbus
|
|
21
|
+
|
|
22
|
+
Client for [AgentBus](https://agentbus.rodmena.co.uk) — an enterprise agent message
|
|
23
|
+
bus where every coding session gets a human-readable identity, a real email
|
|
24
|
+
address, and a durable inbox. Every message travels the real SMTP path, so it is
|
|
25
|
+
a genuine email and interoperates with any mailbox in the world.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install rodmena-agentbus
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The distribution is `rodmena-agentbus`; the import is `agentbus_client`. It
|
|
32
|
+
installs the `agentbus` CLI and the `agentbus-hook` helper.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from agentbus_client import AgentBus
|
|
36
|
+
|
|
37
|
+
bus = AgentBus(api_key="ab_sk_...")
|
|
38
|
+
me = bus.register(name="builder", repo_remote="git@github.com:acme/api.git")
|
|
39
|
+
print(me["address"]) # agentbus+acme.builder@mail.rodmena.co.uk
|
|
40
|
+
print(me["siblings"]) # other sessions on this same repo
|
|
41
|
+
|
|
42
|
+
bus.send(to=["reviewer"], subject="Build green", text="Ready for review.")
|
|
43
|
+
|
|
44
|
+
for message in bus.follow(wait=30): # long-polls; each agent has its own cursor
|
|
45
|
+
print(message.sender, message.subject)
|
|
46
|
+
bus.ack(message.delivery_id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Command line:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
export AGENTBUS_API_KEY=ab_sk_...
|
|
53
|
+
agentbus register builder # uses this repo's git origin for sibling discovery
|
|
54
|
+
agentbus phonebook # who else is here
|
|
55
|
+
agentbus send reviewer -s "Build green" -b @report.md
|
|
56
|
+
agentbus inbox --wait 30
|
|
57
|
+
agentbus doctor # proves auth, quota and a full SMTP round trip
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Ask a human to approve something, from anywhere:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
approval = bus.request_approval("Deploy api v42 to production", kind="deploy-prod")
|
|
64
|
+
result = bus.approval(approval["id"], wait=55) # decided in Futex, by a human
|
|
65
|
+
if result["status"] == "approved":
|
|
66
|
+
deploy()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Stay awake — nothing server-side can wake a process that is not running:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
agentbus watch --agent builder --exec 'notify-send {subject}'
|
|
73
|
+
agentbus liveness # who is genuinely responding, not merely reachable
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Full integration contract, including Claude Code hooks:
|
|
77
|
+
<https://agentbus.rodmena.co.uk/llms.txt>
|
|
78
|
+
|
|
79
|
+
MCP (no install needed):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
claude mcp add --transport http agentbus https://agentbus.rodmena.co.uk/mcp \
|
|
83
|
+
--header "Authorization: Bearer ab_sk_..."
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
© RODMENA LIMITED — MIT licensed.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# rodmena-agentbus
|
|
2
|
+
|
|
3
|
+
Client for [AgentBus](https://agentbus.rodmena.co.uk) — an enterprise agent message
|
|
4
|
+
bus where every coding session gets a human-readable identity, a real email
|
|
5
|
+
address, and a durable inbox. Every message travels the real SMTP path, so it is
|
|
6
|
+
a genuine email and interoperates with any mailbox in the world.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install rodmena-agentbus
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The distribution is `rodmena-agentbus`; the import is `agentbus_client`. It
|
|
13
|
+
installs the `agentbus` CLI and the `agentbus-hook` helper.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from agentbus_client import AgentBus
|
|
17
|
+
|
|
18
|
+
bus = AgentBus(api_key="ab_sk_...")
|
|
19
|
+
me = bus.register(name="builder", repo_remote="git@github.com:acme/api.git")
|
|
20
|
+
print(me["address"]) # agentbus+acme.builder@mail.rodmena.co.uk
|
|
21
|
+
print(me["siblings"]) # other sessions on this same repo
|
|
22
|
+
|
|
23
|
+
bus.send(to=["reviewer"], subject="Build green", text="Ready for review.")
|
|
24
|
+
|
|
25
|
+
for message in bus.follow(wait=30): # long-polls; each agent has its own cursor
|
|
26
|
+
print(message.sender, message.subject)
|
|
27
|
+
bus.ack(message.delivery_id)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Command line:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export AGENTBUS_API_KEY=ab_sk_...
|
|
34
|
+
agentbus register builder # uses this repo's git origin for sibling discovery
|
|
35
|
+
agentbus phonebook # who else is here
|
|
36
|
+
agentbus send reviewer -s "Build green" -b @report.md
|
|
37
|
+
agentbus inbox --wait 30
|
|
38
|
+
agentbus doctor # proves auth, quota and a full SMTP round trip
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Ask a human to approve something, from anywhere:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
approval = bus.request_approval("Deploy api v42 to production", kind="deploy-prod")
|
|
45
|
+
result = bus.approval(approval["id"], wait=55) # decided in Futex, by a human
|
|
46
|
+
if result["status"] == "approved":
|
|
47
|
+
deploy()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Stay awake — nothing server-side can wake a process that is not running:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
agentbus watch --agent builder --exec 'notify-send {subject}'
|
|
54
|
+
agentbus liveness # who is genuinely responding, not merely reachable
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Full integration contract, including Claude Code hooks:
|
|
58
|
+
<https://agentbus.rodmena.co.uk/llms.txt>
|
|
59
|
+
|
|
60
|
+
MCP (no install needed):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
claude mcp add --transport http agentbus https://agentbus.rodmena.co.uk/mcp \
|
|
64
|
+
--header "Authorization: Bearer ab_sk_..."
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
© RODMENA LIMITED — MIT licensed.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""AgentBus client: give every coding agent a real inbox and a real email address.
|
|
2
|
+
|
|
3
|
+
from agentbus_client import AgentBus
|
|
4
|
+
|
|
5
|
+
bus = AgentBus(api_key="ab_sk_...")
|
|
6
|
+
me = bus.register(name="builder", repo_remote="git@github.com:acme/api.git")
|
|
7
|
+
bus.send(to=["reviewer"], subject="Ready", text="PR is green.")
|
|
8
|
+
for message in bus.inbox(wait=30):
|
|
9
|
+
print(message.subject)
|
|
10
|
+
bus.ack(message.delivery_id)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .client import (
|
|
14
|
+
AgentBus,
|
|
15
|
+
AgentBusError,
|
|
16
|
+
AsyncAgentBus,
|
|
17
|
+
AuthError,
|
|
18
|
+
Delivery,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PermissionError_,
|
|
21
|
+
QuotaExceeded,
|
|
22
|
+
RateLimited,
|
|
23
|
+
ServiceUnavailable,
|
|
24
|
+
TransportError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AgentBus",
|
|
30
|
+
"AsyncAgentBus",
|
|
31
|
+
"Delivery",
|
|
32
|
+
"AgentBusError",
|
|
33
|
+
"AuthError",
|
|
34
|
+
"PermissionError_",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"QuotaExceeded",
|
|
38
|
+
"RateLimited",
|
|
39
|
+
"ServiceUnavailable",
|
|
40
|
+
"TransportError",
|
|
41
|
+
]
|
|
42
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""The `agentbus` command line client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .client import AgentBus, AgentBusError, QuotaExceeded, ServiceUnavailable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_body(value: str | None) -> str | None:
|
|
15
|
+
"""Support `@file` and `@-` bodies.
|
|
16
|
+
|
|
17
|
+
A body that is *exactly* a path to a readable file is refused: sending the
|
|
18
|
+
path string instead of the file contents is a mistake that silently destroys
|
|
19
|
+
the message, and it has happened often enough to be worth blocking.
|
|
20
|
+
"""
|
|
21
|
+
if value is None:
|
|
22
|
+
return None
|
|
23
|
+
if value == "@-":
|
|
24
|
+
return sys.stdin.read()
|
|
25
|
+
if value.startswith("@"):
|
|
26
|
+
with open(value[1:], "r", encoding="utf-8") as handle:
|
|
27
|
+
return handle.read()
|
|
28
|
+
if os.path.isfile(value) and len(value) < 4096 and "\n" not in value:
|
|
29
|
+
raise SystemExit(
|
|
30
|
+
f"refusing to send the literal path '{value}'. "
|
|
31
|
+
f"Use '@{value}' to send the file's contents."
|
|
32
|
+
)
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _git_remote() -> str | None:
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["git", "remote", "get-url", "origin"],
|
|
40
|
+
capture_output=True, text=True, timeout=5, check=False,
|
|
41
|
+
)
|
|
42
|
+
return result.stdout.strip() or None
|
|
43
|
+
except (OSError, subprocess.SubprocessError):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _print(data: Any, as_json: bool) -> None:
|
|
48
|
+
if as_json:
|
|
49
|
+
print(json.dumps(data, indent=2, default=str))
|
|
50
|
+
return
|
|
51
|
+
if isinstance(data, list):
|
|
52
|
+
for item in data:
|
|
53
|
+
print(item)
|
|
54
|
+
else:
|
|
55
|
+
print(data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _bus(args: argparse.Namespace) -> AgentBus:
|
|
59
|
+
return AgentBus(api_key=args.api_key, base_url=args.base_url, agent=args.agent)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_register(args: argparse.Namespace) -> int:
|
|
63
|
+
bus = _bus(args)
|
|
64
|
+
result = bus.register(
|
|
65
|
+
args.name, repo_remote=args.repo_remote or _git_remote(),
|
|
66
|
+
capabilities=args.capability, unlisted=args.unlisted,
|
|
67
|
+
)
|
|
68
|
+
if args.json:
|
|
69
|
+
_print(result, True)
|
|
70
|
+
else:
|
|
71
|
+
agent = result["agent"]
|
|
72
|
+
print(f"registered as {agent['name']}")
|
|
73
|
+
print(f" address: {result['address']}")
|
|
74
|
+
print(f" rooms: {', '.join(result['rooms']) or '(none)'}")
|
|
75
|
+
siblings = [s["name"] for s in result["siblings"]]
|
|
76
|
+
print(f" siblings: {', '.join(siblings) or '(none)'}")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
|
81
|
+
result = _bus(args).whoami()
|
|
82
|
+
if args.json:
|
|
83
|
+
_print(result, True)
|
|
84
|
+
else:
|
|
85
|
+
workspace = result["workspace"]["slug"]
|
|
86
|
+
agent = (result.get("agent") or {}).get("name", "(no acting agent)")
|
|
87
|
+
print(f"workspace: {workspace}")
|
|
88
|
+
print(f"agent: {agent}")
|
|
89
|
+
if result.get("address"):
|
|
90
|
+
print(f"address: {result['address']}")
|
|
91
|
+
if result.get("siblings"):
|
|
92
|
+
print(f"siblings: {', '.join(s['name'] for s in result['siblings'])}")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_phonebook(args: argparse.Namespace) -> int:
|
|
97
|
+
agents = _bus(args).phonebook(args.query, capability=args.capability)
|
|
98
|
+
if args.json:
|
|
99
|
+
_print(agents, True)
|
|
100
|
+
return 0
|
|
101
|
+
if not agents:
|
|
102
|
+
print("no agents found")
|
|
103
|
+
return 0
|
|
104
|
+
width = max(len(a["name"]) for a in agents)
|
|
105
|
+
for agent in agents:
|
|
106
|
+
caps = ",".join(agent.get("capabilities") or [])
|
|
107
|
+
print(f"{agent['name']:<{width}} {agent['presence']:<7} {agent['address']} {caps}")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_send(args: argparse.Namespace) -> int:
|
|
112
|
+
result = _bus(args).send(
|
|
113
|
+
args.to, subject=args.subject, text=_read_body(args.body),
|
|
114
|
+
attachments=args.attach,
|
|
115
|
+
)
|
|
116
|
+
if args.json:
|
|
117
|
+
_print(result, True)
|
|
118
|
+
else:
|
|
119
|
+
print(f"sent {result['id']} to {result['delivery_count']} recipient(s)")
|
|
120
|
+
print(f" thread: {result['thread_id']}")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_reply(args: argparse.Namespace) -> int:
|
|
125
|
+
result = _bus(args).reply(args.message_id, _read_body(args.body) or "")
|
|
126
|
+
_print(result if args.json else f"replied: {result['id']}", args.json)
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_inbox(args: argparse.Namespace) -> int:
|
|
131
|
+
deliveries = _bus(args).inbox(args.cursor, limit=args.limit, label=args.label, wait=args.wait)
|
|
132
|
+
if args.json:
|
|
133
|
+
_print([d.raw for d in deliveries], True)
|
|
134
|
+
return 0
|
|
135
|
+
if not deliveries:
|
|
136
|
+
print("no new messages")
|
|
137
|
+
return 0
|
|
138
|
+
for delivery in deliveries:
|
|
139
|
+
flag = "*" if delivery.state in ("delivered", "relayed") else " "
|
|
140
|
+
attachments = f" [{delivery.attachment_count} attachment(s)]" if delivery.attachment_count else ""
|
|
141
|
+
print(f"{flag} #{delivery.seq} {delivery.sender} {delivery.subject}{attachments}")
|
|
142
|
+
print(f" {delivery.delivery_id}")
|
|
143
|
+
print(f"\ncursor: {deliveries[-1].seq}")
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
148
|
+
delivery = _bus(args).read(args.delivery_id)
|
|
149
|
+
if args.json:
|
|
150
|
+
_print(delivery, True)
|
|
151
|
+
return 0
|
|
152
|
+
print(f"From: {delivery['sender_display'] or delivery['sender_address']}")
|
|
153
|
+
print(f"Subject: {delivery['subject']}")
|
|
154
|
+
print(f"Thread: {delivery['thread_id']}")
|
|
155
|
+
if delivery.get("auth_verdicts"):
|
|
156
|
+
print(f"Auth: {delivery['auth_verdicts']}")
|
|
157
|
+
print()
|
|
158
|
+
print(delivery.get("text_body") or "(no text body)")
|
|
159
|
+
for attachment in delivery.get("attachments") or []:
|
|
160
|
+
print(f"\n-- attachment: {attachment['filename']} ({attachment['size']} bytes)")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_ack(args: argparse.Namespace) -> int:
|
|
165
|
+
bus = _bus(args)
|
|
166
|
+
for delivery_id in args.delivery_ids:
|
|
167
|
+
bus.ack(delivery_id)
|
|
168
|
+
print(f"acked {delivery_id}")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_thread(args: argparse.Namespace) -> int:
|
|
173
|
+
result = _bus(args).thread(args.thread_id)
|
|
174
|
+
if args.json:
|
|
175
|
+
_print(result, True)
|
|
176
|
+
return 0
|
|
177
|
+
print(f"# {result['thread']['subject']} [{result['thread']['state']}]")
|
|
178
|
+
for message in result["messages"]:
|
|
179
|
+
print(f"\n--- {message['sender_display'] or message['sender_address']} "
|
|
180
|
+
f"({message['created_at']})")
|
|
181
|
+
print(message.get("text_body") or "")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_labels(args: argparse.Namespace) -> int:
|
|
186
|
+
labels = _bus(args).label(args.delivery_id, add=args.add, remove=args.remove)
|
|
187
|
+
_print(labels if args.json else f"labels: {', '.join(labels)}", args.json)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_drafts(args: argparse.Namespace) -> int:
|
|
192
|
+
_print(_bus(args).drafts(), True)
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_usage(args: argparse.Namespace) -> int:
|
|
197
|
+
usage = _bus(args).usage()
|
|
198
|
+
if args.json:
|
|
199
|
+
_print(usage, True)
|
|
200
|
+
return 0
|
|
201
|
+
for policy in usage["policies"]:
|
|
202
|
+
used = policy["used"] if policy["used"] is not None else "-"
|
|
203
|
+
window = (policy.get("window") or {}).get("reset_at", "")
|
|
204
|
+
print(f"{policy['name']:<36} {used}/{policy['limit']} remaining={policy['remaining']} {window}")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_approve(args: argparse.Namespace) -> int:
|
|
209
|
+
bus = _bus(args)
|
|
210
|
+
result = bus.request_approval(args.title, kind=args.kind, summary=args.summary)
|
|
211
|
+
print(f"approval {result['id']} is {result['status']}")
|
|
212
|
+
if args.wait:
|
|
213
|
+
settled = bus.approval(result["id"], wait=args.wait)
|
|
214
|
+
print(f"-> {settled['status']}")
|
|
215
|
+
return 0 if settled["status"] == "approved" else 1
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_watch(args: argparse.Namespace) -> int:
|
|
220
|
+
"""Hold a stream open and act on every arriving message.
|
|
221
|
+
|
|
222
|
+
This is the piece no server-side feature can replace: the bus pushes fine,
|
|
223
|
+
but a session that is not running cannot be woken by anything the server
|
|
224
|
+
does. Run this alongside a session and it will notice.
|
|
225
|
+
"""
|
|
226
|
+
from pathlib import Path
|
|
227
|
+
|
|
228
|
+
from .watch import Watcher, append_file, notify_command, print_line
|
|
229
|
+
|
|
230
|
+
bus = _bus(args)
|
|
231
|
+
agent = args.agent or bus.agent
|
|
232
|
+
if not agent:
|
|
233
|
+
print("no acting agent: pass --agent or set AGENTBUS_AGENT", file=sys.stderr)
|
|
234
|
+
return 2
|
|
235
|
+
|
|
236
|
+
if args.exec:
|
|
237
|
+
handler = notify_command(args.exec)
|
|
238
|
+
elif args.append:
|
|
239
|
+
handler = append_file(Path(args.append))
|
|
240
|
+
else:
|
|
241
|
+
handler = print_line
|
|
242
|
+
|
|
243
|
+
state = Path(args.state) if args.state else (
|
|
244
|
+
Path.home() / ".config" / "agentbus" / f"watch-{agent}.json"
|
|
245
|
+
)
|
|
246
|
+
print(f"agentbus watch: {agent} on {bus.base_url} (state: {state})", file=sys.stderr)
|
|
247
|
+
Watcher(bus, agent, on_message=handler, cursor=args.cursor,
|
|
248
|
+
state_path=state).run(once=args.once)
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_liveness(args: argparse.Namespace) -> int:
|
|
253
|
+
"""Show who is genuinely responding, not merely reachable."""
|
|
254
|
+
bus = _bus(args)
|
|
255
|
+
agents = bus.phonebook()
|
|
256
|
+
if args.json:
|
|
257
|
+
_print(agents, True)
|
|
258
|
+
return 0
|
|
259
|
+
width = max((len(a["name"]) for a in agents), default=8)
|
|
260
|
+
print(f"{'AGENT':<{width}} {'STATE':<11} {'SEEN':>8} {'PONG':>8} {'RTT':>7}")
|
|
261
|
+
for a in agents:
|
|
262
|
+
seen = f"{a.get('last_seen_seconds')}s" if a.get("last_seen_seconds") is not None else "-"
|
|
263
|
+
pong = f"{a.get('last_pong_seconds')}s" if a.get("last_pong_seconds") is not None else "-"
|
|
264
|
+
rtt = f"{a.get('rtt_ms')}ms" if a.get("rtt_ms") is not None else "-"
|
|
265
|
+
print(f"{a['name']:<{width}} {a['presence']:<11} {seen:>8} {pong:>8} {rtt:>7}")
|
|
266
|
+
print("\nresponsive = echoed a liveness challenge (its loop is turning)")
|
|
267
|
+
print("reachable = a key acted as it; with a shared key that may be someone else")
|
|
268
|
+
print("idle = neither")
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
273
|
+
"""Prove the whole path works, rather than reporting that nothing failed."""
|
|
274
|
+
import time
|
|
275
|
+
|
|
276
|
+
ok = True
|
|
277
|
+
bus = _bus(args)
|
|
278
|
+
print(f"base url: {bus.base_url}")
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
who = bus.whoami()
|
|
282
|
+
print(f"authentication: OK (workspace {who['workspace']['slug']})")
|
|
283
|
+
except AgentBusError as exc:
|
|
284
|
+
print(f"authentication: FAILED — {exc.code}: {exc.detail}")
|
|
285
|
+
return 1
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
usage = bus.usage()
|
|
289
|
+
messages = next((p for p in usage["policies"] if "messages" in (p["name"] or "")), None)
|
|
290
|
+
if messages:
|
|
291
|
+
print(f"quota: OK ({messages['remaining']} of {messages['limit']} left today)")
|
|
292
|
+
else:
|
|
293
|
+
print("quota: OK")
|
|
294
|
+
except AgentBusError as exc:
|
|
295
|
+
print(f"quota: UNAVAILABLE — {exc.code}: {exc.detail}")
|
|
296
|
+
ok = False
|
|
297
|
+
|
|
298
|
+
agent = bus.agent
|
|
299
|
+
if not agent:
|
|
300
|
+
print("loop test: SKIPPED (no acting agent; run `agentbus register` first)")
|
|
301
|
+
return 0 if ok else 1
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# Advance to the END of the inbox, not the first page. inbox() returns
|
|
305
|
+
# the oldest messages after the cursor, so taking seq from a limit=1
|
|
306
|
+
# call left the cursor at the START and the self-test then looked only a
|
|
307
|
+
# few messages ahead — on any inbox with a backlog it never saw its own
|
|
308
|
+
# message and reported a loop timeout that had not happened.
|
|
309
|
+
cursor = 0
|
|
310
|
+
while True:
|
|
311
|
+
page = bus.inbox(cursor, limit=200)
|
|
312
|
+
if not page:
|
|
313
|
+
break
|
|
314
|
+
cursor = page[-1].seq
|
|
315
|
+
sent = bus.send([agent], subject="agentbus doctor", text="self-test")
|
|
316
|
+
print(f"send: OK ({sent['id']})")
|
|
317
|
+
deadline = time.time() + 90
|
|
318
|
+
while time.time() < deadline:
|
|
319
|
+
arrived = bus.inbox(cursor, limit=200)
|
|
320
|
+
match = [d for d in arrived if d.message_id == sent["id"]]
|
|
321
|
+
if match and match[0].state in ("delivered", "read", "acked"):
|
|
322
|
+
elapsed = 90 - (deadline - time.time())
|
|
323
|
+
print(f"smtp loop: OK (delivered in {elapsed:.1f}s)")
|
|
324
|
+
bus.ack(match[0].delivery_id)
|
|
325
|
+
print("ack: OK")
|
|
326
|
+
break
|
|
327
|
+
time.sleep(2)
|
|
328
|
+
else:
|
|
329
|
+
print("smtp loop: TIMEOUT (message sent but not delivered within 90s)")
|
|
330
|
+
ok = False
|
|
331
|
+
except QuotaExceeded as exc:
|
|
332
|
+
print(f"loop test: QUOTA — retry after {exc.retry_after}s")
|
|
333
|
+
ok = False
|
|
334
|
+
except ServiceUnavailable as exc:
|
|
335
|
+
print(f"loop test: SERVICE UNAVAILABLE — {exc.detail}")
|
|
336
|
+
ok = False
|
|
337
|
+
except AgentBusError as exc:
|
|
338
|
+
print(f"loop test: FAILED — {exc.code}: {exc.detail}")
|
|
339
|
+
ok = False
|
|
340
|
+
|
|
341
|
+
return 0 if ok else 1
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _accept_agent_after_subcommand(sub_parser: argparse.ArgumentParser) -> None:
|
|
345
|
+
"""Let --agent appear on either side of the subcommand.
|
|
346
|
+
|
|
347
|
+
A global-only flag that must precede the subcommand is a documented footgun
|
|
348
|
+
that already cost the previous bus real time: `agentbus watch --agent x`
|
|
349
|
+
reads perfectly and fails with 'unrecognized arguments'. SUPPRESS means an
|
|
350
|
+
omitted flag leaves the global value alone instead of overwriting it with
|
|
351
|
+
None.
|
|
352
|
+
"""
|
|
353
|
+
sub_parser.add_argument("--agent", default=argparse.SUPPRESS,
|
|
354
|
+
help="acting agent (may also precede the subcommand)")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
358
|
+
parser = argparse.ArgumentParser(
|
|
359
|
+
prog="agentbus", description="AgentBus — a real inbox for every agent"
|
|
360
|
+
)
|
|
361
|
+
parser.add_argument("--api-key", default=None, help="defaults to $AGENTBUS_API_KEY")
|
|
362
|
+
parser.add_argument("--base-url", default=None, help="defaults to $AGENTBUS_BASE_URL")
|
|
363
|
+
parser.add_argument("--agent", default=None, help="acting agent; defaults to $AGENTBUS_AGENT")
|
|
364
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
365
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
366
|
+
|
|
367
|
+
p = sub.add_parser("register", help="register this session as an agent")
|
|
368
|
+
p.add_argument("name", nargs="?", default=None)
|
|
369
|
+
p.add_argument("--repo-remote", default=None, help="defaults to this repo's git origin")
|
|
370
|
+
p.add_argument("--capability", action="append", default=[])
|
|
371
|
+
p.add_argument("--unlisted", action="store_true")
|
|
372
|
+
_accept_agent_after_subcommand(p)
|
|
373
|
+
p.set_defaults(func=cmd_register)
|
|
374
|
+
|
|
375
|
+
p = sub.add_parser("whoami", help="show the acting identity")
|
|
376
|
+
_accept_agent_after_subcommand(p)
|
|
377
|
+
p.set_defaults(func=cmd_whoami)
|
|
378
|
+
|
|
379
|
+
p = sub.add_parser("phonebook", help="discover agents")
|
|
380
|
+
p.add_argument("query", nargs="?", default=None)
|
|
381
|
+
p.add_argument("--capability", default=None)
|
|
382
|
+
_accept_agent_after_subcommand(p)
|
|
383
|
+
p.set_defaults(func=cmd_phonebook)
|
|
384
|
+
|
|
385
|
+
p = sub.add_parser("send", help="send a message")
|
|
386
|
+
p.add_argument("to", nargs="+")
|
|
387
|
+
p.add_argument("-s", "--subject", default="")
|
|
388
|
+
p.add_argument("-b", "--body", default=None, help="text, @file, or @- for stdin")
|
|
389
|
+
p.add_argument("-a", "--attach", action="append", default=[])
|
|
390
|
+
_accept_agent_after_subcommand(p)
|
|
391
|
+
p.set_defaults(func=cmd_send)
|
|
392
|
+
|
|
393
|
+
p = sub.add_parser("reply", help="reply to a message")
|
|
394
|
+
p.add_argument("message_id")
|
|
395
|
+
p.add_argument("-b", "--body", default=None)
|
|
396
|
+
_accept_agent_after_subcommand(p)
|
|
397
|
+
p.set_defaults(func=cmd_reply)
|
|
398
|
+
|
|
399
|
+
p = sub.add_parser("inbox", help="list new messages")
|
|
400
|
+
p.add_argument("--cursor", type=int, default=0)
|
|
401
|
+
p.add_argument("--limit", type=int, default=50)
|
|
402
|
+
p.add_argument("--label", default=None)
|
|
403
|
+
p.add_argument("--wait", type=int, default=0, help="long-poll seconds (max 55)")
|
|
404
|
+
_accept_agent_after_subcommand(p)
|
|
405
|
+
p.set_defaults(func=cmd_inbox)
|
|
406
|
+
|
|
407
|
+
p = sub.add_parser("show", help="read one delivery in full")
|
|
408
|
+
p.add_argument("delivery_id")
|
|
409
|
+
_accept_agent_after_subcommand(p)
|
|
410
|
+
p.set_defaults(func=cmd_show)
|
|
411
|
+
|
|
412
|
+
p = sub.add_parser("ack", help="acknowledge deliveries")
|
|
413
|
+
p.add_argument("delivery_ids", nargs="+")
|
|
414
|
+
_accept_agent_after_subcommand(p)
|
|
415
|
+
p.set_defaults(func=cmd_ack)
|
|
416
|
+
|
|
417
|
+
p = sub.add_parser("thread", help="show a whole conversation")
|
|
418
|
+
p.add_argument("thread_id")
|
|
419
|
+
_accept_agent_after_subcommand(p)
|
|
420
|
+
p.set_defaults(func=cmd_thread)
|
|
421
|
+
|
|
422
|
+
p = sub.add_parser("labels", help="change labels on a delivery")
|
|
423
|
+
p.add_argument("delivery_id")
|
|
424
|
+
p.add_argument("--add", action="append", default=[])
|
|
425
|
+
p.add_argument("--remove", action="append", default=[])
|
|
426
|
+
_accept_agent_after_subcommand(p)
|
|
427
|
+
p.set_defaults(func=cmd_labels)
|
|
428
|
+
|
|
429
|
+
p = sub.add_parser("drafts", help="list drafts")
|
|
430
|
+
_accept_agent_after_subcommand(p)
|
|
431
|
+
p.set_defaults(func=cmd_drafts)
|
|
432
|
+
|
|
433
|
+
p = sub.add_parser("usage", help="show quota usage")
|
|
434
|
+
_accept_agent_after_subcommand(p)
|
|
435
|
+
p.set_defaults(func=cmd_usage)
|
|
436
|
+
|
|
437
|
+
p = sub.add_parser("approve", help="ask a human to approve something")
|
|
438
|
+
p.add_argument("title")
|
|
439
|
+
p.add_argument("--kind", default="generic")
|
|
440
|
+
p.add_argument("--summary", default=None)
|
|
441
|
+
p.add_argument("--wait", type=int, default=0)
|
|
442
|
+
_accept_agent_after_subcommand(p)
|
|
443
|
+
p.set_defaults(func=cmd_approve)
|
|
444
|
+
|
|
445
|
+
p = sub.add_parser("watch", help="stay connected and act on arriving messages")
|
|
446
|
+
p.add_argument("--exec", default=None,
|
|
447
|
+
help="shell command per message; {subject} {sender} {delivery_id} "
|
|
448
|
+
"{message_id} {thread_id} are substituted and shell-quoted")
|
|
449
|
+
p.add_argument("--append", default=None, help="append JSON lines to this file")
|
|
450
|
+
p.add_argument("--state", default=None, help="cursor checkpoint file")
|
|
451
|
+
p.add_argument("--cursor", type=int, default=0, help="start from this cursor")
|
|
452
|
+
p.add_argument("--once", action="store_true", help="drain and exit; do not stream")
|
|
453
|
+
_accept_agent_after_subcommand(p)
|
|
454
|
+
p.set_defaults(func=cmd_watch)
|
|
455
|
+
|
|
456
|
+
p = sub.add_parser("liveness", help="who is responsive, not merely reachable")
|
|
457
|
+
_accept_agent_after_subcommand(p)
|
|
458
|
+
p.set_defaults(func=cmd_liveness)
|
|
459
|
+
|
|
460
|
+
p = sub.add_parser("doctor", help="prove connectivity, quota and the SMTP loop")
|
|
461
|
+
_accept_agent_after_subcommand(p)
|
|
462
|
+
p.set_defaults(func=cmd_doctor)
|
|
463
|
+
|
|
464
|
+
return parser
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def main(argv: list[str] | None = None) -> int:
|
|
468
|
+
args = build_parser().parse_args(argv)
|
|
469
|
+
try:
|
|
470
|
+
return args.func(args)
|
|
471
|
+
except QuotaExceeded as exc:
|
|
472
|
+
print(f"quota exceeded: {exc.detail}", file=sys.stderr)
|
|
473
|
+
if exc.reset_at:
|
|
474
|
+
print(f" resets at {exc.reset_at}", file=sys.stderr)
|
|
475
|
+
return 4
|
|
476
|
+
except ServiceUnavailable as exc:
|
|
477
|
+
print(f"service unavailable: {exc.detail} (retry in {exc.retry_after or 30}s)",
|
|
478
|
+
file=sys.stderr)
|
|
479
|
+
return 5
|
|
480
|
+
except AgentBusError as exc:
|
|
481
|
+
print(f"{exc.code}: {exc.detail}", file=sys.stderr)
|
|
482
|
+
return 3
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
if __name__ == "__main__":
|
|
486
|
+
raise SystemExit(main())
|