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/server.py ADDED
@@ -0,0 +1,1605 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = [
4
+ # "mail",
5
+ # ]
6
+ # ///
7
+
8
+ # SPDX-License-Identifier: Apache-2.0
9
+ # Copyright (c) 2025 Addison Kline, Will Hahn
10
+
11
+ import asyncio
12
+ import datetime
13
+ import logging
14
+ import os
15
+ import time
16
+ import uuid
17
+ from contextlib import asynccontextmanager
18
+ from typing import Any, Literal
19
+
20
+ import ujson
21
+ import uvicorn
22
+ from aiohttp import ClientSession
23
+ from fastapi import Depends, FastAPI, HTTPException, Request
24
+ from fastapi.middleware.cors import CORSMiddleware
25
+
26
+ import mail.net.server_utils as server_utils
27
+ import mail.utils as utils
28
+ from mail.config.server import ServerConfig, SettingsConfig, SwarmConfig
29
+ from mail.core.message import (
30
+ MAIL_MESSAGE_TYPES,
31
+ MAILAddress,
32
+ MAILBroadcast,
33
+ MAILInterswarmMessage,
34
+ MAILRequest,
35
+ create_address,
36
+ format_agent_address,
37
+ parse_agent_address,
38
+ parse_task_contributors,
39
+ )
40
+ from mail.net import types as types
41
+ from mail.db.utils import close_pool as close_db_pool
42
+ from mail.utils.logger import init_logger
43
+ from mail.utils.openai import SwarmOAIClient, build_oai_clients_dict
44
+
45
+ from .api import MAILSwarm, MAILSwarmTemplate
46
+
47
+ # Initialize logger at module level so it runs regardless of how the server is started
48
+ _server_config: ServerConfig = ServerConfig()
49
+ init_logger()
50
+ logger = logging.getLogger("mail.server")
51
+
52
+ # Template injection for programmatic server startup (single-process only)
53
+ # Used by run_server_with_template() and MAILSwarmTemplate.start_server()
54
+ _TEMPLATE_OVERRIDE: MAILSwarmTemplate | None = None
55
+ _CONFIG_OVERRIDE: ServerConfig | None = None
56
+
57
+
58
+ def _log_prelude(app: FastAPI) -> str:
59
+ """
60
+ Get the log prelude for the server.
61
+ """
62
+ return f"[[green]{app.state.local_swarm_name}[/green]@{app.state.local_base_url}]"
63
+
64
+
65
+ @asynccontextmanager
66
+ async def lifespan(app: FastAPI):
67
+ """
68
+ Handle startup and shutdown events.
69
+ """
70
+ await _server_startup(app)
71
+
72
+ yield
73
+
74
+ await _server_shutdown(app)
75
+
76
+
77
+ async def _server_startup(app: FastAPI) -> None:
78
+ """
79
+ Server startup logic, run before the `yield` in the lifespan context manager.
80
+ """
81
+ global _TEMPLATE_OVERRIDE, _CONFIG_OVERRIDE
82
+ logger.info("MAIL server starting up...")
83
+
84
+ # Use injected template/config if provided, else use module-level config
85
+ # IMPORTANT: When _TEMPLATE_OVERRIDE is set, we MUST use _CONFIG_OVERRIDE
86
+ # to ensure debug=True (required for /ui/message endpoint)
87
+ if _TEMPLATE_OVERRIDE is not None:
88
+ ps = _TEMPLATE_OVERRIDE
89
+ cfg = _CONFIG_OVERRIDE if _CONFIG_OVERRIDE is not None else _server_config
90
+ logger.info(f"Using injected template: {ps.name} (debug={cfg.debug})")
91
+ else:
92
+ cfg = _server_config
93
+ ps = server_utils.get_default_persistent_swarm(cfg)
94
+
95
+ # IMPORTANT: All code below must use the local `cfg` variable, NOT _server_config.
96
+ # This ensures injected config (with debug=True) is used throughout.
97
+ # DO NOT add `cfg = _server_config` anywhere below this point.
98
+
99
+ # set defaults
100
+ app.state.debug = cfg.debug
101
+
102
+ # swarm stuff
103
+ app.state.persistent_swarm = ps
104
+ app.state.admin_mail_instances = server_utils.init_mail_instances_dict()
105
+ app.state.admin_mail_tasks = server_utils.init_mail_tasks_dict()
106
+ app.state.user_mail_instances = server_utils.init_mail_instances_dict()
107
+ app.state.user_mail_tasks = server_utils.init_mail_tasks_dict()
108
+ app.state.swarm_mail_instances = server_utils.init_mail_instances_dict()
109
+ app.state.swarm_mail_tasks = server_utils.init_mail_tasks_dict()
110
+ app.state.task_bindings = server_utils.init_task_bindings_dict()
111
+
112
+ # Interswarm messaging support
113
+ app.state.swarm_registry = server_utils.get_default_swarm_registry(
114
+ cfg, app.state.persistent_swarm
115
+ )
116
+ app.state.local_swarm_name = server_utils.get_default_swarm_name(cfg)
117
+ app.state.local_base_url = server_utils.get_default_base_url(cfg)
118
+ app.state.default_entrypoint_agent = server_utils.get_default_entrypoint_agent(
119
+ app.state.persistent_swarm
120
+ )
121
+
122
+ # Debug-only state
123
+ if app.state.debug:
124
+ app.state.openai_clients = build_oai_clients_dict()
125
+
126
+ # Shared HTTP session for any server-initiated interswarm calls
127
+ app.state._http_session = ClientSession(
128
+ headers={
129
+ "User-Agent": f"MAIL-Server/v{utils.get_protocol_version()}/{app.state.local_swarm_name} (github.com/charonlabs/mail)"
130
+ }
131
+ )
132
+
133
+ # more app state
134
+ app.state.start_time = time.time()
135
+ app.state.health = "healthy"
136
+ app.state.last_health_update = app.state.start_time
137
+
138
+
139
+ def _register_task_binding(
140
+ app: FastAPI,
141
+ task_id: str,
142
+ role: str,
143
+ identifier: str,
144
+ api_key: str,
145
+ *,
146
+ direct: bool = False,
147
+ ) -> None:
148
+ if not task_id:
149
+ return
150
+ binding = {
151
+ "role": role,
152
+ "id": identifier,
153
+ }
154
+ if api_key:
155
+ binding["api_key"] = api_key
156
+ if direct:
157
+ binding["direct"] = True # type: ignore
158
+ app.state.task_bindings[task_id] = binding
159
+
160
+
161
+ def _get_mail_instance_from_interswarm_message(
162
+ app: FastAPI,
163
+ message: MAILInterswarmMessage,
164
+ ) -> MAILSwarm:
165
+ """
166
+ Get the MAIL instance from a message sent with `POST /interswarm/back`.
167
+ """
168
+ task_id = message["payload"]["task_id"]
169
+ contributors = parse_task_contributors(message["task_contributors"])
170
+ for role, id, swarm in contributors:
171
+ if swarm == app.state.local_swarm_name:
172
+ if role == "admin":
173
+ instance = app.state.admin_mail_instances.get(id)
174
+ elif role == "user":
175
+ instance = app.state.user_mail_instances.get(id)
176
+ elif role == "swarm":
177
+ instance = app.state.swarm_mail_instances.get(id)
178
+ else:
179
+ raise HTTPException(status_code=400, detail=f"invalid role: {role}")
180
+
181
+ if instance is None:
182
+ raise HTTPException(
183
+ status_code=404,
184
+ detail=f"no mail instance found for contributor: {role}:{id}@{swarm}",
185
+ )
186
+ return instance
187
+
188
+ raise HTTPException(
189
+ status_code=404, detail=f"no mail instance found for task with id {task_id}"
190
+ )
191
+
192
+
193
+ async def _server_shutdown(app: FastAPI) -> None:
194
+ """
195
+ Server shutdown logic, run after the `yield` in the lifespan context manager.
196
+ """
197
+ logger.info("MAIL server shutting down...")
198
+
199
+ # Stop swarm registry and cleanup volatile endpoints
200
+ if app.state.swarm_registry:
201
+ await app.state.swarm_registry.stop_health_checks()
202
+ # Clean up volatile endpoints and save persistent ones
203
+ app.state.swarm_registry.cleanup_volatile_endpoints()
204
+
205
+ # Clean up all admin MAIL instances
206
+ for admin_id, mail_instance in app.state.admin_mail_instances.items():
207
+ logger.info(
208
+ f"{_log_prelude(app)} shutting down MAIL instance for admin '{admin_id}'"
209
+ )
210
+ await mail_instance.shutdown()
211
+
212
+ for admin_id, mail_task in app.state.admin_mail_tasks.items():
213
+ if mail_task and not mail_task.done():
214
+ logger.info(
215
+ f"{_log_prelude(app)} cancelling MAIL task for admin '{admin_id}'"
216
+ )
217
+ mail_task.cancel()
218
+ try:
219
+ await mail_task
220
+ except asyncio.CancelledError:
221
+ pass
222
+
223
+ # Clean up all user MAIL instances
224
+ for user_id, mail_instance in app.state.user_mail_instances.items():
225
+ logger.info(
226
+ f"{_log_prelude(app)} shutting down MAIL instance for user '{user_id}'"
227
+ )
228
+ await mail_instance.shutdown()
229
+
230
+ for user_id, mail_task in app.state.user_mail_tasks.items():
231
+ if mail_task and not mail_task.done():
232
+ logger.info(
233
+ f"{_log_prelude(app)} cancelling MAIL task for user '{user_id}'"
234
+ )
235
+ mail_task.cancel()
236
+ try:
237
+ await mail_task
238
+ except asyncio.CancelledError:
239
+ pass
240
+
241
+ # Clean up all swarm MAIL instances
242
+ for swarm_id, mail_instance in app.state.swarm_mail_instances.items():
243
+ logger.info(
244
+ f"{_log_prelude(app)} shutting down MAIL instance for swarm '{swarm_id}'"
245
+ )
246
+ await mail_instance.shutdown()
247
+
248
+ for swarm_id, mail_task in app.state.swarm_mail_tasks.items():
249
+ if mail_task and not mail_task.done():
250
+ logger.info(
251
+ f"{_log_prelude(app)} cancelling MAIL task for swarm '{swarm_id}'"
252
+ )
253
+ mail_task.cancel()
254
+ try:
255
+ await mail_task
256
+ except asyncio.CancelledError:
257
+ pass
258
+
259
+ # Close shared HTTP session if opened
260
+ if app.state._http_session is not None:
261
+ try:
262
+ await app.state._http_session.close()
263
+ except Exception:
264
+ pass
265
+ app.state._http_session = None
266
+
267
+ # Close the database connection pool
268
+ try:
269
+ await close_db_pool()
270
+ except Exception as e:
271
+ logger.warning(f"error closing database pool: {e}")
272
+
273
+
274
+ app = FastAPI(lifespan=lifespan)
275
+
276
+ # Add CORS middleware for UI dev server
277
+ app.add_middleware(
278
+ CORSMiddleware,
279
+ allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
280
+ allow_credentials=True,
281
+ allow_methods=["*"],
282
+ allow_headers=["*"],
283
+ )
284
+
285
+
286
+ async def get_or_create_mail_instance(
287
+ role: Literal["admin", "swarm", "user"],
288
+ id: str,
289
+ api_key: str,
290
+ ) -> MAILSwarm:
291
+ """
292
+ Get or create a MAIL instance for a specific role.
293
+ """
294
+ match role:
295
+ case "admin":
296
+ mail_instances = app.state.admin_mail_instances
297
+ mail_tasks = app.state.admin_mail_tasks
298
+ case "swarm":
299
+ mail_instances = app.state.swarm_mail_instances
300
+ mail_tasks = app.state.swarm_mail_tasks
301
+ case "user":
302
+ mail_instances = app.state.user_mail_instances
303
+ mail_tasks = app.state.user_mail_tasks
304
+ case _:
305
+ raise ValueError(f"invalid role: {role}")
306
+
307
+ existing_instance = mail_instances.get(id)
308
+ if isinstance(existing_instance, MAILSwarm):
309
+ return existing_instance
310
+ if isinstance(existing_instance, asyncio.Task):
311
+ logger.warning(
312
+ f"{_log_prelude(app)} MAIL instance for {role} '{id}' was stored as a task; recreating runtime"
313
+ )
314
+ # Best effort: cancel the orphaned task if still running.
315
+ try:
316
+ if not existing_instance.done():
317
+ existing_instance.cancel()
318
+ except Exception:
319
+ pass
320
+ mail_instances.pop(id, None)
321
+ mail_tasks.pop(id, None)
322
+
323
+ if id not in mail_instances:
324
+ try:
325
+ logger.info(f"{_log_prelude(app)} creating MAIL instance for {role} '{id}'")
326
+
327
+ ps = app.state.persistent_swarm
328
+ mail_instance = ps.instantiate(
329
+ instance_params={
330
+ "user_token": api_key,
331
+ },
332
+ user_id=id,
333
+ user_role=role,
334
+ base_url=app.state.local_base_url,
335
+ registry_file=app.state.swarm_registry.persistence_file,
336
+ )
337
+ mail_instances[id] = mail_instance
338
+
339
+ # Start interswarm messaging (only if enabled)
340
+ if mail_instance.enable_interswarm:
341
+ await mail_instance.start_interswarm()
342
+
343
+ # Load existing agent histories and tasks from the database
344
+ await mail_instance.load_agent_histories_from_db()
345
+ await mail_instance.load_tasks_from_db()
346
+
347
+ # Start the MAIL instance in continuous mode for this role
348
+ logger.info(
349
+ f"{_log_prelude(app)} starting MAIL continuous mode for {role} '{id}'"
350
+ )
351
+ mail_task = asyncio.create_task(
352
+ mail_instance.run_continuous(
353
+ max_steps=app.state.persistent_swarm.task_message_limit
354
+ )
355
+ )
356
+ mail_tasks[id] = mail_task
357
+
358
+ logger.info(
359
+ f"{_log_prelude(app)} MAIL instance created and started for {role} '{id}'"
360
+ )
361
+
362
+ except Exception as e:
363
+ logger.error(
364
+ f"{_log_prelude(app)} error creating MAIL instance for {role} '{id}' with error: {e}"
365
+ )
366
+ raise e
367
+
368
+ instance = mail_instances.get(id)
369
+ if isinstance(instance, MAILSwarm):
370
+ return instance
371
+
372
+ raise RuntimeError(
373
+ f"MAIL instance for {role} '{id}' is unavailable after creation attempt"
374
+ )
375
+
376
+
377
+ @app.get("/")
378
+ async def root():
379
+ """
380
+ Return basic info about the server.
381
+ """
382
+ return types.GetRootResponse(
383
+ name="mail",
384
+ protocol_version=utils.get_protocol_version(),
385
+ swarm=types.SwarmInfo(
386
+ name=app.state.persistent_swarm.name,
387
+ version=app.state.persistent_swarm.version,
388
+ description=app.state.persistent_swarm.description,
389
+ entrypoint=app.state.default_entrypoint_agent,
390
+ keywords=app.state.persistent_swarm.keywords,
391
+ public=app.state.persistent_swarm.public,
392
+ ),
393
+ status="running",
394
+ uptime=time.time() - app.state.start_time,
395
+ )
396
+
397
+
398
+ @app.get("/health")
399
+ async def health():
400
+ """
401
+ Health check endpoint for interswarm communication.
402
+ """
403
+ return types.GetHealthResponse(
404
+ status=app.state.health,
405
+ swarm_name=app.state.local_swarm_name,
406
+ timestamp=datetime.datetime.fromtimestamp(
407
+ app.state.last_health_update, datetime.UTC
408
+ ).isoformat(),
409
+ )
410
+
411
+
412
+ @app.post("/health", dependencies=[Depends(utils.caller_is_admin)])
413
+ async def health_post(request: Request):
414
+ """
415
+ Update the server's health status.
416
+ """
417
+ data = await request.json()
418
+ status = data.get("status")
419
+ if not status:
420
+ raise HTTPException(status_code=400, detail="status is required")
421
+
422
+ app.state.health = status
423
+ app.state.last_health_update = time.time()
424
+
425
+ return types.GetHealthResponse(
426
+ status=app.state.health,
427
+ swarm_name=app.state.local_swarm_name,
428
+ timestamp=datetime.datetime.fromtimestamp(
429
+ app.state.last_health_update, datetime.UTC
430
+ ).isoformat(),
431
+ )
432
+
433
+
434
+ @app.get("/whoami", dependencies=[Depends(utils.caller_is_admin_or_user)])
435
+ async def whoami(request: Request):
436
+ """
437
+ Get the username and role of the caller.
438
+ """
439
+ try:
440
+ caller_info = await utils.extract_token_info(request)
441
+ return types.GetWhoamiResponse(id=caller_info["id"], role=caller_info["role"])
442
+ except Exception as e:
443
+ if isinstance(e, HTTPException):
444
+ raise e
445
+ logger.error(f"{_log_prelude(app)} error getting whoami: {e}")
446
+ raise HTTPException(
447
+ status_code=500, detail=f"error getting whoami: {e.with_traceback(None)}"
448
+ )
449
+
450
+
451
+ @app.get("/status", dependencies=[Depends(utils.caller_is_admin_or_user)])
452
+ async def status(request: Request):
453
+ """
454
+ Get the status of the persistent swarm and user-specific MAIL instances.
455
+ """
456
+ caller_info = await utils.extract_token_info(request)
457
+ caller_id = caller_info["id"]
458
+ caller_role = caller_info["role"]
459
+ match caller_role:
460
+ case "admin":
461
+ mail_instances = app.state.admin_mail_instances
462
+ mail_tasks = app.state.admin_mail_tasks
463
+ case "user":
464
+ mail_instances = app.state.user_mail_instances
465
+ mail_tasks = app.state.user_mail_tasks
466
+ case _:
467
+ raise ValueError(f"invalid role: {caller_role}")
468
+
469
+ user_mail_status = caller_id in mail_instances
470
+ user_task_running = caller_id in mail_tasks and not mail_tasks[caller_id].done()
471
+
472
+ return types.GetStatusResponse(
473
+ swarm={
474
+ "name": app.state.persistent_swarm.name
475
+ if app.state.persistent_swarm
476
+ else None,
477
+ "status": "ready",
478
+ },
479
+ active_users=len(app.state.user_mail_instances),
480
+ user_mail_ready=user_mail_status,
481
+ user_task_running=user_task_running,
482
+ )
483
+
484
+
485
+ @app.post("/message", dependencies=[Depends(utils.caller_is_admin_or_user)])
486
+ async def message(request: Request):
487
+ """
488
+ Handle message requests from the client.
489
+ Uses a user-specific MAIL instance to process the request and returns the response.
490
+
491
+ Args:
492
+ body: The string containing the message.
493
+ subject: The subject of the message.
494
+ msg_type: The type of the message.
495
+ entrypoint: The entrypoint to use for the message.
496
+ show_events: Whether to return the events for the task.
497
+ stream: Whether to stream the response.
498
+ task_id: The task ID to use for the message.
499
+ resume_from: The type of resume to use for the message.
500
+ **kwargs: Additional keyword arguments to pass to the runtime.run_task method.
501
+
502
+ Returns:
503
+ A dictionary containing the response message.
504
+ """
505
+ caller_info = await utils.extract_token_info(request)
506
+ caller_id = caller_info["id"]
507
+ caller_role = caller_info["role"]
508
+
509
+ # Extract bearer token from header for runtime instance params
510
+ auth_header = request.headers.get("Authorization", "")
511
+ api_key = auth_header.split(" ")[1] if auth_header.startswith("Bearer ") else ""
512
+
513
+ # Get or create user-specific MAIL instance (for readiness tracking/interswarm)
514
+ try:
515
+ await get_or_create_mail_instance(caller_role, caller_id, api_key)
516
+ except Exception as e:
517
+ logger.error(
518
+ f"{_log_prelude(app)} error getting {caller_role} MAIL instance: {e}"
519
+ )
520
+ raise HTTPException(
521
+ status_code=500,
522
+ detail=f"error getting {caller_role} MAIL instance: {e.with_traceback(None)}",
523
+ )
524
+
525
+ # parse request
526
+ try:
527
+ data = await request.json()
528
+ body = data.get("body") or ""
529
+ subject = data.get("subject") or "New Message"
530
+ msg_type = data.get("msg_type") or "request"
531
+ entrypoint = data.get("entrypoint")
532
+ task_id = data.get("task_id")
533
+ resume_from = data.get("resume_from")
534
+ kwargs = data.get("kwargs") or {}
535
+ # Choose recipient: provided entrypoint or default from config
536
+ if isinstance(entrypoint, str) and entrypoint.strip():
537
+ recipient_agent = entrypoint.strip()
538
+ else:
539
+ recipient_agent = app.state.default_entrypoint_agent
540
+ show_events = data.get("show_events", False)
541
+ stream = data.get("stream", False)
542
+
543
+ assert isinstance(msg_type, str)
544
+ if msg_type not in MAIL_MESSAGE_TYPES:
545
+ raise HTTPException(
546
+ status_code=400, detail=f"invalid message type: {msg_type}"
547
+ )
548
+
549
+ logger.info(
550
+ f"{_log_prelude(app)} received message from {caller_role} '{caller_id}': '{subject}'"
551
+ )
552
+ except Exception as e:
553
+ if isinstance(e, HTTPException):
554
+ raise e
555
+ logger.error(f"{_log_prelude(app)} error parsing request: {e}")
556
+ raise HTTPException(
557
+ status_code=400, detail=f"error parsing request: {e.with_traceback(None)}"
558
+ )
559
+
560
+ if not body and resume_from != "breakpoint_tool_call":
561
+ logger.warning(f"{_log_prelude(app)} no message body provided")
562
+ raise HTTPException(status_code=400, detail="no message provided")
563
+
564
+ # MAIL process
565
+ try:
566
+ assert app.state.persistent_swarm is not None
567
+
568
+ api_swarm = await get_or_create_mail_instance(caller_role, caller_id, api_key)
569
+
570
+ if not isinstance(task_id, str) or not task_id:
571
+ task_id = str(uuid.uuid4())
572
+ _register_task_binding(app, task_id, caller_role, caller_id, api_key)
573
+
574
+ # If client provided an explicit entrypoint, pass it through; otherwise use default
575
+ chosen_entrypoint = recipient_agent
576
+
577
+ if stream:
578
+ logger.info(
579
+ f"{_log_prelude(app)} submitting streamed message via MAIL API for {caller_role} '{caller_id}'"
580
+ )
581
+ return await api_swarm.post_message_stream(
582
+ subject=subject,
583
+ body=body,
584
+ msg_type=msg_type, # type: ignore
585
+ entrypoint=chosen_entrypoint,
586
+ task_id=task_id,
587
+ resume_from=resume_from,
588
+ **kwargs,
589
+ )
590
+ else:
591
+ logger.info(
592
+ f"{_log_prelude(app)} submitting message via MAIL API for {caller_role} '{caller_id}' and waiting"
593
+ )
594
+ result = await api_swarm.post_message(
595
+ subject=subject,
596
+ body=body,
597
+ msg_type=msg_type, # type: ignore
598
+ entrypoint=chosen_entrypoint,
599
+ show_events=show_events,
600
+ task_id=task_id,
601
+ resume_from=resume_from,
602
+ **kwargs,
603
+ )
604
+ # Support both (response, events) and response-only returns
605
+ if isinstance(result, tuple) and len(result) == 2:
606
+ response, events = result
607
+ else:
608
+ response, events = result, [] # type: ignore[misc]
609
+
610
+ return types.PostMessageResponse(
611
+ response=response["message"]["body"],
612
+ events=events if show_events else None,
613
+ )
614
+
615
+ except Exception as e:
616
+ logger.error(
617
+ f"{_log_prelude(app)} error processing message for {caller_role} '{caller_id}' with error: {e}"
618
+ )
619
+ raise HTTPException(
620
+ status_code=500,
621
+ detail=f"error processing message: {e.with_traceback(None)}",
622
+ )
623
+
624
+
625
+ @app.get("/ui/agents")
626
+ async def get_ui_agents():
627
+ """
628
+ Return agent topology for UI visualization.
629
+ Returns agent names, comm_targets, and role flags.
630
+ """
631
+ if not app.state.persistent_swarm:
632
+ raise HTTPException(status_code=503, detail="no swarm loaded")
633
+
634
+ agents = []
635
+ for agent in app.state.persistent_swarm.agents:
636
+ agents.append({
637
+ "name": agent.name,
638
+ "comm_targets": agent.comm_targets,
639
+ "enable_entrypoint": agent.enable_entrypoint,
640
+ "can_complete_tasks": agent.can_complete_tasks,
641
+ "enable_interswarm": agent.enable_interswarm,
642
+ })
643
+
644
+ return {
645
+ "agents": agents,
646
+ "entrypoint": app.state.default_entrypoint_agent,
647
+ }
648
+
649
+
650
+ @app.post("/ui/message", dependencies=[Depends(utils.require_debug)])
651
+ async def ui_message(request: Request):
652
+ """
653
+ Development-only endpoint for UI to send messages without auth.
654
+ Only available when debug=true in server config.
655
+ """
656
+ # Use a default dev user
657
+ caller_id = "ui-dev-user"
658
+ caller_role: Literal["admin", "swarm", "user"] = "user"
659
+ api_key = "dev-token"
660
+
661
+ # parse request
662
+ try:
663
+ data = await request.json()
664
+ body = data.get("body") or ""
665
+ subject = data.get("subject") or "New Message"
666
+ msg_type = data.get("msg_type") or "request"
667
+ entrypoint = data.get("entrypoint")
668
+ task_id = data.get("task_id")
669
+ stream = data.get("stream", True)
670
+ resume_from = data.get("resume_from") # "user_response" for follow-ups
671
+
672
+ if isinstance(entrypoint, str) and entrypoint.strip():
673
+ recipient_agent = entrypoint.strip()
674
+ else:
675
+ recipient_agent = app.state.default_entrypoint_agent
676
+
677
+ if msg_type not in MAIL_MESSAGE_TYPES:
678
+ raise HTTPException(
679
+ status_code=400, detail=f"invalid message type: {msg_type}"
680
+ )
681
+
682
+ logger.info(
683
+ f"{_log_prelude(app)} [UI-DEV] received message: '{subject}'"
684
+ )
685
+ except Exception as e:
686
+ if isinstance(e, HTTPException):
687
+ raise e
688
+ logger.error(f"{_log_prelude(app)} error parsing request: {e}")
689
+ raise HTTPException(
690
+ status_code=400, detail=f"error parsing request: {e.with_traceback(None)}"
691
+ )
692
+
693
+ if not body and resume_from != "breakpoint_tool_call":
694
+ raise HTTPException(status_code=400, detail="no message provided")
695
+
696
+ try:
697
+ assert app.state.persistent_swarm is not None
698
+
699
+ # Get or create a dev user MAIL instance
700
+ api_swarm = await get_or_create_mail_instance(caller_role, caller_id, api_key)
701
+
702
+ if not isinstance(task_id, str) or not task_id:
703
+ task_id = str(uuid.uuid4())
704
+ _register_task_binding(app, task_id, caller_role, caller_id, api_key)
705
+
706
+ if stream:
707
+ logger.info(
708
+ f"{_log_prelude(app)} [UI-DEV] submitting streamed message (resume_from={resume_from})"
709
+ )
710
+ return await api_swarm.post_message_stream(
711
+ subject=subject,
712
+ body=body,
713
+ msg_type=msg_type, # type: ignore
714
+ entrypoint=recipient_agent,
715
+ task_id=task_id,
716
+ resume_from=resume_from,
717
+ )
718
+ else:
719
+ logger.info(
720
+ f"{_log_prelude(app)} [UI-DEV] submitting message and waiting"
721
+ )
722
+ result = await api_swarm.post_message(
723
+ subject=subject,
724
+ body=body,
725
+ msg_type=msg_type, # type: ignore
726
+ entrypoint=recipient_agent,
727
+ show_events=True,
728
+ task_id=task_id,
729
+ resume_from=resume_from,
730
+ )
731
+ if isinstance(result, tuple) and len(result) == 2:
732
+ response, events = result
733
+ else:
734
+ response, events = result, [] # type: ignore[misc]
735
+
736
+ return types.PostMessageResponse(
737
+ response=response["message"]["body"],
738
+ events=events,
739
+ )
740
+
741
+ except Exception as e:
742
+ logger.error(
743
+ f"{_log_prelude(app)} [UI-DEV] error processing message: {e}"
744
+ )
745
+ raise HTTPException(
746
+ status_code=500,
747
+ detail=f"error processing message: {e.with_traceback(None)}",
748
+ )
749
+
750
+
751
+ @app.get("/ui/dump-events", dependencies=[Depends(utils.require_debug)])
752
+ async def ui_dump_events():
753
+ """
754
+ Dump all events from all tasks to a JSONL file for debugging.
755
+ Returns the events and writes to events_dump.jsonl.
756
+ """
757
+ caller_id = "ui-dev-user"
758
+ caller_role = "user"
759
+ api_key = "dev-token"
760
+
761
+ api_swarm = await get_or_create_mail_instance(caller_role, caller_id, api_key)
762
+ tasks = api_swarm.get_all_tasks()
763
+
764
+ all_events = []
765
+ for task_id, task in tasks.items():
766
+ for event in task.events:
767
+ event_data = event.data
768
+ # Parse if string
769
+ if isinstance(event_data, str):
770
+ try:
771
+ event_data = ujson.loads(event_data)
772
+ except Exception:
773
+ pass
774
+
775
+ all_events.append({
776
+ "task_id": task_id,
777
+ "event_type": event.event,
778
+ "event_id": event.id,
779
+ "data": event_data,
780
+ })
781
+
782
+ # Write to file
783
+ with open("events_dump.jsonl", "w") as f:
784
+ for event in all_events:
785
+ f.write(ujson.dumps(event) + "\n")
786
+
787
+ logger.info(f"{_log_prelude(app)} [UI-DEV] dumped {len(all_events)} events to events_dump.jsonl")
788
+
789
+ return {
790
+ "message": f"Dumped {len(all_events)} events to events_dump.jsonl",
791
+ "event_count": len(all_events),
792
+ "events": all_events,
793
+ }
794
+
795
+
796
+ async def _persist_task_title(
797
+ api_swarm: MAILSwarm,
798
+ task_id: str,
799
+ title: str,
800
+ caller_role: str,
801
+ caller_id: str,
802
+ ) -> None:
803
+ """
804
+ Persist task title to database if db persistence is enabled.
805
+ """
806
+ if not api_swarm._runtime.enable_db_agent_histories:
807
+ return
808
+
809
+ try:
810
+ from mail.db.utils import update_task
811
+
812
+ await update_task(
813
+ task_id=task_id,
814
+ swarm_name=api_swarm._runtime.swarm_name,
815
+ caller_role=caller_role, # type: ignore
816
+ caller_id=caller_id,
817
+ title=title,
818
+ )
819
+ logger.debug(f"persisted title for task '{task_id}' to database")
820
+ except Exception as e:
821
+ logger.warning(f"failed to persist title for task '{task_id}' to database: {e}")
822
+
823
+
824
+ @app.get("/ui/task-summary/{task_id}", dependencies=[Depends(utils.require_debug)])
825
+ async def ui_get_task_summary(task_id: str, force_regen: bool = False):
826
+ """
827
+ Get an AI-generated summary title for a task.
828
+ Generates using Haiku on first request, then returns cached title.
829
+ Pass force_regen=true to regenerate the title.
830
+ """
831
+ from mail.summarizer import summarize_task
832
+
833
+ caller_id = "ui-dev-user"
834
+ caller_role: Literal["admin", "swarm", "user"] = "user"
835
+ api_key = "dev-token"
836
+
837
+ api_swarm = await get_or_create_mail_instance(caller_role, caller_id, api_key)
838
+ task = api_swarm.get_task_by_id(task_id)
839
+
840
+ if task is None:
841
+ raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found")
842
+
843
+ # Return cached title if already generated (unless force_regen)
844
+ if task.title is not None and not force_regen:
845
+ return {"task_id": task_id, "title": task.title}
846
+
847
+ # Extract chat messages from task events
848
+ chat_messages: list[dict] = []
849
+ for event in task.events:
850
+ if event.event != "new_message":
851
+ continue
852
+
853
+ # Parse event data
854
+ event_data = event.data
855
+ if isinstance(event_data, str):
856
+ try:
857
+ event_data = ujson.loads(event_data)
858
+ except Exception:
859
+ continue
860
+
861
+ # Skip if not a dict (could be None, list, etc.)
862
+ if not isinstance(event_data, dict):
863
+ continue
864
+
865
+ extra_data = event_data.get("extra_data", {})
866
+ full_message = extra_data.get("full_message", {})
867
+ message = full_message.get("message", {})
868
+ sender = message.get("sender", {})
869
+ msg_type = full_message.get("msg_type")
870
+
871
+ # User message: sender.address_type == "user"
872
+ if sender.get("address_type") == "user":
873
+ chat_messages.append({
874
+ "role": "user",
875
+ "content": message.get("body", ""),
876
+ })
877
+
878
+ # Assistant response: msg_type == "broadcast_complete"
879
+ if msg_type == "broadcast_complete":
880
+ chat_messages.append({
881
+ "role": "assistant",
882
+ "content": message.get("body", ""),
883
+ })
884
+
885
+ if not chat_messages:
886
+ task.title = "<no messages>"
887
+ await _persist_task_title(api_swarm, task_id, task.title, caller_role, caller_id)
888
+ return {"task_id": task_id, "title": task.title}
889
+
890
+ # Generate title using summarizer (creates fresh swarm per request)
891
+ try:
892
+ title = await summarize_task(chat_messages)
893
+ task.title = title if title else "<title failed>"
894
+ await _persist_task_title(api_swarm, task_id, task.title, caller_role, caller_id)
895
+ return {"task_id": task_id, "title": task.title}
896
+ except Exception as e:
897
+ logger.warning(f"Failed to generate title for task {task_id}: {e}")
898
+ task.title = "<title failed>"
899
+ await _persist_task_title(api_swarm, task_id, task.title, caller_role, caller_id)
900
+ return {"task_id": task_id, "title": task.title}
901
+
902
+
903
+ @app.get("/ui/tasks", dependencies=[Depends(utils.require_debug)])
904
+ async def ui_get_tasks():
905
+ """Get all tasks for the UI (debug mode only)."""
906
+ caller_id = "ui-dev-user"
907
+ caller_role = "user"
908
+ api_key = "dev-token"
909
+
910
+ api_swarm = await get_or_create_mail_instance(caller_role, caller_id, api_key)
911
+ tasks = api_swarm.get_all_tasks()
912
+
913
+ result = []
914
+ for task in tasks.values():
915
+ result.append({
916
+ "task_id": task.task_id,
917
+ "task_owner": task.task_owner,
918
+ "is_running": task.is_running,
919
+ "completed": task.completed,
920
+ "start_time": task.start_time.isoformat(),
921
+ "event_count": len(task.events),
922
+ "title": task.title,
923
+ })
924
+
925
+ # Sort by start_time descending (newest first)
926
+ result.sort(key=lambda t: t["start_time"], reverse=True)
927
+ return result
928
+
929
+
930
+ @app.get("/ui/task/{task_id}", dependencies=[Depends(utils.require_debug)])
931
+ async def ui_get_task(task_id: str):
932
+ """Get a specific task with events for the UI (debug mode only)."""
933
+ caller_id = "ui-dev-user"
934
+ caller_role: Literal["admin", "swarm", "user"] = "user"
935
+ api_key = "dev-token"
936
+
937
+ api_swarm = await get_or_create_mail_instance(caller_role, caller_id, api_key)
938
+ task = api_swarm.get_task_by_id(task_id)
939
+
940
+ if task is None:
941
+ raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found")
942
+
943
+ # Serialize events - data is already a JSON string from runtime
944
+ events = []
945
+ for e in task.events:
946
+ event_data = e.data
947
+ # Parse if string (it should be), keep as-is if already dict
948
+ if isinstance(event_data, str):
949
+ try:
950
+ event_data = ujson.loads(event_data)
951
+ except Exception:
952
+ pass # Keep as string if parse fails
953
+
954
+ events.append({
955
+ "event": e.event,
956
+ "data": event_data, # Now a proper dict
957
+ "id": e.id,
958
+ })
959
+
960
+ return {
961
+ "task_id": task.task_id,
962
+ "task_owner": task.task_owner,
963
+ "is_running": task.is_running,
964
+ "completed": task.completed,
965
+ "start_time": task.start_time.isoformat(),
966
+ "title": task.title,
967
+ "events": events,
968
+ }
969
+
970
+
971
+ @app.get("/swarms")
972
+ async def list_swarms():
973
+ """
974
+ List all known swarms for service discovery.
975
+ """
976
+ if not app.state.swarm_registry:
977
+ raise HTTPException(status_code=503, detail="swarm registry not available")
978
+
979
+ endpoints = app.state.swarm_registry.get_public_endpoints()
980
+
981
+ swarms = [
982
+ types.SwarmEndpointCleaned(
983
+ swarm_name=endpoint["swarm_name"],
984
+ base_url=endpoint["base_url"],
985
+ version=endpoint["version"],
986
+ last_seen=endpoint["last_seen"],
987
+ is_active=endpoint["is_active"],
988
+ latency=endpoint["latency"],
989
+ swarm_description=endpoint["swarm_description"],
990
+ keywords=endpoint["keywords"],
991
+ metadata=endpoint["metadata"],
992
+ )
993
+ for endpoint in endpoints.values()
994
+ ]
995
+
996
+ return types.GetSwarmsResponse(
997
+ swarms=swarms,
998
+ )
999
+
1000
+
1001
+ @app.post("/swarms", dependencies=[Depends(utils.caller_is_admin)])
1002
+ async def register_swarm(request: Request):
1003
+ """
1004
+ Register a new swarm in the registry.
1005
+ Only admins can register new swarms.
1006
+ If "volatile" is False, the swarm will be persistent and will not be removed from the registry when the server shuts down.
1007
+ """
1008
+ if not app.state.swarm_registry:
1009
+ raise HTTPException(status_code=503, detail="swarm registry not available")
1010
+
1011
+ try:
1012
+ # parse request
1013
+ data = await request.json()
1014
+ swarm_name = data.get("name")
1015
+ base_url = data.get("base_url")
1016
+ auth_token = data.get("auth_token")
1017
+ volatile = data.get("volatile", True)
1018
+ metadata = data.get("metadata")
1019
+
1020
+ if not swarm_name or not base_url:
1021
+ raise HTTPException(
1022
+ status_code=400, detail="name and base_url are required"
1023
+ )
1024
+
1025
+ await app.state.swarm_registry.register_swarm(
1026
+ swarm_name, base_url, auth_token, metadata, volatile
1027
+ )
1028
+ return types.PostSwarmsResponse(
1029
+ status="registered",
1030
+ swarm_name=swarm_name,
1031
+ )
1032
+
1033
+ except Exception as e:
1034
+ if isinstance(e, HTTPException):
1035
+ raise e
1036
+ logger.error(f"{_log_prelude(app)} error registering swarm: {e}")
1037
+ raise HTTPException(
1038
+ status_code=500, detail=f"error registering swarm: '{str(e)}'"
1039
+ )
1040
+
1041
+
1042
+ @app.get("/swarms/dump", dependencies=[Depends(utils.caller_is_admin)])
1043
+ async def dump_swarm(request: Request):
1044
+ """
1045
+ Dump the persistent swarm to the console.
1046
+ """
1047
+ assert app.state.persistent_swarm is not None
1048
+
1049
+ # log da swarm
1050
+ logger.info(
1051
+ f"{_log_prelude(app)} current persistent swarm: name='{app.state.persistent_swarm.name}', agents={[agent.name for agent in app.state.persistent_swarm.agents]}"
1052
+ )
1053
+
1054
+ # all done!
1055
+ return types.GetSwarmsDumpResponse(
1056
+ status="dumped",
1057
+ swarm_name=app.state.persistent_swarm.name,
1058
+ )
1059
+
1060
+
1061
+ @app.post("/interswarm/forward", dependencies=[Depends(utils.caller_is_agent)])
1062
+ async def receive_interswarm_forward(request: Request):
1063
+ """
1064
+ Receive a message from a remote swarm, in the case of a new task.
1065
+ This creates a new swarm instance for that task, assuming one does not already exist.
1066
+ Once this swarm resolves the task, it will `POST /interswarm/back` to the swarm that forwarded the message.
1067
+ """
1068
+ # parse args
1069
+ data = await request.json()
1070
+ message = data.get("message")
1071
+ if not message:
1072
+ raise HTTPException(status_code=400, detail="parameter 'message' is required")
1073
+ caller_info = await utils.extract_token_info(request)
1074
+ caller_id = caller_info["id"]
1075
+ caller_api_key = caller_info["api_key"]
1076
+ # ensure the message is a valid MAILInterswarmMessage
1077
+ REQUIRED_FIELDS: dict[str, type] = {
1078
+ "message_id": str,
1079
+ "source_swarm": str,
1080
+ "target_swarm": str,
1081
+ "timestamp": str,
1082
+ "payload": dict,
1083
+ "msg_type": str,
1084
+ "auth_token": str,
1085
+ "metadata": dict,
1086
+ "task_owner": str,
1087
+ "task_contributors": list,
1088
+ }
1089
+ for field, expected_type in REQUIRED_FIELDS.items():
1090
+ if field not in message:
1091
+ raise HTTPException(
1092
+ status_code=400, detail=f"parameter '{field}' is required"
1093
+ )
1094
+ if not isinstance(message[field], expected_type):
1095
+ raise HTTPException(
1096
+ status_code=400,
1097
+ detail=f"parameter '{field}' must be a {expected_type.__name__}, got {type(message[field]).__name__}",
1098
+ )
1099
+
1100
+ try:
1101
+ # create a new swarm instance for the task
1102
+ payload = message["payload"]
1103
+ swarm = await get_or_create_mail_instance("swarm", caller_id, caller_api_key)
1104
+ _register_task_binding(
1105
+ app, payload["task_id"], "swarm", caller_id, caller_api_key
1106
+ )
1107
+ # post this message to the swarm
1108
+ await swarm.receive_interswarm_message(message, direction="forward")
1109
+ return types.PostInterswarmForwardResponse(
1110
+ swarm=app.state.local_swarm_name,
1111
+ task_id=payload["task_id"],
1112
+ status="success",
1113
+ local_runner=f"swarm:{caller_id}@{app.state.local_swarm_name}",
1114
+ )
1115
+ except Exception as e:
1116
+ if isinstance(e, HTTPException):
1117
+ raise e
1118
+ logger.error(
1119
+ f"{_log_prelude(app)} server failed to receive interswarm forward message: {e}"
1120
+ )
1121
+ raise HTTPException(
1122
+ status_code=500,
1123
+ detail=f"server failed to receive interswarm forward message: {e}",
1124
+ )
1125
+
1126
+
1127
+ @app.post("/interswarm/back", dependencies=[Depends(utils.caller_is_agent)])
1128
+ async def receive_interswarm_back(request: Request):
1129
+ """
1130
+ Receive a message from a remote swarm, in the case of a task resolution.
1131
+ This binds the message to the existing swarm instance for the task.
1132
+ This swarm will then process the task until `task_complete` is called.
1133
+ """
1134
+ # parse args
1135
+ data = await request.json()
1136
+ message = data.get("message")
1137
+ if not message:
1138
+ raise HTTPException(status_code=400, detail="parameter 'message' is required")
1139
+ caller_info = await utils.extract_token_info(request)
1140
+ caller_id = caller_info["id"]
1141
+ caller_api_key = caller_info["api_key"]
1142
+ # ensure the message is a valid MAILInterswarmMessage
1143
+ REQUIRED_FIELDS: dict[str, type] = {
1144
+ "message_id": str,
1145
+ "source_swarm": str,
1146
+ "target_swarm": str,
1147
+ "timestamp": str,
1148
+ "payload": dict,
1149
+ "msg_type": str,
1150
+ "auth_token": str,
1151
+ "metadata": dict,
1152
+ "task_owner": str,
1153
+ "task_contributors": list,
1154
+ }
1155
+ for field, expected_type in REQUIRED_FIELDS.items():
1156
+ if field not in message:
1157
+ raise HTTPException(
1158
+ status_code=400, detail=f"parameter '{field}' is required"
1159
+ )
1160
+ if not isinstance(message[field], expected_type):
1161
+ raise HTTPException(
1162
+ status_code=400,
1163
+ detail=f"parameter '{field}' must be a {expected_type.__name__}, got {type(message[field]).__name__}",
1164
+ )
1165
+
1166
+ # if this task is not already running, raise an error
1167
+ payload = message["payload"]
1168
+ if not app.state.task_bindings.get(payload["task_id"]):
1169
+ raise HTTPException(status_code=400, detail="task is not running")
1170
+
1171
+ try:
1172
+ # get the swarm instance for the task
1173
+ swarm = _get_mail_instance_from_interswarm_message(app, message)
1174
+ # post this message to the swarm
1175
+ await swarm.receive_interswarm_message(message, direction="back")
1176
+ return types.PostInterswarmBackResponse(
1177
+ swarm=app.state.local_swarm_name,
1178
+ task_id=payload["task_id"],
1179
+ status="success",
1180
+ local_runner=f"swarm:{caller_id}@{app.state.local_swarm_name}",
1181
+ )
1182
+ except Exception as e:
1183
+ if isinstance(e, HTTPException):
1184
+ raise e
1185
+ logger.error(
1186
+ f"{_log_prelude(app)} server failed to receive interswarm back message: {e}"
1187
+ )
1188
+ raise HTTPException(
1189
+ status_code=500,
1190
+ detail=f"server failed to receive interswarm back message: {e}",
1191
+ )
1192
+
1193
+
1194
+ @app.post("/interswarm/message", dependencies=[Depends(utils.caller_is_admin_or_user)])
1195
+ async def post_interswarm_message(request: Request):
1196
+ """
1197
+ Post a message to a remote swarm.
1198
+ Intended for users and admins.
1199
+
1200
+ Args:
1201
+ targets: The targets to send the message to.
1202
+ body: The message to send.
1203
+ subject: The subject of the message.
1204
+ msg_type: The type of the message.
1205
+ task_id: The task ID of the message.
1206
+ routing_info: The routing information for the message.
1207
+ stream: Whether to stream the message.
1208
+ ignore_stream_pings: Whether to ignore stream pings.
1209
+ user_token: The user token to use for the message.
1210
+
1211
+ Returns:
1212
+ The response from the message.
1213
+ """
1214
+ try:
1215
+ caller_info = await utils.extract_token_info(request)
1216
+ caller_id = caller_info["id"]
1217
+ caller_role = caller_info["role"]
1218
+ assert caller_role in ["admin", "user"]
1219
+
1220
+ # parse request
1221
+ data = await request.json()
1222
+ targets = data.get("targets")
1223
+ message_content = data.get("body")
1224
+ subject = data.get("subject", "Interswarm Message")
1225
+ msg_type = data.get("msg_type", "request")
1226
+ raw_task_id = data.get("task_id")
1227
+ task_id = (
1228
+ raw_task_id
1229
+ if isinstance(raw_task_id, str) and raw_task_id
1230
+ else str(uuid.uuid4())
1231
+ )
1232
+ routing_info = data.get("routing_info") or {}
1233
+ stream_requested = bool(data.get("stream"))
1234
+ ignore_pings = bool(data.get("ignore_stream_pings"))
1235
+ if stream_requested:
1236
+ routing_info["stream"] = True
1237
+ if ignore_pings:
1238
+ routing_info["ignore_stream_pings"] = True
1239
+ elif ignore_pings:
1240
+ routing_info["ignore_stream_pings"] = True
1241
+
1242
+ user_token = data.get("user_token")
1243
+ if not user_token:
1244
+ raise HTTPException(status_code=401, detail="user token is required")
1245
+
1246
+ if message_content is not None and not isinstance(message_content, str):
1247
+ message_content = str(message_content)
1248
+
1249
+ if subject is not None and not isinstance(subject, str):
1250
+ subject = str(subject)
1251
+
1252
+ if not targets or not message_content:
1253
+ raise HTTPException(
1254
+ status_code=400,
1255
+ detail="'targets' and 'body' are required",
1256
+ )
1257
+
1258
+ mail_instance = await get_or_create_mail_instance(
1259
+ caller_role, caller_id, user_token
1260
+ )
1261
+ _register_task_binding(app, task_id, caller_role, caller_id, user_token or "")
1262
+
1263
+ sender_address = create_address(caller_id, caller_role)
1264
+
1265
+ def _build_request(target: str) -> MAILInterswarmMessage:
1266
+ recipient_agent, recipient_swarm = parse_agent_address(target)
1267
+ recipient_address = format_agent_address(recipient_agent, recipient_swarm)
1268
+ return MAILInterswarmMessage(
1269
+ message_id=str(uuid.uuid4()),
1270
+ timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
1271
+ source_swarm=app.state.local_swarm_name,
1272
+ target_swarm=recipient_swarm or app.state.local_swarm_name,
1273
+ payload=MAILRequest(
1274
+ task_id=task_id,
1275
+ request_id=str(uuid.uuid4()),
1276
+ sender=sender_address,
1277
+ recipient=recipient_address,
1278
+ subject=subject,
1279
+ body=message_content,
1280
+ sender_swarm=app.state.local_swarm_name,
1281
+ recipient_swarm=recipient_swarm or app.state.local_swarm_name,
1282
+ routing_info=routing_info,
1283
+ ),
1284
+ msg_type="request",
1285
+ auth_token=user_token,
1286
+ task_owner=caller_id,
1287
+ task_contributors=[caller_id],
1288
+ metadata={},
1289
+ )
1290
+
1291
+ def _build_broadcast() -> MAILInterswarmMessage:
1292
+ recipients: list[MAILAddress] = []
1293
+ recipient_swarms: set[str] = set()
1294
+ for target in targets:
1295
+ agent, swarm = parse_agent_address(target)
1296
+ recipients.append(format_agent_address(agent, swarm))
1297
+ if swarm:
1298
+ recipient_swarms.add(swarm)
1299
+ return MAILInterswarmMessage(
1300
+ message_id=str(uuid.uuid4()),
1301
+ timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
1302
+ source_swarm=app.state.local_swarm_name,
1303
+ target_swarm=app.state.local_swarm_name,
1304
+ payload=MAILBroadcast(
1305
+ task_id=task_id,
1306
+ broadcast_id=str(uuid.uuid4()),
1307
+ sender=sender_address,
1308
+ recipients=recipients,
1309
+ subject=subject,
1310
+ body=message_content,
1311
+ sender_swarm=app.state.local_swarm_name,
1312
+ recipient_swarms=list(recipient_swarms)
1313
+ or [app.state.local_swarm_name],
1314
+ routing_info=routing_info,
1315
+ ),
1316
+ msg_type="broadcast",
1317
+ auth_token=user_token,
1318
+ task_owner=caller_id,
1319
+ task_contributors=[caller_id],
1320
+ metadata={},
1321
+ )
1322
+
1323
+ match msg_type:
1324
+ case "request":
1325
+ if len(targets) != 1:
1326
+ raise HTTPException(
1327
+ status_code=400,
1328
+ detail="'request' messages require exactly one target",
1329
+ )
1330
+ mail_message = _build_request(targets[0])
1331
+ case "broadcast":
1332
+ mail_message = _build_broadcast()
1333
+ case _:
1334
+ raise HTTPException(
1335
+ status_code=400,
1336
+ detail=f"msg_type '{msg_type}' is not supported for interswarm send",
1337
+ )
1338
+
1339
+ # Route the message
1340
+ if mail_instance.enable_interswarm:
1341
+ response = await mail_instance.post_interswarm_user_message(mail_message)
1342
+ return types.PostInterswarmMessageResponse(
1343
+ response=response,
1344
+ events=None,
1345
+ )
1346
+ else:
1347
+ raise HTTPException(
1348
+ status_code=503, detail="interswarm router not available"
1349
+ )
1350
+
1351
+ except Exception as e:
1352
+ if isinstance(e, HTTPException):
1353
+ raise e
1354
+ logger.error(
1355
+ f"{_log_prelude(app)} server failed to send interswarm message: {e}"
1356
+ )
1357
+ raise HTTPException(
1358
+ status_code=500,
1359
+ detail=f"server failed to send interswarm message: {str(e)}",
1360
+ )
1361
+
1362
+
1363
+ @app.post("/swarms/load", dependencies=[Depends(utils.caller_is_admin)])
1364
+ async def load_swarm_from_json(request: Request):
1365
+ """
1366
+ Load a swarm from a JSON string.
1367
+ """
1368
+ # get the json string from the request
1369
+ data = await request.json()
1370
+ swarm_json = data.get("json")
1371
+
1372
+ try:
1373
+ # try to load the swarm from string and set the persistent swarm
1374
+ app.state.persistent_swarm = MAILSwarmTemplate.from_swarm_json(swarm_json)
1375
+ return types.PostSwarmsLoadResponse(
1376
+ status="success",
1377
+ swarm_name=app.state.persistent_swarm.name,
1378
+ )
1379
+ except Exception as e:
1380
+ # shit hit the fan
1381
+ logger.error(f"{_log_prelude(app)} error loading swarm from JSON: {e}")
1382
+ raise HTTPException(
1383
+ status_code=500, detail=f"error loading swarm from JSON: {e}"
1384
+ )
1385
+
1386
+
1387
+ @app.post(
1388
+ "/responses",
1389
+ dependencies=[
1390
+ Depends(utils.require_debug),
1391
+ Depends(utils.caller_is_admin_or_user),
1392
+ ],
1393
+ include_in_schema=False,
1394
+ )
1395
+ async def responses(request: Request):
1396
+ """
1397
+ Obtain a MAIL response in the form of an OpenAI `/responses`-style API call.
1398
+ """
1399
+ data = await request.json()
1400
+
1401
+ # parse the request
1402
+ REQUIRED_PARAMS: dict[str, Any] = {
1403
+ "input": list,
1404
+ "tools": list,
1405
+ }
1406
+ OPTIONAL_PARAMS: dict[str, Any] = {
1407
+ "instructions": str | None,
1408
+ "previous_response_id": str | None,
1409
+ "tool_choice": str | dict | None,
1410
+ "parallel_tool_calls": bool | None,
1411
+ }
1412
+ logger.info(f"{_log_prelude(app)} responses: {data}")
1413
+ for param, expected_type in REQUIRED_PARAMS.items():
1414
+ if param not in data:
1415
+ raise HTTPException(
1416
+ status_code=400, detail=f"parameter '{param}' is required"
1417
+ )
1418
+ if not isinstance(data[param], expected_type):
1419
+ raise HTTPException(
1420
+ status_code=400,
1421
+ detail=f"parameter '{param}' must be a {expected_type.__name__}, got {type(data[param]).__name__}",
1422
+ )
1423
+ for param, expected_type in OPTIONAL_PARAMS.items():
1424
+ if param not in data:
1425
+ continue
1426
+ if not isinstance(data[param], expected_type):
1427
+ raise HTTPException(
1428
+ status_code=400,
1429
+ detail=f"parameter '{param}' must be a {expected_type.__name__}, got {type(data[param]).__name__}",
1430
+ )
1431
+
1432
+ input = data["input"]
1433
+ tools = data["tools"]
1434
+ instructions = data.get("instructions")
1435
+ previous_response_id = data.get("previous_response_id")
1436
+ tool_choice = data.get("tool_choice") or "auto"
1437
+ parallel_tool_calls = data.get("parallel_tool_calls", True)
1438
+ kwargs = data.get("kwargs") or {}
1439
+
1440
+ # get the caller's user ID from the API key
1441
+ caller_info = await utils.extract_token_info(request)
1442
+ caller_id = caller_info["id"]
1443
+ caller_role = caller_info["role"]
1444
+ assert caller_role in ["admin", "user"]
1445
+
1446
+ # ensure the caller's MAIL instance is ready
1447
+ caller_mail_instance = await get_or_create_mail_instance(
1448
+ caller_role, caller_id, caller_info["api_key"]
1449
+ )
1450
+ assert caller_mail_instance is not None
1451
+
1452
+ # fetch the client and run the response
1453
+ client = app.state.openai_clients.get(caller_info["api_key"])
1454
+ if client is None:
1455
+ client = SwarmOAIClient(
1456
+ app.state.persistent_swarm,
1457
+ caller_mail_instance,
1458
+ validate_responses=False,
1459
+ )
1460
+ app.state.openai_clients[caller_info["api_key"]] = client
1461
+
1462
+ try:
1463
+ response = await client.responses.create(
1464
+ input=input,
1465
+ tools=tools,
1466
+ instructions=instructions,
1467
+ previous_response_id=previous_response_id,
1468
+ tool_choice=tool_choice,
1469
+ parallel_tool_calls=parallel_tool_calls,
1470
+ **kwargs,
1471
+ )
1472
+ return response.model_dump_json()
1473
+ except Exception as e:
1474
+ logger.error(f"{_log_prelude(app)} error running responses: {e}")
1475
+ raise HTTPException(status_code=500, detail=f"error running responses: {e}")
1476
+
1477
+
1478
+ @app.get(
1479
+ "/tasks",
1480
+ dependencies=[Depends(utils.caller_is_admin_or_user)],
1481
+ )
1482
+ async def get_tasks(request: Request):
1483
+ """
1484
+ Get the list of tasks for this caller.
1485
+ """
1486
+ caller_info = await utils.extract_token_info(request)
1487
+ caller_id = caller_info["id"]
1488
+ caller_role = caller_info["role"]
1489
+ assert caller_role in ["admin", "user"]
1490
+
1491
+ mail_instance = await get_or_create_mail_instance(
1492
+ caller_role, caller_id, caller_info["api_key"]
1493
+ )
1494
+ assert mail_instance is not None
1495
+
1496
+ tasks = mail_instance.get_all_tasks()
1497
+
1498
+ return tasks
1499
+
1500
+
1501
+ @app.get(
1502
+ "/task",
1503
+ dependencies=[Depends(utils.caller_is_admin_or_user)],
1504
+ )
1505
+ async def get_task(request: Request):
1506
+ """
1507
+ Get a specific task for this caller.
1508
+ """
1509
+ caller_info = await utils.extract_token_info(request)
1510
+ caller_id = caller_info["id"]
1511
+ caller_role = caller_info["role"]
1512
+ assert caller_role in ["admin", "user"]
1513
+
1514
+ body = await request.json()
1515
+ task_id = body.get("task_id")
1516
+
1517
+ if task_id is None:
1518
+ raise HTTPException(status_code=400, detail="task_id is required")
1519
+ if not isinstance(task_id, str):
1520
+ raise HTTPException(status_code=400, detail="task_id must be a string")
1521
+
1522
+ mail_instance = await get_or_create_mail_instance(
1523
+ caller_role, caller_id, caller_info["api_key"]
1524
+ )
1525
+ assert mail_instance is not None
1526
+
1527
+ task = mail_instance.get_task_by_id(task_id)
1528
+ if task is None:
1529
+ raise HTTPException(status_code=404, detail=f"task '{task_id}' not found")
1530
+
1531
+ return task
1532
+
1533
+
1534
+ def run_server(
1535
+ cfg: ServerConfig,
1536
+ ):
1537
+ logger.info("starting MAIL server directly...")
1538
+
1539
+ # Ensure the server lifespan uses the runtime config supplied via CLI or caller.
1540
+ global _server_config
1541
+ _server_config = cfg
1542
+
1543
+ os.environ["SWARM_NAME"] = cfg.swarm.name
1544
+ os.environ["SWARM_REGISTRY_FILE"] = cfg.swarm.registry_file
1545
+ os.environ["SWARM_SOURCE"] = cfg.swarm.source
1546
+ os.environ.setdefault("BASE_URL", server_utils.compute_external_base_url(cfg))
1547
+
1548
+ uvicorn.run(app, host=cfg.host, port=cfg.port, reload=cfg.reload)
1549
+
1550
+
1551
+ def run_server_with_template(
1552
+ template: MAILSwarmTemplate,
1553
+ port: int = 8000,
1554
+ host: str = "0.0.0.0",
1555
+ task_message_limit: int | None = None,
1556
+ ) -> None:
1557
+ """Run MAIL server with a pre-configured swarm template.
1558
+
1559
+ This function is for programmatic use only. It runs in single-process
1560
+ mode (no reload, no workers) to support template injection.
1561
+
1562
+ Args:
1563
+ template: The swarm template to use
1564
+ port: Server port
1565
+ host: Server host
1566
+ task_message_limit: Max messages per task (None for unlimited)
1567
+ """
1568
+ global _TEMPLATE_OVERRIDE, _CONFIG_OVERRIDE
1569
+
1570
+ cfg = ServerConfig(
1571
+ port=port,
1572
+ host=host,
1573
+ debug=True, # Required for /ui/message endpoint
1574
+ reload=False, # Must be False for template injection
1575
+ swarm=SwarmConfig(
1576
+ name=template.name,
1577
+ source="<injected>",
1578
+ registry_file="",
1579
+ ),
1580
+ settings=SettingsConfig(
1581
+ # Use large sentinel for "unlimited" - avoids changing type throughout codebase
1582
+ task_message_limit=task_message_limit if task_message_limit is not None else 999999,
1583
+ ),
1584
+ )
1585
+
1586
+ # Set overrides BEFORE calling run_server
1587
+ _TEMPLATE_OVERRIDE = template
1588
+ _CONFIG_OVERRIDE = cfg
1589
+
1590
+ try:
1591
+ # Use run_server() to ensure _server_config and env vars are set properly
1592
+ # IMPORTANT: reload=False in cfg enforces single-process mode.
1593
+ # Template injection via globals does NOT work with reload or workers.
1594
+ assert cfg.reload is False, "reload must be False for template injection"
1595
+ run_server(cfg)
1596
+ finally:
1597
+ # Clear globals even if server errors
1598
+ _TEMPLATE_OVERRIDE = None
1599
+ _CONFIG_OVERRIDE = None
1600
+
1601
+
1602
+ if __name__ == "__main__":
1603
+ run_server(
1604
+ cfg=ServerConfig(),
1605
+ )