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.
- mail/__init__.py +35 -0
- mail/api.py +1964 -0
- mail/cli.py +432 -0
- mail/client.py +1657 -0
- mail/config/__init__.py +8 -0
- mail/config/client.py +87 -0
- mail/config/server.py +165 -0
- mail/core/__init__.py +72 -0
- mail/core/actions.py +69 -0
- mail/core/agents.py +73 -0
- mail/core/message.py +366 -0
- mail/core/runtime.py +3537 -0
- mail/core/tasks.py +311 -0
- mail/core/tools.py +1206 -0
- mail/db/__init__.py +0 -0
- mail/db/init.py +182 -0
- mail/db/types.py +65 -0
- mail/db/utils.py +523 -0
- mail/examples/__init__.py +27 -0
- mail/examples/analyst_dummy/__init__.py +15 -0
- mail/examples/analyst_dummy/agent.py +136 -0
- mail/examples/analyst_dummy/prompts.py +44 -0
- mail/examples/consultant_dummy/__init__.py +15 -0
- mail/examples/consultant_dummy/agent.py +136 -0
- mail/examples/consultant_dummy/prompts.py +42 -0
- mail/examples/data_analysis/__init__.py +40 -0
- mail/examples/data_analysis/analyst/__init__.py +9 -0
- mail/examples/data_analysis/analyst/agent.py +67 -0
- mail/examples/data_analysis/analyst/prompts.py +53 -0
- mail/examples/data_analysis/processor/__init__.py +13 -0
- mail/examples/data_analysis/processor/actions.py +293 -0
- mail/examples/data_analysis/processor/agent.py +67 -0
- mail/examples/data_analysis/processor/prompts.py +48 -0
- mail/examples/data_analysis/reporter/__init__.py +10 -0
- mail/examples/data_analysis/reporter/actions.py +187 -0
- mail/examples/data_analysis/reporter/agent.py +67 -0
- mail/examples/data_analysis/reporter/prompts.py +49 -0
- mail/examples/data_analysis/statistics/__init__.py +18 -0
- mail/examples/data_analysis/statistics/actions.py +343 -0
- mail/examples/data_analysis/statistics/agent.py +67 -0
- mail/examples/data_analysis/statistics/prompts.py +60 -0
- mail/examples/mafia/__init__.py +0 -0
- mail/examples/mafia/game.py +1537 -0
- mail/examples/mafia/narrator_tools.py +396 -0
- mail/examples/mafia/personas.py +240 -0
- mail/examples/mafia/prompts.py +489 -0
- mail/examples/mafia/roles.py +147 -0
- mail/examples/mafia/spec.md +350 -0
- mail/examples/math_dummy/__init__.py +23 -0
- mail/examples/math_dummy/actions.py +252 -0
- mail/examples/math_dummy/agent.py +136 -0
- mail/examples/math_dummy/prompts.py +46 -0
- mail/examples/math_dummy/types.py +5 -0
- mail/examples/research/__init__.py +39 -0
- mail/examples/research/researcher/__init__.py +9 -0
- mail/examples/research/researcher/agent.py +67 -0
- mail/examples/research/researcher/prompts.py +54 -0
- mail/examples/research/searcher/__init__.py +10 -0
- mail/examples/research/searcher/actions.py +324 -0
- mail/examples/research/searcher/agent.py +67 -0
- mail/examples/research/searcher/prompts.py +53 -0
- mail/examples/research/summarizer/__init__.py +18 -0
- mail/examples/research/summarizer/actions.py +255 -0
- mail/examples/research/summarizer/agent.py +67 -0
- mail/examples/research/summarizer/prompts.py +55 -0
- mail/examples/research/verifier/__init__.py +10 -0
- mail/examples/research/verifier/actions.py +337 -0
- mail/examples/research/verifier/agent.py +67 -0
- mail/examples/research/verifier/prompts.py +52 -0
- mail/examples/supervisor/__init__.py +11 -0
- mail/examples/supervisor/agent.py +4 -0
- mail/examples/supervisor/prompts.py +93 -0
- mail/examples/support/__init__.py +33 -0
- mail/examples/support/classifier/__init__.py +10 -0
- mail/examples/support/classifier/actions.py +307 -0
- mail/examples/support/classifier/agent.py +68 -0
- mail/examples/support/classifier/prompts.py +56 -0
- mail/examples/support/coordinator/__init__.py +9 -0
- mail/examples/support/coordinator/agent.py +67 -0
- mail/examples/support/coordinator/prompts.py +48 -0
- mail/examples/support/faq/__init__.py +10 -0
- mail/examples/support/faq/actions.py +182 -0
- mail/examples/support/faq/agent.py +67 -0
- mail/examples/support/faq/prompts.py +42 -0
- mail/examples/support/sentiment/__init__.py +15 -0
- mail/examples/support/sentiment/actions.py +341 -0
- mail/examples/support/sentiment/agent.py +67 -0
- mail/examples/support/sentiment/prompts.py +54 -0
- mail/examples/weather_dummy/__init__.py +23 -0
- mail/examples/weather_dummy/actions.py +75 -0
- mail/examples/weather_dummy/agent.py +136 -0
- mail/examples/weather_dummy/prompts.py +35 -0
- mail/examples/weather_dummy/types.py +5 -0
- mail/factories/__init__.py +27 -0
- mail/factories/action.py +223 -0
- mail/factories/base.py +1531 -0
- mail/factories/supervisor.py +241 -0
- mail/net/__init__.py +7 -0
- mail/net/registry.py +712 -0
- mail/net/router.py +728 -0
- mail/net/server_utils.py +114 -0
- mail/net/types.py +247 -0
- mail/server.py +1605 -0
- mail/stdlib/__init__.py +0 -0
- mail/stdlib/anthropic/__init__.py +0 -0
- mail/stdlib/fs/__init__.py +15 -0
- mail/stdlib/fs/actions.py +209 -0
- mail/stdlib/http/__init__.py +19 -0
- mail/stdlib/http/actions.py +333 -0
- mail/stdlib/interswarm/__init__.py +11 -0
- mail/stdlib/interswarm/actions.py +208 -0
- mail/stdlib/mcp/__init__.py +19 -0
- mail/stdlib/mcp/actions.py +294 -0
- mail/stdlib/openai/__init__.py +13 -0
- mail/stdlib/openai/agents.py +451 -0
- mail/summarizer.py +234 -0
- mail/swarms_json/__init__.py +27 -0
- mail/swarms_json/types.py +87 -0
- mail/swarms_json/utils.py +255 -0
- mail/url_scheme.py +51 -0
- mail/utils/__init__.py +53 -0
- mail/utils/auth.py +194 -0
- mail/utils/context.py +17 -0
- mail/utils/logger.py +73 -0
- mail/utils/openai.py +212 -0
- mail/utils/parsing.py +89 -0
- mail/utils/serialize.py +292 -0
- mail/utils/store.py +49 -0
- mail/utils/string_builder.py +119 -0
- mail/utils/version.py +20 -0
- mail_swarms-1.3.2.dist-info/METADATA +237 -0
- mail_swarms-1.3.2.dist-info/RECORD +137 -0
- mail_swarms-1.3.2.dist-info/WHEEL +4 -0
- mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
- mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
- mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
- 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"))
|