mail-swarms 1.3.2__py3-none-any.whl

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 (137) hide show
  1. mail/__init__.py +35 -0
  2. mail/api.py +1964 -0
  3. mail/cli.py +432 -0
  4. mail/client.py +1657 -0
  5. mail/config/__init__.py +8 -0
  6. mail/config/client.py +87 -0
  7. mail/config/server.py +165 -0
  8. mail/core/__init__.py +72 -0
  9. mail/core/actions.py +69 -0
  10. mail/core/agents.py +73 -0
  11. mail/core/message.py +366 -0
  12. mail/core/runtime.py +3537 -0
  13. mail/core/tasks.py +311 -0
  14. mail/core/tools.py +1206 -0
  15. mail/db/__init__.py +0 -0
  16. mail/db/init.py +182 -0
  17. mail/db/types.py +65 -0
  18. mail/db/utils.py +523 -0
  19. mail/examples/__init__.py +27 -0
  20. mail/examples/analyst_dummy/__init__.py +15 -0
  21. mail/examples/analyst_dummy/agent.py +136 -0
  22. mail/examples/analyst_dummy/prompts.py +44 -0
  23. mail/examples/consultant_dummy/__init__.py +15 -0
  24. mail/examples/consultant_dummy/agent.py +136 -0
  25. mail/examples/consultant_dummy/prompts.py +42 -0
  26. mail/examples/data_analysis/__init__.py +40 -0
  27. mail/examples/data_analysis/analyst/__init__.py +9 -0
  28. mail/examples/data_analysis/analyst/agent.py +67 -0
  29. mail/examples/data_analysis/analyst/prompts.py +53 -0
  30. mail/examples/data_analysis/processor/__init__.py +13 -0
  31. mail/examples/data_analysis/processor/actions.py +293 -0
  32. mail/examples/data_analysis/processor/agent.py +67 -0
  33. mail/examples/data_analysis/processor/prompts.py +48 -0
  34. mail/examples/data_analysis/reporter/__init__.py +10 -0
  35. mail/examples/data_analysis/reporter/actions.py +187 -0
  36. mail/examples/data_analysis/reporter/agent.py +67 -0
  37. mail/examples/data_analysis/reporter/prompts.py +49 -0
  38. mail/examples/data_analysis/statistics/__init__.py +18 -0
  39. mail/examples/data_analysis/statistics/actions.py +343 -0
  40. mail/examples/data_analysis/statistics/agent.py +67 -0
  41. mail/examples/data_analysis/statistics/prompts.py +60 -0
  42. mail/examples/mafia/__init__.py +0 -0
  43. mail/examples/mafia/game.py +1537 -0
  44. mail/examples/mafia/narrator_tools.py +396 -0
  45. mail/examples/mafia/personas.py +240 -0
  46. mail/examples/mafia/prompts.py +489 -0
  47. mail/examples/mafia/roles.py +147 -0
  48. mail/examples/mafia/spec.md +350 -0
  49. mail/examples/math_dummy/__init__.py +23 -0
  50. mail/examples/math_dummy/actions.py +252 -0
  51. mail/examples/math_dummy/agent.py +136 -0
  52. mail/examples/math_dummy/prompts.py +46 -0
  53. mail/examples/math_dummy/types.py +5 -0
  54. mail/examples/research/__init__.py +39 -0
  55. mail/examples/research/researcher/__init__.py +9 -0
  56. mail/examples/research/researcher/agent.py +67 -0
  57. mail/examples/research/researcher/prompts.py +54 -0
  58. mail/examples/research/searcher/__init__.py +10 -0
  59. mail/examples/research/searcher/actions.py +324 -0
  60. mail/examples/research/searcher/agent.py +67 -0
  61. mail/examples/research/searcher/prompts.py +53 -0
  62. mail/examples/research/summarizer/__init__.py +18 -0
  63. mail/examples/research/summarizer/actions.py +255 -0
  64. mail/examples/research/summarizer/agent.py +67 -0
  65. mail/examples/research/summarizer/prompts.py +55 -0
  66. mail/examples/research/verifier/__init__.py +10 -0
  67. mail/examples/research/verifier/actions.py +337 -0
  68. mail/examples/research/verifier/agent.py +67 -0
  69. mail/examples/research/verifier/prompts.py +52 -0
  70. mail/examples/supervisor/__init__.py +11 -0
  71. mail/examples/supervisor/agent.py +4 -0
  72. mail/examples/supervisor/prompts.py +93 -0
  73. mail/examples/support/__init__.py +33 -0
  74. mail/examples/support/classifier/__init__.py +10 -0
  75. mail/examples/support/classifier/actions.py +307 -0
  76. mail/examples/support/classifier/agent.py +68 -0
  77. mail/examples/support/classifier/prompts.py +56 -0
  78. mail/examples/support/coordinator/__init__.py +9 -0
  79. mail/examples/support/coordinator/agent.py +67 -0
  80. mail/examples/support/coordinator/prompts.py +48 -0
  81. mail/examples/support/faq/__init__.py +10 -0
  82. mail/examples/support/faq/actions.py +182 -0
  83. mail/examples/support/faq/agent.py +67 -0
  84. mail/examples/support/faq/prompts.py +42 -0
  85. mail/examples/support/sentiment/__init__.py +15 -0
  86. mail/examples/support/sentiment/actions.py +341 -0
  87. mail/examples/support/sentiment/agent.py +67 -0
  88. mail/examples/support/sentiment/prompts.py +54 -0
  89. mail/examples/weather_dummy/__init__.py +23 -0
  90. mail/examples/weather_dummy/actions.py +75 -0
  91. mail/examples/weather_dummy/agent.py +136 -0
  92. mail/examples/weather_dummy/prompts.py +35 -0
  93. mail/examples/weather_dummy/types.py +5 -0
  94. mail/factories/__init__.py +27 -0
  95. mail/factories/action.py +223 -0
  96. mail/factories/base.py +1531 -0
  97. mail/factories/supervisor.py +241 -0
  98. mail/net/__init__.py +7 -0
  99. mail/net/registry.py +712 -0
  100. mail/net/router.py +728 -0
  101. mail/net/server_utils.py +114 -0
  102. mail/net/types.py +247 -0
  103. mail/server.py +1605 -0
  104. mail/stdlib/__init__.py +0 -0
  105. mail/stdlib/anthropic/__init__.py +0 -0
  106. mail/stdlib/fs/__init__.py +15 -0
  107. mail/stdlib/fs/actions.py +209 -0
  108. mail/stdlib/http/__init__.py +19 -0
  109. mail/stdlib/http/actions.py +333 -0
  110. mail/stdlib/interswarm/__init__.py +11 -0
  111. mail/stdlib/interswarm/actions.py +208 -0
  112. mail/stdlib/mcp/__init__.py +19 -0
  113. mail/stdlib/mcp/actions.py +294 -0
  114. mail/stdlib/openai/__init__.py +13 -0
  115. mail/stdlib/openai/agents.py +451 -0
  116. mail/summarizer.py +234 -0
  117. mail/swarms_json/__init__.py +27 -0
  118. mail/swarms_json/types.py +87 -0
  119. mail/swarms_json/utils.py +255 -0
  120. mail/url_scheme.py +51 -0
  121. mail/utils/__init__.py +53 -0
  122. mail/utils/auth.py +194 -0
  123. mail/utils/context.py +17 -0
  124. mail/utils/logger.py +73 -0
  125. mail/utils/openai.py +212 -0
  126. mail/utils/parsing.py +89 -0
  127. mail/utils/serialize.py +292 -0
  128. mail/utils/store.py +49 -0
  129. mail/utils/string_builder.py +119 -0
  130. mail/utils/version.py +20 -0
  131. mail_swarms-1.3.2.dist-info/METADATA +237 -0
  132. mail_swarms-1.3.2.dist-info/RECORD +137 -0
  133. mail_swarms-1.3.2.dist-info/WHEEL +4 -0
  134. mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
  135. mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
  136. mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
  137. mail_swarms-1.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +12334 -0
