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/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
|
+
)
|