agentainer 0.1.6 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +248 -677
- package/agentainer +16 -18
- package/agentainer.example.yaml +86 -0
- package/bin/agentainer.js +9 -8
- package/examples/brainstorm.yaml +27 -128
- package/examples/bug-hunt.yaml +51 -96
- package/examples/code-review.yaml +73 -0
- package/examples/debate.yaml +16 -90
- package/examples/incident-response.yaml +52 -109
- package/examples/localization.yaml +56 -123
- package/examples/quickstart.yaml +48 -0
- package/examples/research.yaml +25 -0
- package/examples/software-company.yaml +71 -128
- package/examples/tdd-pingpong.yaml +36 -68
- package/examples/writers-room.yaml +49 -111
- package/hooks/claude_stop.sh +5 -3
- package/hooks/codex_notify.sh +4 -3
- package/lib/cli.py +929 -0
- package/lib/config.py +247 -305
- package/lib/hooks.py +246 -0
- package/lib/lock.py +75 -0
- package/lib/log.py +64 -0
- package/lib/mail.py +634 -0
- package/lib/minyaml.py +1 -39
- package/lib/reconcile.py +473 -0
- package/lib/sessions.py +223 -0
- package/lib/supervisor.py +216 -0
- package/lib/telegram.py +372 -0
- package/lib/tmux.py +355 -0
- package/lib/turn.py +159 -0
- package/lib/ui.py +1020 -0
- package/llms.txt +145 -429
- package/package.json +9 -7
- package/scripts/check-deps.js +18 -61
- package/ui/app.js +869 -0
- package/ui/index.html +348 -0
- package/agents.example.yaml +0 -257
- package/examples/code-review-broadcast.yaml +0 -109
- package/examples/existing-repo.yaml +0 -74
- package/examples/multi-language-broadcast.yaml +0 -127
- package/examples/ping-pong.yaml +0 -89
- package/examples/red-team.yaml +0 -117
- package/examples/research-swarm.yaml +0 -129
- package/lib/swarm.py +0 -2461
package/lib/telegram.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agentainer -- optional Telegram bridge (mirror mail out, route replies in).
|
|
3
|
+
|
|
4
|
+
This is a **fully optional** integration. When configured (a `telegram:` block
|
|
5
|
+
in ``agentainer.yaml`` with a bot token + chat id), it does two things:
|
|
6
|
+
|
|
7
|
+
1. **Mirror out** -- whenever a message is delivered into an agent's (or the
|
|
8
|
+
``user``'s) queue, a copy is pushed to a Telegram chat, so a human watching
|
|
9
|
+
their phone sees the swarm's mail traffic live. Which agents are mirrored is
|
|
10
|
+
configurable (a list, or ``*`` for all); mail addressed to the ``user`` is
|
|
11
|
+
always mirrored (so the human is reachable even while "away").
|
|
12
|
+
2. **Route replies in** -- a long-poll loop reads Telegram updates; when the
|
|
13
|
+
human **replies** (a Telegram message reply) to a mirrored piece of ``user``
|
|
14
|
+
mail, the reply is routed back into the swarm as ``user`` mail to the
|
|
15
|
+
original sender. A ``/to <agent> <text>`` command works too.
|
|
16
|
+
|
|
17
|
+
Hard invariants (see CLAUDE.md):
|
|
18
|
+
* **Zero runtime dependencies.** stdlib ``urllib`` only -- no ``requests``, no
|
|
19
|
+
telegram SDK. Every network call goes through the single ``_urlopen`` seam so
|
|
20
|
+
tests can mock it with no sockets.
|
|
21
|
+
* **Correctness never depends on the network.** The mirror is best-effort:
|
|
22
|
+
``on_enqueued`` wraps everything and swallows errors, so a down/slow Telegram
|
|
23
|
+
can never wedge or delay-fail the mailroom. The mail is already durably
|
|
24
|
+
queued before we ever touch the network.
|
|
25
|
+
* No secrets are logged. The bot token lives only in the config + the request
|
|
26
|
+
URL we build locally.
|
|
27
|
+
|
|
28
|
+
Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
import urllib.parse
|
|
40
|
+
import urllib.request
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
_LIB = Path(__file__).resolve().parent
|
|
44
|
+
if str(_LIB) not in sys.path:
|
|
45
|
+
sys.path.insert(0, str(_LIB))
|
|
46
|
+
|
|
47
|
+
import lock # noqa: E402
|
|
48
|
+
import log # noqa: E402
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
API_ROOT = "https://api.telegram.org"
|
|
52
|
+
|
|
53
|
+
# How long getUpdates holds the connection open (server-side long poll), and the
|
|
54
|
+
# socket timeout we allow on top of it. Kept small for the mirror path.
|
|
55
|
+
LONG_POLL_S = 25
|
|
56
|
+
MIRROR_TIMEOUT_S = 8
|
|
57
|
+
|
|
58
|
+
# Bodies are trimmed before mirroring so a huge message can't spam the chat.
|
|
59
|
+
MAX_BODY = 1200
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TelegramError(Exception):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --------------------------------------------------------------------------
|
|
67
|
+
# the single network seam (mock THIS in tests -- nothing else touches sockets)
|
|
68
|
+
# --------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _urlopen(url: str, data: bytes | None, timeout: float) -> bytes: # pragma: no cover - the socket boundary; mocked in every test
|
|
72
|
+
"""POST *data* (or GET when None) to *url*; return the raw response body.
|
|
73
|
+
|
|
74
|
+
The ONE place this module opens a socket. Tests monkeypatch it.
|
|
75
|
+
"""
|
|
76
|
+
req = urllib.request.Request(url, data=data, method="POST" if data is not None else "GET")
|
|
77
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - fixed api.telegram.org host
|
|
78
|
+
return resp.read()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def api_call(cfg, method: str, params: dict, timeout: float = MIRROR_TIMEOUT_S):
|
|
82
|
+
"""Call one Telegram Bot API *method* and return its ``result`` payload.
|
|
83
|
+
|
|
84
|
+
Raises ``TelegramError`` on a transport error or an ``ok: false`` response.
|
|
85
|
+
"""
|
|
86
|
+
token = cfg.telegram.bot_token
|
|
87
|
+
if not token:
|
|
88
|
+
raise TelegramError("no bot_token configured")
|
|
89
|
+
url = f"{API_ROOT}/bot{token}/{method}"
|
|
90
|
+
body = urllib.parse.urlencode(params).encode()
|
|
91
|
+
try:
|
|
92
|
+
raw = _urlopen(url, body, timeout)
|
|
93
|
+
except Exception as exc: # noqa: BLE001 - normalise every transport failure
|
|
94
|
+
raise TelegramError(str(exc)) from exc
|
|
95
|
+
try:
|
|
96
|
+
payload = json.loads(raw.decode())
|
|
97
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
98
|
+
raise TelegramError(f"bad response: {exc}") from exc
|
|
99
|
+
if not payload.get("ok"):
|
|
100
|
+
raise TelegramError(f"telegram error: {payload.get('description', 'unknown')}")
|
|
101
|
+
return payload.get("result")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
# config helpers
|
|
106
|
+
# --------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_enabled(cfg) -> bool:
|
|
110
|
+
"""True iff Telegram is switched on AND has the credentials to work."""
|
|
111
|
+
tg = getattr(cfg, "telegram", None)
|
|
112
|
+
return bool(tg and tg.enabled and tg.bot_token and tg.chat_id)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _mirror_set(cfg) -> set:
|
|
116
|
+
m = cfg.telegram.mirror
|
|
117
|
+
if isinstance(m, str):
|
|
118
|
+
return set() # "*" handled separately by _mirror_all
|
|
119
|
+
return set(m)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _mirror_all(cfg) -> bool:
|
|
123
|
+
m = cfg.telegram.mirror
|
|
124
|
+
return m == "*" or (isinstance(m, list) and "*" in m)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _should_mirror(cfg, frm: str, to: str) -> bool:
|
|
128
|
+
"""Decide whether a mail from *frm* to *to* should be pushed to Telegram."""
|
|
129
|
+
tg = cfg.telegram
|
|
130
|
+
if to == "user":
|
|
131
|
+
return bool(tg.mirror_user)
|
|
132
|
+
if frm == "system":
|
|
133
|
+
return bool(tg.mirror_system)
|
|
134
|
+
if _mirror_all(cfg):
|
|
135
|
+
return True
|
|
136
|
+
s = _mirror_set(cfg)
|
|
137
|
+
return frm in s or to in s
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------
|
|
141
|
+
# reply map (Telegram message_id -> the agent whose mail it mirrored)
|
|
142
|
+
# --------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _replymap_path(cfg) -> Path:
|
|
146
|
+
return cfg.run_dir / "telegram.replymap.json"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _load_json(path: Path, default):
|
|
150
|
+
if path.exists():
|
|
151
|
+
try:
|
|
152
|
+
return json.loads(path.read_text())
|
|
153
|
+
except (json.JSONDecodeError, OSError):
|
|
154
|
+
return default
|
|
155
|
+
return default
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _write_atomic(path: Path, text: str) -> None:
|
|
159
|
+
"""Write *text* to *path* atomically, so a lock-free reader (the poller) sees
|
|
160
|
+
either the old or the new file -- never a half-written one."""
|
|
161
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
163
|
+
tmp.write_text(text)
|
|
164
|
+
os.replace(tmp, path)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _record_reply_target(cfg, tg_message_id, agent: str, msg_id: str) -> None:
|
|
168
|
+
"""Remember that Telegram message *tg_message_id* mirrored mail from *agent*,
|
|
169
|
+
so a reply to it can be routed back as ``user`` mail.
|
|
170
|
+
|
|
171
|
+
The read-modify-write is serialised with the cross-process file lock so two
|
|
172
|
+
concurrent mirrors (e.g. a hook process and a ``send``) can't clobber each
|
|
173
|
+
other's entry, and the write is atomic so the poller never reads a torn map.
|
|
174
|
+
"""
|
|
175
|
+
with lock.file_lock(cfg, "telegram", "replymap"):
|
|
176
|
+
path = _replymap_path(cfg)
|
|
177
|
+
data = _load_json(path, {})
|
|
178
|
+
data[str(tg_message_id)] = {"agent": agent, "msg_id": msg_id}
|
|
179
|
+
# Keep the map bounded -- only the most recent 200 mirrored messages.
|
|
180
|
+
if len(data) > 200:
|
|
181
|
+
for k in sorted(data, key=lambda x: int(x))[:-200]:
|
|
182
|
+
data.pop(k, None)
|
|
183
|
+
_write_atomic(path, json.dumps(data))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _reply_target(cfg, tg_message_id):
|
|
187
|
+
return _load_json(_replymap_path(cfg), {}).get(str(tg_message_id))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --------------------------------------------------------------------------
|
|
191
|
+
# header parsing (self-contained: don't import mail to avoid an import cycle)
|
|
192
|
+
# --------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _hdr(text: str, field: str):
|
|
196
|
+
m = re.search(rf"^{re.escape(field)}:\s*(.+)$", text, re.MULTILINE)
|
|
197
|
+
return m.group(1).strip() if m else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _split_body(text: str) -> str:
|
|
201
|
+
parts = text.split("\n\n", 1)
|
|
202
|
+
return parts[1] if len(parts) > 1 else text
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def format_mail(frm: str, to: str, body: str) -> str:
|
|
206
|
+
"""Human-readable one-message Telegram card (plain text, no markup)."""
|
|
207
|
+
icon = "🧑" if frm == "user" else "⚙️" if frm == "system" else "✉️"
|
|
208
|
+
body = body.strip()
|
|
209
|
+
if len(body) > MAX_BODY:
|
|
210
|
+
body = body[:MAX_BODY] + " …"
|
|
211
|
+
header = f"{icon} {frm} → {to}"
|
|
212
|
+
if to == "user":
|
|
213
|
+
header += "\n(reply to this message to answer as the user)"
|
|
214
|
+
return f"{header}\n\n{body}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --------------------------------------------------------------------------
|
|
218
|
+
# outbound: mirror a freshly-queued message
|
|
219
|
+
# --------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def send_message(cfg, text: str, reply_to=None):
|
|
223
|
+
"""Send *text* to the configured chat; return the sent message dict."""
|
|
224
|
+
params = {"chat_id": cfg.telegram.chat_id, "text": text, "disable_web_page_preview": "true"}
|
|
225
|
+
if reply_to is not None:
|
|
226
|
+
params["reply_to_message_id"] = reply_to
|
|
227
|
+
return api_call(cfg, "sendMessage", params)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def on_enqueued(cfg, recipient: str, text: str, msg_id: str) -> None:
|
|
231
|
+
"""Best-effort: mirror a just-queued stamped message to Telegram.
|
|
232
|
+
|
|
233
|
+
Called from ``mail.enqueue`` for EVERY delivered message. Never raises -- the
|
|
234
|
+
mail is already durably queued, so a Telegram failure is logged and dropped.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
if not is_enabled(cfg):
|
|
238
|
+
return
|
|
239
|
+
frm = _hdr(text, "From") or "?"
|
|
240
|
+
to = _hdr(text, "To") or recipient
|
|
241
|
+
if not _should_mirror(cfg, frm, to):
|
|
242
|
+
return
|
|
243
|
+
sent = send_message(cfg, format_mail(frm, to, _split_body(text)))
|
|
244
|
+
if to == "user" and isinstance(sent, dict) and sent.get("message_id") is not None:
|
|
245
|
+
_record_reply_target(cfg, sent["message_id"], frm, msg_id)
|
|
246
|
+
log.log_event(cfg, recipient, "telegram-mirror", from_=frm, to=to, id=msg_id)
|
|
247
|
+
except Exception as exc: # noqa: BLE001 - mirror is best-effort, never fatal
|
|
248
|
+
try:
|
|
249
|
+
log.log_event(cfg, recipient, "telegram-error", error=str(exc)[:200])
|
|
250
|
+
except Exception: # pragma: no cover - logging must never raise here
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --------------------------------------------------------------------------
|
|
255
|
+
# inbound: poll for updates and route replies back into the swarm
|
|
256
|
+
# --------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _offset_path(cfg) -> Path:
|
|
260
|
+
return cfg.run_dir / "telegram.offset.json"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _load_offset(cfg) -> int:
|
|
264
|
+
return int(_load_json(_offset_path(cfg), {}).get("offset", 0))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _save_offset(cfg, offset: int) -> None:
|
|
268
|
+
_write_atomic(_offset_path(cfg), json.dumps({"offset": offset}))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _route_user_reply(cfg, agent: str, text: str) -> bool:
|
|
272
|
+
"""Route a Telegram-originated reply into the swarm as ``user`` mail."""
|
|
273
|
+
if agent not in cfg.names():
|
|
274
|
+
return False
|
|
275
|
+
import mail # lazy: avoids the mail <-> telegram import cycle
|
|
276
|
+
|
|
277
|
+
mail.send_as_user(cfg, agent, text)
|
|
278
|
+
log.log_event(cfg, agent, "telegram-reply", from_="user")
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _process_update(cfg, upd: dict) -> None:
|
|
283
|
+
"""Handle one Telegram update: a reply routes back; ``/to`` routes explicitly."""
|
|
284
|
+
msg = upd.get("message") or upd.get("channel_post")
|
|
285
|
+
if not isinstance(msg, dict):
|
|
286
|
+
return
|
|
287
|
+
text = msg.get("text")
|
|
288
|
+
if not text:
|
|
289
|
+
return
|
|
290
|
+
# Only accept input from the configured chat (a shared bot must not let
|
|
291
|
+
# strangers drive the swarm). is_enabled guarantees chat_id is set.
|
|
292
|
+
chat_id = str((msg.get("chat") or {}).get("id", ""))
|
|
293
|
+
if chat_id != str(cfg.telegram.chat_id):
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
reply = msg.get("reply_to_message")
|
|
297
|
+
if isinstance(reply, dict):
|
|
298
|
+
target = _reply_target(cfg, reply.get("message_id"))
|
|
299
|
+
if target and _route_user_reply(cfg, target["agent"], text):
|
|
300
|
+
send_message(cfg, f"✓ delivered to {target['agent']}")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if text.startswith("/to "):
|
|
304
|
+
parts = text[4:].split(None, 1)
|
|
305
|
+
if len(parts) == 2 and _route_user_reply(cfg, parts[0], parts[1]):
|
|
306
|
+
send_message(cfg, f"✓ delivered to {parts[0]}")
|
|
307
|
+
else:
|
|
308
|
+
send_message(cfg, "usage: /to <agent> <message> (unknown agent otherwise)")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def poll_once(cfg, long_poll: int = 0) -> int:
|
|
312
|
+
"""One getUpdates round; process each update; return how many were handled."""
|
|
313
|
+
if not is_enabled(cfg):
|
|
314
|
+
return 0
|
|
315
|
+
params = {"offset": _load_offset(cfg), "timeout": long_poll}
|
|
316
|
+
updates = api_call(cfg, "getUpdates", params, timeout=long_poll + MIRROR_TIMEOUT_S)
|
|
317
|
+
n = 0
|
|
318
|
+
for upd in updates or []:
|
|
319
|
+
_save_offset(cfg, int(upd["update_id"]) + 1)
|
|
320
|
+
try:
|
|
321
|
+
_process_update(cfg, upd)
|
|
322
|
+
except Exception as exc: # noqa: BLE001 - one bad update can't kill the loop
|
|
323
|
+
log.log_event(cfg, "user", "telegram-error", error=str(exc)[:200])
|
|
324
|
+
n += 1
|
|
325
|
+
return n
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class Poller:
|
|
329
|
+
"""A background long-poll loop that routes Telegram replies into the swarm."""
|
|
330
|
+
|
|
331
|
+
def __init__(self, cfg):
|
|
332
|
+
self.cfg = cfg
|
|
333
|
+
self._stop = threading.Event()
|
|
334
|
+
self._thread = None
|
|
335
|
+
|
|
336
|
+
def _run(self) -> None:
|
|
337
|
+
while not self._stop.is_set():
|
|
338
|
+
try:
|
|
339
|
+
self.cfg = _reloaded(self.cfg)
|
|
340
|
+
poll_once(self.cfg, long_poll=LONG_POLL_S)
|
|
341
|
+
except Exception: # noqa: BLE001 - a transient network error must not kill the loop
|
|
342
|
+
self._stop.wait(3)
|
|
343
|
+
|
|
344
|
+
def start(self) -> "Poller":
|
|
345
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
346
|
+
self._thread.start()
|
|
347
|
+
return self
|
|
348
|
+
|
|
349
|
+
def stop(self) -> None:
|
|
350
|
+
self._stop.set()
|
|
351
|
+
if self._thread is not None:
|
|
352
|
+
self._thread.join(timeout=5)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _reloaded(cfg):
|
|
356
|
+
"""Re-read the config from disk so live edits (token/chat/enable) take effect.
|
|
357
|
+
|
|
358
|
+
Best-effort: if the file is momentarily unreadable, keep the current config.
|
|
359
|
+
"""
|
|
360
|
+
try:
|
|
361
|
+
import config as cfgmod
|
|
362
|
+
|
|
363
|
+
return cfgmod.load(cfg.path)
|
|
364
|
+
except Exception: # pragma: no cover - defensive; a bad edit keeps the old cfg
|
|
365
|
+
return cfg
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def start_poller(cfg):
|
|
369
|
+
"""Start a background reply poller if Telegram is enabled; else return None."""
|
|
370
|
+
if not is_enabled(cfg):
|
|
371
|
+
return None
|
|
372
|
+
return Poller(cfg).start()
|