mail/client.py ADDED
@@ -0,0 +1,1657 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import datetime
8
+ import json
9
+ import logging
10
+ import re
11
+ import readline
12
+ import shlex
13
+ from collections.abc import AsyncIterator
14
+ from pathlib import Path
15
+ from typing import Any, Literal, cast
16
+
17
+ import ujson
18
+ from aiohttp import (
19
+ ClientError,
20
+ ClientResponse,
21
+ ClientResponseError,
22
+ ClientSession,
23
+ ClientTimeout,
24
+ ContentTypeError,
25
+ )
26
+ from openai.types.responses import Response
27
+ from rich import console
28
+ from rich.syntax import Syntax
29
+ from sse_starlette import ServerSentEvent
30
+
31
+ import mail.utils as utils
32
+ from mail.config import ClientConfig
33
+ from mail.core.message import MAILInterswarmMessage, MAILMessage
34
+ from mail.net.types import (
35
+ GetHealthResponse,
36
+ GetRootResponse,
37
+ GetStatusResponse,
38
+ GetSwarmsDumpResponse,
39
+ GetSwarmsResponse,
40
+ GetWhoamiResponse,
41
+ PostInterswarmMessageResponse,
42
+ PostMessageResponse,
43
+ PostSwarmsLoadResponse,
44
+ PostSwarmsResponse,
45
+ )
46
+
47
+
48
+ class MAILClient:
49
+ """
50
+ Asynchronous client for interacting with the MAIL HTTP API.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ url: str,
56
+ api_key: str | None = None,
57
+ session: ClientSession | None = None,
58
+ config: ClientConfig | None = None,
59
+ ) -> None:
60
+ self.base_url = url.rstrip("/")
61
+ self.api_key = api_key
62
+ if config is None:
63
+ config = ClientConfig()
64
+ self.verbose = config.verbose
65
+ if self.verbose:
66
+ self.logger = logging.getLogger("mail.client")
67
+ else:
68
+ self.logger = logging.getLogger("mailquiet.client")
69
+
70
+ timeout_float = float(config.timeout)
71
+ self._timeout = ClientTimeout(total=timeout_float)
72
+ self._session = session
73
+ self._owns_session = session is None
74
+ self._console = console.Console()
75
+ self.user_id = "unknown"
76
+ self.user_role = "unknown"
77
+
78
+ def _log_prelude(self) -> str:
79
+ """
80
+ Get the log prelude for the client.
81
+ """
82
+ return f"[{self.user_role}:{self.user_id}@{self.base_url}]"
83
+
84
+ async def _register_user_info(self) -> None:
85
+ """
86
+ Attempt to login and fetch user info.
87
+ """
88
+ try:
89
+ self.user_info = await self._request_json("POST", "/auth/login")
90
+ self.user_role = self.user_info["role"]
91
+ self.user_id = self.user_info["id"]
92
+ except Exception as e:
93
+ self.logger.error(f"{self._log_prelude()} error registering user info: {e}")
94
+
95
+ async def __aenter__(self) -> MAILClient:
96
+ await self._ensure_session()
97
+ return self
98
+
99
+ async def __aexit__(self, *_exc_info: Any) -> None:
100
+ await self.aclose()
101
+
102
+ async def aclose(self) -> None:
103
+ if self._owns_session and self._session is not None:
104
+ await self._session.close()
105
+ self._session = None
106
+
107
+ async def _ensure_session(self) -> ClientSession:
108
+ """
109
+ Ensure a session exists by creating one if it doesn't.
110
+ """
111
+ if self._session is None:
112
+ session_kwargs: dict[str, Any] = {}
113
+ if self._timeout is not None:
114
+ session_kwargs["timeout"] = self._timeout
115
+ self._session = ClientSession(**session_kwargs)
116
+
117
+ return self._session
118
+
119
+ def _build_url(self, path: str) -> str:
120
+ """
121
+ Build the URL for the HTTP request, given `self.base_url` and `path`.
122
+ """
123
+ return f"{self.base_url}/{path.lstrip('/')}"
124
+
125
+ def _build_headers(
126
+ self,
127
+ extra: dict[str, str] | None = None,
128
+ ignore_auth: bool = False,
129
+ ) -> dict[str, str]:
130
+ """
131
+ Build headers for the HTTP request.
132
+ """
133
+ headers: dict[str, str] = {
134
+ "Accept": "application/json",
135
+ "User-Agent": f"MAIL-Client/{utils.get_version()} (github.com/charonlabs/mail)",
136
+ }
137
+
138
+ if self.api_key and not ignore_auth:
139
+ headers["Authorization"] = f"Bearer {self.api_key}"
140
+ if extra:
141
+ headers.update(extra)
142
+
143
+ return headers
144
+
145
+ async def _request_json(
146
+ self,
147
+ method: str,
148
+ path: str,
149
+ *,
150
+ payload: dict[str, Any] | None = None,
151
+ headers: dict[str, str] | None = None,
152
+ ignore_auth: bool = False,
153
+ ) -> Any:
154
+ """
155
+ Make a request to a remote MAIL swarm via HTTP.
156
+ """
157
+ session = await self._ensure_session()
158
+ url = self._build_url(path)
159
+ self.logger.debug(f"{self._log_prelude()} {method.upper()} {url}")
160
+
161
+ try:
162
+ async with session.request(
163
+ method,
164
+ url,
165
+ json=payload,
166
+ headers=self._build_headers(headers, ignore_auth),
167
+ ) as response:
168
+ response.raise_for_status()
169
+ return await self._read_json(response)
170
+ except ClientResponseError as e:
171
+ self.logger.error(
172
+ f"{self._log_prelude()} HTTP request failed with status code {e.status}: '{e.message}'"
173
+ )
174
+ raise RuntimeError(
175
+ f"HTTP request failed with status code {e.status}: '{e.message}'"
176
+ )
177
+ except Exception as e:
178
+ self.logger.error(
179
+ f"{self._log_prelude()} exception during request to remote HTTP, aborting"
180
+ )
181
+ raise RuntimeError(f"MAIL client request failed: {e}")
182
+
183
+ @staticmethod
184
+ async def _read_json(response: ClientResponse) -> Any:
185
+ """
186
+ Read the JSON body from the HTTP response.
187
+ """
188
+ try:
189
+ return await response.json()
190
+ except ContentTypeError as exc:
191
+ text = await response.text()
192
+ raise ValueError(
193
+ f"expected JSON response but received content with type '{response.content_type}': {text}"
194
+ ) from exc
195
+
196
+ async def ping(self) -> GetRootResponse:
197
+ """
198
+ Get basic metadata about the MAIL server (`GET /`).
199
+ """
200
+ return cast(GetRootResponse, await self._request_json("GET", "/"))
201
+
202
+ async def get_health(self) -> GetHealthResponse:
203
+ """
204
+ Get the health of the MAIL server (`GET /health`).
205
+ """
206
+ return cast(GetHealthResponse, await self._request_json("GET", "/health"))
207
+
208
+ async def update_health(self, status: str) -> GetHealthResponse:
209
+ """
210
+ Update the health of the MAIL server (`POST /health`).
211
+ """
212
+ return cast(
213
+ GetHealthResponse,
214
+ await self._request_json("POST", "/health", payload={"status": status}),
215
+ )
216
+
217
+ async def login(self, api_key: str) -> dict[str, Any]:
218
+ """
219
+ Log in to the MAIL server given an API key.
220
+ """
221
+ self.api_key = api_key
222
+ try:
223
+ response = await self._request_json("GET", "/whoami")
224
+ self.user_role = response["role"]
225
+ self.user_id = response["id"]
226
+ return response
227
+ except Exception as e:
228
+ self.api_key = None
229
+ self.logger.error(f"{self._log_prelude()} error logging in: {e}")
230
+ raise RuntimeError(f"MAIL client login failed: {e}")
231
+
232
+ async def get_whoami(self) -> GetWhoamiResponse:
233
+ """
234
+ Get the username and role of the caller (`GET /whoami`).
235
+ """
236
+ return cast(GetWhoamiResponse, await self._request_json("GET", "/whoami"))
237
+
238
+ async def get_status(self) -> GetStatusResponse:
239
+ """
240
+ Get the status of the MAIL server (`GET /status`).
241
+ """
242
+ return cast(GetStatusResponse, await self._request_json("GET", "/status"))
243
+
244
+ async def post_message(
245
+ self,
246
+ body: str,
247
+ subject: str = "New Message",
248
+ msg_type: Literal["request", "response", "broadcast", "interrupt"] = "request",
249
+ *,
250
+ entrypoint: str | None = None,
251
+ show_events: bool = False,
252
+ task_id: str | None = None,
253
+ resume_from: Literal["user_response", "breakpoint_tool_call"] | None = None,
254
+ **kwargs: Any,
255
+ ) -> PostMessageResponse:
256
+ """
257
+ Queue a user-scoped task, optionally returning runtime events or an SSE stream (`POST /message`).
258
+ """
259
+ payload: dict[str, Any] = {
260
+ "subject": subject,
261
+ "body": body,
262
+ "msg_type": msg_type,
263
+ "entrypoint": entrypoint,
264
+ "show_events": show_events,
265
+ "task_id": task_id,
266
+ "resume_from": resume_from,
267
+ "kwargs": kwargs,
268
+ }
269
+
270
+ return cast(
271
+ PostMessageResponse,
272
+ await self._request_json("POST", "/message", payload=payload),
273
+ )
274
+
275
+ async def post_message_stream(
276
+ self,
277
+ body: str,
278
+ subject: str = "New Message",
279
+ msg_type: Literal["request", "response", "broadcast", "interrupt"] = "request",
280
+ *,
281
+ entrypoint: str | None = None,
282
+ task_id: str | None = None,
283
+ resume_from: Literal["user_response", "breakpoint_tool_call"] | None = None,
284
+ **kwargs: Any,
285
+ ) -> AsyncIterator[ServerSentEvent]:
286
+ """
287
+ Queue a user-scoped task, optionally returning runtime events or an SSE stream (`POST /message`).
288
+ """
289
+ session = await self._ensure_session()
290
+
291
+ payload: dict[str, Any] = {
292
+ "subject": subject,
293
+ "body": body,
294
+ "msg_type": msg_type,
295
+ "entrypoint": entrypoint,
296
+ "stream": True,
297
+ "task_id": task_id,
298
+ "resume_from": resume_from,
299
+ "kwargs": kwargs,
300
+ }
301
+
302
+ url = self._build_url("/message")
303
+ self.logger.debug(f"{self._log_prelude()} POST {url} (stream)")
304
+
305
+ try:
306
+ response = await session.post(
307
+ url,
308
+ json=payload,
309
+ headers=self._build_headers({"Accept": "text/event-stream"}),
310
+ )
311
+ except Exception as e:
312
+ self.logger.error(
313
+ f"{self._log_prelude()} exception in POST request, aborting"
314
+ )
315
+ raise RuntimeError(f"MAIL client request failed: {e}")
316
+
317
+ try:
318
+ response.raise_for_status()
319
+ except Exception as e:
320
+ self.logger.error(
321
+ f"{self._log_prelude()} exception in POST response, aborting"
322
+ )
323
+ response.close()
324
+ raise RuntimeError(f"MAIL client request failed: {e}") from e
325
+
326
+ async def _event_stream() -> AsyncIterator[ServerSentEvent]:
327
+ try:
328
+ async for event in self._iterate_sse(response):
329
+ yield event
330
+ finally:
331
+ response.close()
332
+
333
+ return _event_stream()
334
+
335
+ async def _iterate_sse(
336
+ self,
337
+ response: ClientResponse,
338
+ ) -> AsyncIterator[ServerSentEvent]:
339
+ """
340
+ Minimal SSE parser to stitch chunked bytes into ServerSentEvent instances.
341
+ """
342
+ buffer = ""
343
+ try:
344
+ async for chunk in response.content.iter_any():
345
+ buffer += chunk.decode("utf-8", errors="replace")
346
+ if "\r" in buffer:
347
+ buffer = buffer.replace("\r\n", "\n").replace("\r", "\n")
348
+
349
+ while "\n\n" in buffer:
350
+ raw_event, buffer = buffer.split("\n\n", 1)
351
+ if not raw_event.strip():
352
+ continue
353
+ event_kwargs: dict[str, Any] = {}
354
+ data_lines: list[str] = []
355
+ for line in raw_event.splitlines():
356
+ if not line or line.startswith(":"):
357
+ continue
358
+ field, _, value = line.partition(":")
359
+ value = value.lstrip(" ")
360
+ if field == "data":
361
+ data_lines.append(value)
362
+ elif field == "event":
363
+ event_kwargs["event"] = value
364
+ elif field == "id":
365
+ event_kwargs["id"] = value
366
+ elif field == "retry":
367
+ try:
368
+ event_kwargs["retry"] = int(value)
369
+ except ValueError:
370
+ pass
371
+ data_payload = "\n".join(data_lines) if data_lines else None
372
+ event_kwargs.setdefault("event", "message")
373
+ yield ServerSentEvent(data=data_payload, **event_kwargs)
374
+ except (TimeoutError, ClientError) as e:
375
+ self.logger.warning(f"SSE stream interrupted: {e}")
376
+ # Process any remaining complete events in the buffer before returning
377
+ while "\n\n" in buffer:
378
+ raw_event, buffer = buffer.split("\n\n", 1)
379
+ if not raw_event.strip():
380
+ continue
381
+ event_kwargs = {}
382
+ data_lines = []
383
+ for line in raw_event.splitlines():
384
+ if not line or line.startswith(":"):
385
+ continue
386
+ field, _, value = line.partition(":")
387
+ value = value.lstrip(" ")
388
+ if field == "data":
389
+ data_lines.append(value)
390
+ elif field == "event":
391
+ event_kwargs["event"] = value
392
+ elif field == "id":
393
+ event_kwargs["id"] = value
394
+ data_payload = "\n".join(data_lines) if data_lines else None
395
+ event_kwargs.setdefault("event", "message")
396
+ yield ServerSentEvent(data=data_payload, **event_kwargs)
397
+
398
+ async def get_swarms(self) -> GetSwarmsResponse:
399
+ """
400
+ Get the swarms of the MAIL server (`GET /swarms`).
401
+ """
402
+ return cast(GetSwarmsResponse, await self._request_json("GET", "/swarms"))
403
+
404
+ async def register_swarm(
405
+ self,
406
+ name: str,
407
+ base_url: str,
408
+ *,
409
+ auth_token: str | None = None,
410
+ volatile: bool = True,
411
+ metadata: dict[str, Any] | None = None,
412
+ ) -> PostSwarmsResponse:
413
+ """
414
+ Register a swarm with the MAIL server (`POST /swarms`).
415
+ """
416
+ payload: dict[str, Any] = {
417
+ "name": name,
418
+ "base_url": base_url,
419
+ "volatile": volatile,
420
+ }
421
+
422
+ if auth_token is not None:
423
+ payload["auth_token"] = auth_token
424
+ if metadata is not None:
425
+ payload["metadata"] = metadata
426
+
427
+ return cast(
428
+ PostSwarmsResponse,
429
+ await self._request_json("POST", "/swarms", payload=payload),
430
+ )
431
+
432
+ async def dump_swarm(self) -> GetSwarmsDumpResponse:
433
+ """
434
+ Dump the swarm of the MAIL server (`GET /swarms/dump`).
435
+ """
436
+ return cast(
437
+ GetSwarmsDumpResponse,
438
+ await self._request_json("GET", "/swarms/dump"),
439
+ )
440
+
441
+ async def post_interswarm_message(
442
+ self,
443
+ message: MAILInterswarmMessage,
444
+ ) -> MAILMessage:
445
+ """
446
+ Post an interswarm message to the MAIL server (`POST /interswarm/message`).
447
+ """
448
+ payload = dict(message)
449
+
450
+ response = await self._request_json(
451
+ "POST",
452
+ "/interswarm/message",
453
+ payload=payload,
454
+ )
455
+
456
+ return cast(MAILMessage, response)
457
+
458
+ async def post_interswarm_response(
459
+ self,
460
+ message: MAILMessage,
461
+ ) -> PostInterswarmMessageResponse:
462
+ """
463
+ Post an interswarm response to the MAIL server (`POST /interswarm/response`).
464
+ """
465
+ payload = dict(message)
466
+
467
+ return cast(
468
+ PostInterswarmMessageResponse,
469
+ await self._request_json(
470
+ "POST",
471
+ "/interswarm/response",
472
+ payload=payload,
473
+ ),
474
+ )
475
+
476
+ async def send_interswarm_message(
477
+ self,
478
+ body: str,
479
+ user_token: str,
480
+ subject: str | None = None,
481
+ targets: list[str] | None = None,
482
+ msg_type: str | None = None,
483
+ task_id: str | None = None,
484
+ routing_info: dict[str, Any] | None = None,
485
+ stream: bool | None = None,
486
+ ignore_stream_pings: bool | None = None,
487
+ ) -> PostInterswarmMessageResponse:
488
+ """
489
+ Send an interswarm message to the MAIL server (`POST /interswarm/send`).
490
+ """
491
+ payload: dict[str, Any] = {
492
+ "body": body,
493
+ "user_token": user_token,
494
+ }
495
+
496
+ if targets is not None:
497
+ payload["targets"] = targets
498
+ if subject is not None:
499
+ payload["subject"] = subject
500
+ if msg_type is not None:
501
+ payload["msg_type"] = msg_type
502
+ if task_id is not None:
503
+ payload["task_id"] = task_id
504
+ if routing_info is not None:
505
+ payload["routing_info"] = routing_info
506
+ if stream is not None:
507
+ payload["stream"] = stream
508
+ if ignore_stream_pings is not None:
509
+ payload["ignore_stream_pings"] = ignore_stream_pings
510
+
511
+ return cast(
512
+ PostInterswarmMessageResponse,
513
+ await self._request_json(
514
+ "POST",
515
+ "/interswarm/message",
516
+ payload=payload,
517
+ ),
518
+ )
519
+
520
+ async def load_swarm_from_json(
521
+ self,
522
+ swarm_json: str,
523
+ ) -> PostSwarmsLoadResponse:
524
+ """
525
+ Load a swarm from a JSON document (`POST /swarms/load`).
526
+ """
527
+ payload = {"json": swarm_json}
528
+
529
+ return cast(
530
+ PostSwarmsLoadResponse,
531
+ await self._request_json(
532
+ "POST",
533
+ "/swarms/load",
534
+ payload=payload,
535
+ ),
536
+ )
537
+
538
+ async def debug_post_responses(
539
+ self,
540
+ input: list[dict[str, Any]],
541
+ tools: list[dict[str, Any]],
542
+ instructions: str | None = None,
543
+ previous_response_id: str | None = None,
544
+ tool_choice: str | dict[str, Any] | None = None,
545
+ parallel_tool_calls: bool | None = None,
546
+ **kwargs: Any,
547
+ ) -> Response:
548
+ """
549
+ Post a responses request to the MAIL server in the form of an OpenAI `/responses`-style API call.
550
+ """
551
+ payload: dict[str, Any] = {
552
+ "input": input,
553
+ "tools": tools,
554
+ }
555
+
556
+ if instructions is not None:
557
+ payload["instructions"] = instructions
558
+ if previous_response_id is not None:
559
+ payload["previous_response_id"] = previous_response_id
560
+ if tool_choice is not None:
561
+ payload["tool_choice"] = tool_choice
562
+ if parallel_tool_calls is not None:
563
+ payload["parallel_tool_calls"] = parallel_tool_calls
564
+ if kwargs:
565
+ payload["kwargs"] = kwargs
566
+
567
+ return Response.model_validate_json(
568
+ await self._request_json("POST", "/responses", payload=payload)
569
+ )
570
+
571
+ async def get_tasks(self) -> dict[str, Any]:
572
+ """
573
+ Get the list of tasks for this caller.
574
+ """
575
+ return await self._request_json("GET", "/tasks")
576
+
577
+ async def get_task(self, task_id: str) -> dict[str, Any]:
578
+ """
579
+ Get a specific task for this caller.
580
+ """
581
+ return await self._request_json("GET", "/task", payload={"task_id": task_id})
582
+
583
+
584
+ class MAILClientCLI:
585
+ """
586
+ CLI for interacting with the MAIL server.
587
+ """
588
+
589
+ def __init__(
590
+ self,
591
+ args: argparse.Namespace,
592
+ config: ClientConfig | None = None,
593
+ ) -> None:
594
+ self.args = args
595
+ self._config = config or ClientConfig()
596
+ self.verbose = args.verbose
597
+ self.client = MAILClient(
598
+ args.url,
599
+ api_key=args.api_key,
600
+ config=self._config,
601
+ )
602
+ self.parser = self._build_parser()
603
+ self._prompt_console = console.Console(force_terminal=True)
604
+
605
+ # Initialize readline history
606
+ self._history_file = Path.home() / ".mail_history"
607
+ try:
608
+ readline.read_history_file(self._history_file)
609
+ except (FileNotFoundError, OSError):
610
+ pass
611
+ readline.set_history_length(1000)
612
+
613
+ def _build_parser(self) -> argparse.ArgumentParser:
614
+ """
615
+ Build the argument parser for the MAIL client.
616
+ """
617
+ parser = argparse.ArgumentParser(
618
+ prog="", # to make usage examples work inside the REPL
619
+ description="Interact with a remote MAIL server",
620
+ epilog="For more information, see `README.md` and `docs/`",
621
+ )
622
+
623
+ # subparsers for each MAIL command
624
+ subparsers = parser.add_subparsers()
625
+
626
+ # command `ping`
627
+ ping_parser = subparsers.add_parser(
628
+ "ping", aliases=["p"], help="ping the MAIL server"
629
+ )
630
+ ping_parser.add_argument(
631
+ "-v",
632
+ "--verbose",
633
+ action="store_true",
634
+ help="view the full JSON response for `GET /`",
635
+ )
636
+ ping_parser.set_defaults(func=self._ping)
637
+
638
+ # command `health`
639
+ health_parser = subparsers.add_parser(
640
+ "health", aliases=["h"], help="get the health of the MAIL server"
641
+ )
642
+ health_parser.add_argument(
643
+ "-v",
644
+ "--verbose",
645
+ action="store_true",
646
+ help="view the full JSON response for `GET /health`",
647
+ )
648
+ health_parser.set_defaults(func=self._health)
649
+
650
+ # command `health-update`
651
+ health_update_parser = subparsers.add_parser(
652
+ "health-update",
653
+ aliases=["hu"],
654
+ help="(admin) update the health of the MAIL server",
655
+ )
656
+ health_update_parser.add_argument(
657
+ "status",
658
+ type=str,
659
+ help="the status of the MAIL server",
660
+ )
661
+ health_update_parser.add_argument(
662
+ "-v",
663
+ "--verbose",
664
+ action="store_true",
665
+ help="view the full JSON response for `POST /health`",
666
+ )
667
+ health_update_parser.set_defaults(func=self._health_update)
668
+
669
+ # command `login`
670
+ login_parser = subparsers.add_parser(
671
+ "login", aliases=["l"], help="log in to the MAIL server"
672
+ )
673
+ login_parser.add_argument(
674
+ "api_key",
675
+ type=str,
676
+ help="the API key to log in with",
677
+ )
678
+ login_parser.add_argument(
679
+ "-v",
680
+ "--verbose",
681
+ action="store_true",
682
+ help="enable verbose output",
683
+ )
684
+ login_parser.set_defaults(func=self._login)
685
+
686
+ # command `logout`
687
+ logout_parser = subparsers.add_parser(
688
+ "logout", aliases=["lo"], help="(user|admin) log out of the MAIL server"
689
+ )
690
+ logout_parser.set_defaults(func=self._logout)
691
+
692
+ # command `whoami`
693
+ whoami_parser = subparsers.add_parser(
694
+ "whoami",
695
+ aliases=["me", "id"],
696
+ help="(user|admin) get the username and role of the caller",
697
+ )
698
+ whoami_parser.add_argument(
699
+ "-v",
700
+ "--verbose",
701
+ action="store_true",
702
+ help="view the full JSON response for `GET /whoami`",
703
+ )
704
+ whoami_parser.set_defaults(func=self._whoami)
705
+
706
+ # command `status`
707
+ status_parser = subparsers.add_parser(
708
+ "status",
709
+ aliases=["s"],
710
+ help="(user|admin) view the status of the user runtime within the MAIL server",
711
+ )
712
+ status_parser.add_argument(
713
+ "-v",
714
+ "--verbose",
715
+ action="store_true",
716
+ help="view the full JSON response for `GET /status`",
717
+ )
718
+ status_parser.set_defaults(func=self._status)
719
+
720
+ # command `message`
721
+ message_parser = subparsers.add_parser(
722
+ "message",
723
+ aliases=["m", "msg"],
724
+ help="(user|admin) send a message to the MAIL server",
725
+ )
726
+ message_parser.add_argument(
727
+ "body",
728
+ type=str,
729
+ help="the message to send",
730
+ )
731
+ message_parser.add_argument(
732
+ "-s",
733
+ "--subject",
734
+ type=str,
735
+ required=False,
736
+ default="New Message",
737
+ help="the subject of the message",
738
+ )
739
+ message_parser.add_argument(
740
+ "-t",
741
+ "--msg-type",
742
+ type=str,
743
+ required=False,
744
+ default="request",
745
+ help="the type of the message",
746
+ )
747
+ message_parser.add_argument(
748
+ "-tid",
749
+ "--task-id",
750
+ type=str,
751
+ required=False,
752
+ default=None,
753
+ help="the task ID of the message",
754
+ )
755
+ message_parser.add_argument(
756
+ "-e",
757
+ "--entrypoint",
758
+ type=str,
759
+ required=False,
760
+ default=None,
761
+ help="the agent to send the message to",
762
+ )
763
+ message_parser.add_argument(
764
+ "-se",
765
+ "--show-events",
766
+ action="store_true",
767
+ required=False,
768
+ default=False,
769
+ help="show events",
770
+ )
771
+ message_parser.add_argument(
772
+ "-rf",
773
+ "--resume-from",
774
+ type=str,
775
+ required=False,
776
+ default=None,
777
+ help="the resume from of the message",
778
+ )
779
+ message_parser.add_argument(
780
+ "-k",
781
+ "--kwargs",
782
+ type=json.loads,
783
+ required=False,
784
+ default=f"{{}}", # noqa: F541
785
+ help="the kwargs of the message",
786
+ )
787
+ message_parser.set_defaults(func=self._message)
788
+
789
+ # command `message-stream`
790
+ message_stream_parser = subparsers.add_parser(
791
+ "message-stream",
792
+ aliases=["ms", "msg-s"],
793
+ help="(user|admin) send a message to the MAIL server and stream the response",
794
+ )
795
+ message_stream_parser.add_argument(
796
+ "body",
797
+ type=str,
798
+ help="the message to send",
799
+ )
800
+ message_stream_parser.add_argument(
801
+ "-s",
802
+ "--subject",
803
+ type=str,
804
+ required=False,
805
+ default="New Message",
806
+ help="the subject of the message",
807
+ )
808
+ message_stream_parser.add_argument(
809
+ "-t",
810
+ "--msg-type",
811
+ type=str,
812
+ required=False,
813
+ default="request",
814
+ help="the type of the message",
815
+ )
816
+ message_stream_parser.add_argument(
817
+ "-tid",
818
+ "--task-id",
819
+ type=str,
820
+ required=False,
821
+ default=None,
822
+ help="the task ID of the message",
823
+ )
824
+ message_stream_parser.add_argument(
825
+ "-e",
826
+ "--entrypoint",
827
+ type=str,
828
+ required=False,
829
+ default=None,
830
+ help="the agent to send the message to",
831
+ )
832
+ message_stream_parser.add_argument(
833
+ "-rf",
834
+ "--resume-from",
835
+ type=str,
836
+ required=False,
837
+ default=None,
838
+ help="the resume from of the message",
839
+ )
840
+ message_stream_parser.add_argument(
841
+ "-k",
842
+ "--kwargs",
843
+ type=json.loads,
844
+ required=False,
845
+ default=f"{{}}", # noqa: F541
846
+ help="the kwargs of the message",
847
+ )
848
+ message_stream_parser.set_defaults(func=self._message_stream)
849
+
850
+ # command `message-interswarm`
851
+ message_interswarm_parser = subparsers.add_parser(
852
+ "message-interswarm",
853
+ aliases=["mi", "msg-i"],
854
+ help="(user|admin) send an interswarm message through this MAIL server",
855
+ )
856
+ message_interswarm_parser.add_argument(
857
+ "body",
858
+ type=str,
859
+ help="the message to send",
860
+ )
861
+ message_interswarm_parser.add_argument(
862
+ "targets",
863
+ type=list[str],
864
+ help="the target agent to send the message to",
865
+ )
866
+ message_interswarm_parser.add_argument(
867
+ "user_token",
868
+ type=str,
869
+ help="the user token to send the message with",
870
+ )
871
+ message_interswarm_parser.set_defaults(func=self._message_interswarm)
872
+
873
+ # command `swarms-get`
874
+ swarms_get_parser = subparsers.add_parser(
875
+ "swarms-get",
876
+ aliases=["sg", "swarms-g"],
877
+ help="(user|admin) get the list of foreign swarms known by this MAIL server",
878
+ )
879
+ swarms_get_parser.add_argument(
880
+ "-v",
881
+ "--verbose",
882
+ action="store_true",
883
+ help="view the full JSON response for `GET /swarms`",
884
+ )
885
+ swarms_get_parser.set_defaults(func=self._swarms_get)
886
+
887
+ # command `swarm-register`
888
+ swarm_register_parser = subparsers.add_parser(
889
+ "swarm-register",
890
+ aliases=["sr", "swarm-r"],
891
+ help="(admin) register a foreign swarm with the MAIL server",
892
+ )
893
+ swarm_register_parser.add_argument(
894
+ "name",
895
+ type=str,
896
+ help="the name of the swarm",
897
+ )
898
+ swarm_register_parser.add_argument(
899
+ "base_url",
900
+ type=str,
901
+ help="the base URL of the swarm",
902
+ )
903
+ swarm_register_parser.add_argument(
904
+ "auth_token",
905
+ type=str,
906
+ help="the auth token of the swarm",
907
+ )
908
+ swarm_register_parser.add_argument(
909
+ "-V",
910
+ "--volatile",
911
+ action="store_true",
912
+ help="whether the swarm is volatile",
913
+ )
914
+ swarm_register_parser.add_argument(
915
+ "-v",
916
+ "--verbose",
917
+ action="store_true",
918
+ help="view the full JSON response for `POST /swarms`",
919
+ )
920
+ swarm_register_parser.set_defaults(func=self._swarm_register)
921
+
922
+ # command `swarm-dump`
923
+ swarm_dump_parser = subparsers.add_parser(
924
+ "swarm-dump",
925
+ aliases=["sd", "swarm-d"],
926
+ help="(admin) dump the persistent swarm of this MAIL server",
927
+ )
928
+ swarm_dump_parser.add_argument(
929
+ "-v",
930
+ "--verbose",
931
+ action="store_true",
932
+ help="view the full JSON response for `GET /swarms/dump`",
933
+ )
934
+ swarm_dump_parser.set_defaults(func=self._swarm_dump)
935
+
936
+ # command `swarm-load-from-json`
937
+ swarm_load_from_json_parser = subparsers.add_parser(
938
+ "swarm-load-from-json",
939
+ aliases=["sl", "swarm-l"],
940
+ help="(admin) load a swarm from a JSON string",
941
+ )
942
+ swarm_load_from_json_parser.add_argument(
943
+ "swarm_json",
944
+ type=str,
945
+ help="the JSON string to load the swarm from",
946
+ )
947
+ swarm_load_from_json_parser.add_argument(
948
+ "-v",
949
+ "--verbose",
950
+ action="store_true",
951
+ help="view the full JSON response for `POST /swarms/load`",
952
+ )
953
+ swarm_load_from_json_parser.set_defaults(func=self._swarm_load_from_json)
954
+
955
+ # command `responses`
956
+ responses_parser = subparsers.add_parser(
957
+ "responses",
958
+ aliases=["r", "resp"],
959
+ help="(user|admin) (debug only) post a responses request to the MAIL server",
960
+ )
961
+ responses_parser.add_argument(
962
+ "input",
963
+ type=json.loads,
964
+ help="the input to the responses request",
965
+ )
966
+ responses_parser.add_argument(
967
+ "tools",
968
+ type=json.loads,
969
+ help="the tools to the responses request",
970
+ )
971
+ responses_parser.add_argument(
972
+ "-i",
973
+ "--instructions",
974
+ type=str,
975
+ help="the instructions to the responses request",
976
+ )
977
+ responses_parser.add_argument(
978
+ "-pr",
979
+ "--previous-response-id",
980
+ type=str,
981
+ help="the previous response ID to the responses request",
982
+ )
983
+ responses_parser.add_argument(
984
+ "-tc",
985
+ "--tool-choice",
986
+ type=str,
987
+ help="the tool choice to the responses request",
988
+ )
989
+ responses_parser.add_argument(
990
+ "-ptc",
991
+ "--parallel-tool-calls",
992
+ action=argparse.BooleanOptionalAction,
993
+ default=None,
994
+ help="whether to parallel tool calls",
995
+ )
996
+ responses_parser.add_argument(
997
+ "-k",
998
+ "--kwargs",
999
+ type=json.loads,
1000
+ help="the kwargs to the responses request",
1001
+ default=f"{{}}", # noqa: F541
1002
+ )
1003
+ responses_parser.add_argument(
1004
+ "-v",
1005
+ "--verbose",
1006
+ action="store_true",
1007
+ help="view the full JSON response for `POST /responses`",
1008
+ )
1009
+ responses_parser.set_defaults(func=self._debug_post_responses)
1010
+
1011
+ # command `tasks-get`
1012
+ tasks_get_parser = subparsers.add_parser(
1013
+ "tasks-get",
1014
+ aliases=["tsg", "tasks-g"],
1015
+ help="(user|admin) get the list of tasks for this caller",
1016
+ )
1017
+ tasks_get_parser.add_argument(
1018
+ "-v",
1019
+ "--verbose",
1020
+ action="store_true",
1021
+ help="view the full JSON response for `GET /tasks`",
1022
+ )
1023
+ tasks_get_parser.set_defaults(func=self._tasks_get)
1024
+
1025
+ # command `task-get`
1026
+ task_get_parser = subparsers.add_parser(
1027
+ "task-get",
1028
+ aliases=["tg", "task-g"],
1029
+ help="(user|admin) get a specific task for this caller",
1030
+ )
1031
+ task_get_parser.add_argument(
1032
+ "task_id",
1033
+ type=str,
1034
+ help="the ID of the task to get",
1035
+ )
1036
+ task_get_parser.add_argument(
1037
+ "-v",
1038
+ "--verbose",
1039
+ action="store_true",
1040
+ help="view the full JSON response for `GET /task`",
1041
+ )
1042
+ task_get_parser.set_defaults(func=self._task_get)
1043
+
1044
+ return parser
1045
+
1046
+ async def _ping(self, args: argparse.Namespace) -> None:
1047
+ """
1048
+ Get the root of the MAIL server.
1049
+ """
1050
+ try:
1051
+ response = await self.client.ping()
1052
+ if args.verbose:
1053
+ self.client._console.print(json.dumps(response, indent=2))
1054
+ else:
1055
+ self.client._console.print("pong")
1056
+ except Exception as e:
1057
+ self.client._console.print(f"[red bold]error[/red bold] pinging: {e}")
1058
+
1059
+ async def _health(self, args: argparse.Namespace) -> None:
1060
+ """
1061
+ Get the health of the MAIL server.
1062
+ """
1063
+ try:
1064
+ response = await self.client.get_health()
1065
+ if args.verbose:
1066
+ self.client._console.print(json.dumps(response, indent=2))
1067
+ else:
1068
+ self.client._console.print(
1069
+ f"health: [green]{response['status']}[/green]"
1070
+ )
1071
+ except Exception as e:
1072
+ self.client._console.print(
1073
+ f"[red bold]error[/red bold] getting health: {e}"
1074
+ )
1075
+
1076
+ async def _health_update(self, args: argparse.Namespace) -> None:
1077
+ """
1078
+ Update the health of the MAIL server.
1079
+ """
1080
+ try:
1081
+ response = await self.client.update_health(args.status)
1082
+ if args.verbose:
1083
+ self.client._console.print(json.dumps(response, indent=2))
1084
+ else:
1085
+ self.client._console.print(
1086
+ f"[green]successfully[/green] updated health to [green]{response['status']}[/green]"
1087
+ )
1088
+ except Exception as e:
1089
+ self.client._console.print(
1090
+ f"[red bold]error[/red bold] updating health: {e}"
1091
+ )
1092
+
1093
+ async def _login(self, args: argparse.Namespace) -> None:
1094
+ """
1095
+ Log in to the MAIL server.
1096
+ """
1097
+ try:
1098
+ response = await self.client.login(args.api_key)
1099
+ self.user_role = response["role"]
1100
+ self.user_id = response["id"]
1101
+ if args.verbose:
1102
+ self.client._console.print(json.dumps(response, indent=2))
1103
+ else:
1104
+ self.client._console.print(
1105
+ f"[green]successfully[/green] logged into {self.client.base_url}"
1106
+ )
1107
+ self.client._console.print(f"> role: [green]{self.user_role}[/green]")
1108
+ self.client._console.print(f"> id: [green]{self.user_id}[/green]")
1109
+ except Exception as e:
1110
+ self.client._console.print(f"[red bold]error[/red bold] logging in: {e}")
1111
+
1112
+ async def _logout(self, args: argparse.Namespace) -> None:
1113
+ """
1114
+ Log out of the MAIL server.
1115
+ """
1116
+ if self.user_role not in {"user", "admin"}:
1117
+ self.client._console.print(
1118
+ "[red bold]error[/red bold] logging out: not currently logged in"
1119
+ )
1120
+ return
1121
+
1122
+ self.client.api_key = None
1123
+ self.api_key = None
1124
+
1125
+ self.client.user_role = "unknown"
1126
+ self.client.user_id = "unknown"
1127
+ self.user_role = "unknown"
1128
+ self.user_id = "unknown"
1129
+
1130
+ self.client._console.print(
1131
+ f"[green]successfully[/green] logged out of {self.client.base_url}"
1132
+ )
1133
+
1134
+ async def _whoami(self, args: argparse.Namespace) -> None:
1135
+ """
1136
+ Get the username and role of the caller.
1137
+ """
1138
+ try:
1139
+ response = await self.client.get_whoami()
1140
+ if args.verbose:
1141
+ self.client._console.print(json.dumps(response, indent=2))
1142
+ else:
1143
+ self.client._console.print(
1144
+ f"role [green]{response['role']}[/green] with ID [green]{response['id']}[/green]"
1145
+ )
1146
+ except Exception as e:
1147
+ self.client._console.print(
1148
+ f"[red bold]error[/red bold] getting whoami: {e}"
1149
+ )
1150
+
1151
+ async def _status(self, args: argparse.Namespace) -> None:
1152
+ """
1153
+ Get the status the user within the MAIL server.
1154
+ """
1155
+ try:
1156
+ response = await self.client.get_status()
1157
+ if args.verbose:
1158
+ self.client._console.print(json.dumps(response, indent=2))
1159
+ else:
1160
+ self.client._console.print(
1161
+ f"user MAIL {'[green]IS[/green]' if response['user_mail_ready'] else '[red]IS NOT[/red]'} ready"
1162
+ )
1163
+ self.client._console.print(
1164
+ f"user task {'[green]IS[/green]' if response['user_task_running'] else '[red]IS NOT[/red]'} running"
1165
+ )
1166
+ except Exception as e:
1167
+ self.client._console.print(
1168
+ f"[red bold]error[/red bold] getting status: {e}"
1169
+ )
1170
+
1171
+ async def _message(self, args: argparse.Namespace) -> None:
1172
+ """
1173
+ Post a message to the MAIL server.
1174
+ """
1175
+ try:
1176
+ response = await self.client.post_message(
1177
+ body=args.body,
1178
+ subject=args.subject or "New Message",
1179
+ msg_type=args.msg_type,
1180
+ entrypoint=args.entrypoint,
1181
+ show_events=args.show_events,
1182
+ task_id=args.task_id,
1183
+ resume_from=args.resume_from,
1184
+ **args.kwargs,
1185
+ )
1186
+ self.client._console.print(
1187
+ json.dumps(response, indent=2, ensure_ascii=False)
1188
+ )
1189
+ self._print_embedded_xml(response)
1190
+ except Exception as e:
1191
+ self.client._console.print(
1192
+ f"[red bold]error[/red bold] posting message: {e}"
1193
+ )
1194
+
1195
+ async def _message_stream(self, args: argparse.Namespace) -> None:
1196
+ """
1197
+ Post a message to the MAIL server and stream the response.
1198
+ """
1199
+ try:
1200
+ response = await self.client.post_message_stream(
1201
+ body=args.body,
1202
+ subject=args.subject or "New Message",
1203
+ msg_type=args.msg_type,
1204
+ entrypoint=args.entrypoint,
1205
+ task_id=args.task_id,
1206
+ resume_from=args.resume_from,
1207
+ **args.kwargs,
1208
+ )
1209
+ except Exception as e:
1210
+ self.client._console.print(
1211
+ f"[red bold]error[/red bold] connecting to server: {e}"
1212
+ )
1213
+ return
1214
+
1215
+ try:
1216
+ async for event in response:
1217
+ try:
1218
+ event_dict = {
1219
+ "event": event.event,
1220
+ "data": event.data,
1221
+ }
1222
+ self.client._console.print(self._strip_event(event_dict))
1223
+ except Exception as e:
1224
+ self.client._console.print(
1225
+ f"[yellow]warning: failed to process event: {e}[/yellow]"
1226
+ )
1227
+ continue
1228
+ except Exception as e:
1229
+ self.client._console.print(
1230
+ f"[red bold]error[/red bold] streaming response: {e}"
1231
+ )
1232
+
1233
+ async def _swarms_get(self, args: argparse.Namespace) -> None:
1234
+ """
1235
+ Get the swarms of the MAIL server.
1236
+ """
1237
+ try:
1238
+ response = await self.client.get_swarms()
1239
+ if args.verbose:
1240
+ self.client._console.print(json.dumps(response, indent=2))
1241
+ else:
1242
+ self.client._console.print(f"found {len(response['swarms'])} swarms:")
1243
+ for swarm in response["swarms"]:
1244
+ self.client._console.print(
1245
+ f"{swarm['swarm_name']}@{swarm['base_url']}"
1246
+ )
1247
+ except Exception as e:
1248
+ self.client._console.print(
1249
+ f"[red bold]error[/red bold] getting swarms: {e}"
1250
+ )
1251
+
1252
+ async def _swarm_register(self, args: argparse.Namespace) -> None:
1253
+ """
1254
+ Register a swarm with the MAIL server.
1255
+ """
1256
+ try:
1257
+ response = await self.client.register_swarm(
1258
+ args.name,
1259
+ args.base_url,
1260
+ auth_token=args.auth_token,
1261
+ volatile=args.volatile,
1262
+ metadata=None,
1263
+ )
1264
+ if args.verbose:
1265
+ self.client._console.print(json.dumps(response, indent=2))
1266
+ else:
1267
+ self.client._console.print(f"swarm {args.name} registered")
1268
+ except Exception as e:
1269
+ self.client._console.print(
1270
+ f"[red bold]error[/red bold] registering swarm: {e}"
1271
+ )
1272
+
1273
+ async def _swarm_dump(self, args: argparse.Namespace) -> None:
1274
+ """
1275
+ Dump the swarm of the MAIL server.
1276
+ """
1277
+ try:
1278
+ response = await self.client.dump_swarm()
1279
+ if args.verbose:
1280
+ self.client._console.print(json.dumps(response, indent=2))
1281
+ else:
1282
+ self.client._console.print(f"swarm '{response['swarm_name']}' dumped")
1283
+ except Exception as e:
1284
+ self.client._console.print(f"[red bold]error[/red bold] dumping swarm: {e}")
1285
+
1286
+ async def _message_interswarm(self, args: argparse.Namespace) -> None:
1287
+ """
1288
+ Send an interswarm message to the MAIL server.
1289
+ """
1290
+ try:
1291
+ response = await self.client.send_interswarm_message(
1292
+ args.body, args.targets, args.user_token
1293
+ )
1294
+ self.client._console.print(json.dumps(response, indent=2))
1295
+ except Exception as e:
1296
+ self.client._console.print(
1297
+ f"[red bold]error[/red bold] sending interswarm message: {e}"
1298
+ )
1299
+
1300
+ async def _swarm_load_from_json(self, args: argparse.Namespace) -> None:
1301
+ """
1302
+ Load a swarm from a JSON string.
1303
+ """
1304
+ try:
1305
+ response = await self.client.load_swarm_from_json(args.swarm_json)
1306
+ if args.verbose:
1307
+ self.client._console.print(json.dumps(response, indent=2))
1308
+ else:
1309
+ self.client._console.print(f"swarm '{response['swarm_name']}' loaded")
1310
+ except Exception as e:
1311
+ self.client._console.print(
1312
+ f"[red bold]error[/red bold] loading swarm from JSON: {e}"
1313
+ )
1314
+
1315
+ async def _debug_post_responses(self, args: argparse.Namespace) -> None:
1316
+ """
1317
+ Post a responses request to the MAIL server in the form of an OpenAI `/responses`-style API call.
1318
+ """
1319
+ try:
1320
+ tool_choice = args.tool_choice
1321
+ if tool_choice is not None:
1322
+ try:
1323
+ tool_choice = json.loads(tool_choice)
1324
+ except (TypeError, json.JSONDecodeError):
1325
+ pass
1326
+
1327
+ response = await self.client.debug_post_responses(
1328
+ args.input,
1329
+ args.tools,
1330
+ args.instructions,
1331
+ args.previous_response_id,
1332
+ tool_choice,
1333
+ args.parallel_tool_calls,
1334
+ **(args.kwargs or {}),
1335
+ )
1336
+
1337
+ if args.verbose:
1338
+ self.client._console.print(response.model_dump())
1339
+ else:
1340
+ self.client._console.print(f"response ID: [green]{response.id}[/green]")
1341
+ self.client._console.print(
1342
+ f"response created at: [green]{response.created_at}[/green]"
1343
+ )
1344
+ self.client._console.print(
1345
+ f"response model: [green]{response.model}[/green]"
1346
+ )
1347
+ self.client._console.print(
1348
+ f"response object: [green]{response.object}[/green]"
1349
+ )
1350
+ self.client._console.print(
1351
+ f"response tools: [green]{response.tools}[/green]"
1352
+ )
1353
+ self.client._console.print(
1354
+ f"response output: [green]{response.output}[/green]"
1355
+ )
1356
+ self.client._console.print(
1357
+ f"response parallel tool calls: [green]{response.parallel_tool_calls}[/green]"
1358
+ )
1359
+ self.client._console.print(
1360
+ f"response tool choice: [green]{response.tool_choice}[/green]"
1361
+ )
1362
+ except Exception as e:
1363
+ self.client._console.print(
1364
+ f"[red bold]error[/red bold] posting responses: {e}"
1365
+ )
1366
+
1367
+ async def _tasks_get(self, args: argparse.Namespace) -> None:
1368
+ """
1369
+ Get the list of tasks for this caller.
1370
+ """
1371
+ try:
1372
+ response = await self.client.get_tasks()
1373
+ if args.verbose:
1374
+ self.client._console.print(json.dumps(response, indent=2))
1375
+ else:
1376
+ self.client._console.print(f"found {len(response)} tasks:")
1377
+ for task_id, task in response.items():
1378
+ self.client._console.print(
1379
+ f"{task_id} - completed: {task.get('completed', 'unknown')}"
1380
+ )
1381
+ self.client._console.print(" - events:")
1382
+ for event in self._strip_events(task.get("events", "unknown")):
1383
+ self.client._console.print(event)
1384
+ except Exception as e:
1385
+ self.client._console.print(f"[red bold]error[/red bold] getting tasks: {e}")
1386
+
1387
+ async def _task_get(self, args: argparse.Namespace) -> None:
1388
+ """
1389
+ Get a specific task for this caller.
1390
+ """
1391
+ try:
1392
+ response = await self.client.get_task(args.task_id)
1393
+ if args.verbose:
1394
+ self.client._console.print(json.dumps(response, indent=2))
1395
+ else:
1396
+ self.client._console.print(
1397
+ f"task '{response['task_id']}' - completed: {response.get('completed', 'unknown')}"
1398
+ )
1399
+ self.client._console.print(" - events:")
1400
+ for event in self._strip_events(response.get("events", "unknown")):
1401
+ self.client._console.print(event)
1402
+ except Exception as e:
1403
+ self.client._console.print(f"[red bold]error[/red bold] getting task: {e}")
1404
+
1405
+ def _strip_event(self, event: Any) -> str:
1406
+ """
1407
+ Strip the event from the task.
1408
+ """
1409
+ if isinstance(event, str):
1410
+ return event
1411
+
1412
+ event_type = event.get("event")
1413
+ data = event.get("data")
1414
+ if data is None:
1415
+ return "unknown"
1416
+ payload: Any = data
1417
+ if isinstance(payload, str):
1418
+ if payload == "":
1419
+ return "unknown"
1420
+
1421
+ try:
1422
+ payload = ujson.loads(payload)
1423
+ except (ujson.JSONDecodeError, ValueError):
1424
+ # Fallback for single-quoted dict strings emitted by some runtimes.
1425
+ payload = payload.replace('"', "::tmp::")
1426
+ payload = payload.replace("'", '"')
1427
+ payload = payload.replace("::tmp::", "'")
1428
+ payload = payload.replace("None", '"unknown"')
1429
+ try:
1430
+ payload = ujson.loads(payload)
1431
+ except (ujson.JSONDecodeError, ValueError):
1432
+ if event_type == "task_complete":
1433
+ timestamp = datetime.datetime.now(datetime.UTC).isoformat()
1434
+ return f"\t{timestamp} - {payload}"
1435
+ return payload
1436
+
1437
+ if not isinstance(payload, dict):
1438
+ return "unknown"
1439
+
1440
+ timestamp = payload.get("timestamp", "unknown")
1441
+ description = payload.get("description")
1442
+ if not description:
1443
+ description = payload.get("response")
1444
+ if not description:
1445
+ description = "unknown (possible ping)"
1446
+
1447
+ return f"\t{timestamp} - {description}"
1448
+
1449
+ def _strip_events(self, events: Any) -> list[str]:
1450
+ """
1451
+ Strip the events from the task.
1452
+ """
1453
+ if isinstance(events, str):
1454
+ if events == "unknown":
1455
+ return []
1456
+ return [events]
1457
+
1458
+ events_list: list[str] = []
1459
+ for event in events:
1460
+ events_list.append(self._strip_event(event))
1461
+ return events_list
1462
+
1463
+ def _print_preamble(self) -> None:
1464
+ """
1465
+ Print the preamble for the MAIL client.
1466
+ """
1467
+ self.client._console.print(
1468
+ f"[bold]MAIL CLIent v[cyan]{utils.get_version()}[/cyan][/bold]"
1469
+ )
1470
+ self.client._console.print(
1471
+ "Enter [cyan]`help`[/cyan] for help and [cyan]`exit`[/cyan] to quit"
1472
+ )
1473
+ self.client._console.print("==========")
1474
+
1475
+ def _repl_input_string(
1476
+ self,
1477
+ user_role: str,
1478
+ user_id: str,
1479
+ base_url: str,
1480
+ ) -> str:
1481
+ """
1482
+ Get the input string for the REPL.
1483
+ """
1484
+ base_url = base_url.removeprefix("http://")
1485
+ base_url = base_url.removeprefix("https://")
1486
+ # truncate the user ID if it's longer than 8 characters
1487
+ if len(user_id) > 8:
1488
+ user_id = f"{user_id[:4]}...{user_id[-4:]}"
1489
+
1490
+ return f"[cyan bold]mail[/cyan bold]::[green bold]{user_role}:{user_id}@{base_url}[/green bold]> "
1491
+
1492
+ @staticmethod
1493
+ def _readline_safe_prompt(prompt: str) -> str:
1494
+ """
1495
+ Wrap ANSI codes so readline ignores them when computing prompt length.
1496
+ """
1497
+ ansi_pattern = re.compile(r"(\x1b\[[0-9;?]*[ -/]*[@-~])")
1498
+ return ansi_pattern.sub(lambda match: f"\001{match.group(1)}\002", prompt)
1499
+
1500
+ def _render_prompt(self, prompt_markup: str) -> str:
1501
+ """
1502
+ Render Rich markup to ANSI and make it safe for readline editing.
1503
+ """
1504
+ with self._prompt_console.capture() as capture:
1505
+ self._prompt_console.print(prompt_markup, end="")
1506
+ rendered = capture.get().rstrip("\n")
1507
+ return self._readline_safe_prompt(rendered)
1508
+
1509
+ async def run(
1510
+ self,
1511
+ attempt_login: bool = True,
1512
+ ) -> None:
1513
+ """
1514
+ Run the MAIL client as a REPL in the terminal.
1515
+ """
1516
+ if attempt_login:
1517
+ try:
1518
+ whoami = await self.client.get_whoami()
1519
+ self.client._console.print(
1520
+ f"[green]successfully[/green] logged into {self.client.base_url}"
1521
+ )
1522
+ self.client._console.print(f"> role: [green]{whoami['role']}[/green]")
1523
+ self.client._console.print(f"> id: [green]{whoami['id']}[/green]")
1524
+ except Exception as e:
1525
+ self.client._console.print(
1526
+ "[yellow]warning[/yellow]: unable to determine identity via /whoami"
1527
+ )
1528
+ self.client._console.print(f"> error: {e}")
1529
+ self.client._console.print(
1530
+ "> NOTE: your client will be connected to the server but will not be logged in"
1531
+ )
1532
+ self.client._console.print(
1533
+ "> NOTE: you can log in by running `login {YOUR_API_KEY}`"
1534
+ )
1535
+ self.user_role = "unknown"
1536
+ self.user_id = "unknown"
1537
+ else:
1538
+ self.user_id = whoami.get("id", "unknown")
1539
+ self.user_role = whoami.get("role", "unknown")
1540
+ else:
1541
+ self.user_id = "unknown"
1542
+ self.user_role = "unknown"
1543
+ self.base_url = self.client.base_url
1544
+
1545
+ self._print_preamble()
1546
+
1547
+ while True:
1548
+ try:
1549
+ prompt_markup = self._repl_input_string(
1550
+ self.user_role, self.user_id, self.base_url
1551
+ )
1552
+ raw_command = self.client._console.input(prompt_markup)
1553
+ except EOFError:
1554
+ self.client._console.print()
1555
+ break
1556
+ except KeyboardInterrupt:
1557
+ self.client._console.print()
1558
+ continue
1559
+
1560
+ if not raw_command.strip():
1561
+ continue
1562
+
1563
+ try:
1564
+ tokens = shlex.split(raw_command)
1565
+ except ValueError as exc:
1566
+ self.client._console.print(
1567
+ f"[red bold]error[/red bold] parsing command: {exc}"
1568
+ )
1569
+ continue
1570
+
1571
+ command = tokens[0]
1572
+
1573
+ if command in {"exit", "quit"}:
1574
+ break
1575
+ if command in {"help", "?"}:
1576
+ self.parser.print_help()
1577
+ continue
1578
+
1579
+ try:
1580
+ args = self.parser.parse_args(tokens)
1581
+ except SystemExit:
1582
+ continue
1583
+
1584
+ func = getattr(args, "func", None)
1585
+ if func is None:
1586
+ self.parser.print_help()
1587
+ continue
1588
+
1589
+ await func(args)
1590
+
1591
+ # Save readline history on exit
1592
+ try:
1593
+ readline.write_history_file(self._history_file)
1594
+ except OSError:
1595
+ pass
1596
+
1597
+ @staticmethod
1598
+ def _collect_xml_strings(candidate: Any) -> list[str]:
1599
+ """
1600
+ Recursively gather XML-like strings from nested data.
1601
+ """
1602
+
1603
+ collected_set: set[str] = set()
1604
+
1605
+ def _walk(node: Any) -> None:
1606
+ if isinstance(node, str):
1607
+ snippet = node.strip()
1608
+ if "<" in snippet and ">" in snippet:
1609
+ start = snippet.find("<")
1610
+ end = snippet.rfind(">")
1611
+ if start != -1 and end != -1 and start < end:
1612
+ candidate = snippet[start : end + 1]
1613
+ candidate = (
1614
+ candidate.replace("\\n", "")
1615
+ .replace("\\t", "\t")
1616
+ .replace("[\\'", "")
1617
+ .replace("\\']", "")
1618
+ )
1619
+ collected_set.add(candidate)
1620
+ return
1621
+ if hasattr(node, "description"):
1622
+ try:
1623
+ _walk(getattr(node, "description"))
1624
+ except Exception:
1625
+ pass
1626
+ return
1627
+ if isinstance(node, dict):
1628
+ for value in node.values():
1629
+ _walk(value)
1630
+ return
1631
+ if isinstance(node, list | tuple | set):
1632
+ for value in node:
1633
+ _walk(value)
1634
+
1635
+ _walk(candidate)
1636
+ return list(collected_set)
1637
+
1638
+ @staticmethod
1639
+ def _pretty_format_xml(xml_text: str) -> str | None:
1640
+ try:
1641
+ from xml.dom import minidom
1642
+
1643
+ parsed = minidom.parseString(xml_text)
1644
+ pretty = parsed.toprettyxml(indent=" ", encoding="utf-8")
1645
+ except Exception:
1646
+ return None
1647
+
1648
+ try:
1649
+ return pretty.decode("utf-8").strip()
1650
+ except AttributeError:
1651
+ return pretty.strip().decode("utf-8")
1652
+
1653
+ def _print_embedded_xml(self, payload: Any) -> None:
1654
+ for snippet in self._collect_xml_strings(payload):
1655
+ pretty = self._pretty_format_xml(snippet)
1656
+ if pretty:
1657
+ self.client._console.print(Syntax(pretty, "xml"))