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.
Files changed (44) hide show
  1. package/README.md +248 -677
  2. package/agentainer +16 -18
  3. package/agentainer.example.yaml +86 -0
  4. package/bin/agentainer.js +9 -8
  5. package/examples/brainstorm.yaml +27 -128
  6. package/examples/bug-hunt.yaml +51 -96
  7. package/examples/code-review.yaml +73 -0
  8. package/examples/debate.yaml +16 -90
  9. package/examples/incident-response.yaml +52 -109
  10. package/examples/localization.yaml +56 -123
  11. package/examples/quickstart.yaml +48 -0
  12. package/examples/research.yaml +25 -0
  13. package/examples/software-company.yaml +71 -128
  14. package/examples/tdd-pingpong.yaml +36 -68
  15. package/examples/writers-room.yaml +49 -111
  16. package/hooks/claude_stop.sh +5 -3
  17. package/hooks/codex_notify.sh +4 -3
  18. package/lib/cli.py +929 -0
  19. package/lib/config.py +247 -305
  20. package/lib/hooks.py +246 -0
  21. package/lib/lock.py +75 -0
  22. package/lib/log.py +64 -0
  23. package/lib/mail.py +634 -0
  24. package/lib/minyaml.py +1 -39
  25. package/lib/reconcile.py +473 -0
  26. package/lib/sessions.py +223 -0
  27. package/lib/supervisor.py +216 -0
  28. package/lib/telegram.py +372 -0
  29. package/lib/tmux.py +355 -0
  30. package/lib/turn.py +159 -0
  31. package/lib/ui.py +1020 -0
  32. package/llms.txt +145 -429
  33. package/package.json +9 -7
  34. package/scripts/check-deps.js +18 -61
  35. package/ui/app.js +869 -0
  36. package/ui/index.html +348 -0
  37. package/agents.example.yaml +0 -257
  38. package/examples/code-review-broadcast.yaml +0 -109
  39. package/examples/existing-repo.yaml +0 -74
  40. package/examples/multi-language-broadcast.yaml +0 -127
  41. package/examples/ping-pong.yaml +0 -89
  42. package/examples/red-team.yaml +0 -117
  43. package/examples/research-swarm.yaml +0 -129
  44. package/lib/swarm.py +0 -2461
@@ -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()