opencode-swarm-plugin 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.beads/issues.jsonl +115 -5
- package/README.md +38 -1
- package/bun.lock +23 -0
- package/dist/index.js +9697 -2
- package/dist/plugin.js +9695 -2
- package/package.json +2 -3
- package/src/agent-mail.ts +134 -0
- package/src/index.ts +2 -0
- package/src/rate-limiter.integration.test.ts +466 -0
- package/src/rate-limiter.ts +656 -0
- package/src/swarm.integration.test.ts +126 -0
- package/src/swarm.ts +278 -0
- package/src/tool-availability.ts +3 -2
- package/Dockerfile +0 -30
- package/docker/agent-mail/Dockerfile +0 -23
- package/docker/agent-mail/__pycache__/server.cpython-314.pyc +0 -0
- package/docker/agent-mail/requirements.txt +0 -3
- package/docker/agent-mail/server.py +0 -879
- package/docker-compose.yml +0 -45
- package/scripts/docker-entrypoint.sh +0 -54
|
@@ -1,879 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Minimal Agent Mail Server for Integration Testing
|
|
3
|
-
|
|
4
|
-
A lightweight MCP-compatible server that provides multi-agent coordination
|
|
5
|
-
capabilities: messaging, file reservations, and project management.
|
|
6
|
-
|
|
7
|
-
This is NOT the production Agent Mail server - it's a minimal implementation
|
|
8
|
-
for testing the opencode-swarm-plugin MCP client.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import random
|
|
12
|
-
import sqlite3
|
|
13
|
-
import uuid
|
|
14
|
-
from contextlib import contextmanager
|
|
15
|
-
from datetime import datetime, timedelta, timezone
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
from typing import Any
|
|
18
|
-
|
|
19
|
-
from fastapi import FastAPI, HTTPException
|
|
20
|
-
from fastapi.responses import JSONResponse
|
|
21
|
-
from pydantic import BaseModel
|
|
22
|
-
|
|
23
|
-
# =============================================================================
|
|
24
|
-
# Configuration
|
|
25
|
-
# =============================================================================
|
|
26
|
-
|
|
27
|
-
DB_PATH = Path("/data/agentmail.db")
|
|
28
|
-
|
|
29
|
-
# Agent name generation wordlists
|
|
30
|
-
ADJECTIVES = [
|
|
31
|
-
"Blue", "Red", "Green", "Golden", "Silver", "Crystal", "Shadow", "Bright",
|
|
32
|
-
"Swift", "Silent", "Bold", "Calm", "Wild", "Noble", "Frost", "Storm",
|
|
33
|
-
"Dawn", "Dusk", "Iron", "Copper", "Azure", "Crimson", "Amber", "Jade",
|
|
34
|
-
"Coral", "Misty", "Sunny", "Lunar", "Solar", "Cosmic", "Terra", "Aqua",
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
NOUNS = [
|
|
38
|
-
"Lake", "Stone", "River", "Mountain", "Forest", "Valley", "Meadow", "Peak",
|
|
39
|
-
"Canyon", "Desert", "Ocean", "Island", "Prairie", "Grove", "Creek", "Ridge",
|
|
40
|
-
"Harbor", "Cliff", "Glacier", "Dune", "Marsh", "Brook", "Hill", "Plain",
|
|
41
|
-
"Bay", "Cape", "Delta", "Fjord", "Mesa", "Plateau", "Reef", "Tundra",
|
|
42
|
-
]
|
|
43
|
-
|
|
44
|
-
# =============================================================================
|
|
45
|
-
# Database Setup
|
|
46
|
-
# =============================================================================
|
|
47
|
-
|
|
48
|
-
def init_db():
|
|
49
|
-
"""Initialize SQLite database with required tables."""
|
|
50
|
-
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
-
|
|
52
|
-
conn = sqlite3.connect(str(DB_PATH))
|
|
53
|
-
conn.row_factory = sqlite3.Row
|
|
54
|
-
|
|
55
|
-
conn.executescript("""
|
|
56
|
-
-- Projects table
|
|
57
|
-
CREATE TABLE IF NOT EXISTS projects (
|
|
58
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
-
slug TEXT UNIQUE NOT NULL,
|
|
60
|
-
human_key TEXT NOT NULL,
|
|
61
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
-- Agents table
|
|
65
|
-
CREATE TABLE IF NOT EXISTS agents (
|
|
66
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
-
name TEXT NOT NULL,
|
|
68
|
-
program TEXT NOT NULL,
|
|
69
|
-
model TEXT NOT NULL,
|
|
70
|
-
task_description TEXT,
|
|
71
|
-
inception_ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
72
|
-
last_active_ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
73
|
-
project_id INTEGER NOT NULL,
|
|
74
|
-
FOREIGN KEY (project_id) REFERENCES projects(id),
|
|
75
|
-
UNIQUE (name, project_id)
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
-- Messages table
|
|
79
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
80
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
81
|
-
project_id INTEGER NOT NULL,
|
|
82
|
-
sender_id INTEGER NOT NULL,
|
|
83
|
-
subject TEXT NOT NULL,
|
|
84
|
-
body_md TEXT,
|
|
85
|
-
thread_id TEXT,
|
|
86
|
-
importance TEXT DEFAULT 'normal',
|
|
87
|
-
ack_required INTEGER DEFAULT 0,
|
|
88
|
-
kind TEXT DEFAULT 'message',
|
|
89
|
-
created_ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
90
|
-
FOREIGN KEY (project_id) REFERENCES projects(id),
|
|
91
|
-
FOREIGN KEY (sender_id) REFERENCES agents(id)
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
-- Message recipients table (many-to-many)
|
|
95
|
-
CREATE TABLE IF NOT EXISTS message_recipients (
|
|
96
|
-
message_id INTEGER NOT NULL,
|
|
97
|
-
agent_id INTEGER NOT NULL,
|
|
98
|
-
read_at TEXT,
|
|
99
|
-
acked_at TEXT,
|
|
100
|
-
PRIMARY KEY (message_id, agent_id),
|
|
101
|
-
FOREIGN KEY (message_id) REFERENCES messages(id),
|
|
102
|
-
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
-- File reservations table
|
|
106
|
-
CREATE TABLE IF NOT EXISTS file_reservations (
|
|
107
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
108
|
-
project_id INTEGER NOT NULL,
|
|
109
|
-
agent_id INTEGER NOT NULL,
|
|
110
|
-
path_pattern TEXT NOT NULL,
|
|
111
|
-
exclusive INTEGER DEFAULT 1,
|
|
112
|
-
reason TEXT,
|
|
113
|
-
created_ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
114
|
-
expires_ts TEXT NOT NULL,
|
|
115
|
-
FOREIGN KEY (project_id) REFERENCES projects(id),
|
|
116
|
-
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
-- Full-text search for messages
|
|
120
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
121
|
-
subject, body_md, content='messages', content_rowid='id'
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
-- Triggers to keep FTS in sync
|
|
125
|
-
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
126
|
-
INSERT INTO messages_fts(rowid, subject, body_md)
|
|
127
|
-
VALUES (new.id, new.subject, new.body_md);
|
|
128
|
-
END;
|
|
129
|
-
|
|
130
|
-
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
131
|
-
INSERT INTO messages_fts(messages_fts, rowid, subject, body_md)
|
|
132
|
-
VALUES ('delete', old.id, old.subject, old.body_md);
|
|
133
|
-
END;
|
|
134
|
-
|
|
135
|
-
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
|
136
|
-
INSERT INTO messages_fts(messages_fts, rowid, subject, body_md)
|
|
137
|
-
VALUES ('delete', old.id, old.subject, old.body_md);
|
|
138
|
-
INSERT INTO messages_fts(rowid, subject, body_md)
|
|
139
|
-
VALUES (new.id, new.subject, new.body_md);
|
|
140
|
-
END;
|
|
141
|
-
""")
|
|
142
|
-
|
|
143
|
-
conn.commit()
|
|
144
|
-
conn.close()
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
@contextmanager
|
|
148
|
-
def get_db():
|
|
149
|
-
"""Get database connection with row factory."""
|
|
150
|
-
conn = sqlite3.connect(str(DB_PATH))
|
|
151
|
-
conn.row_factory = sqlite3.Row
|
|
152
|
-
try:
|
|
153
|
-
yield conn
|
|
154
|
-
conn.commit()
|
|
155
|
-
finally:
|
|
156
|
-
conn.close()
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def generate_agent_name() -> str:
|
|
160
|
-
"""Generate a random adjective+noun agent name."""
|
|
161
|
-
return f"{random.choice(ADJECTIVES)}{random.choice(NOUNS)}"
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def generate_slug(human_key: str) -> str:
|
|
165
|
-
"""Generate a URL-safe slug from a human key."""
|
|
166
|
-
# Simple slug: replace path separators and special chars
|
|
167
|
-
slug = human_key.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
|
168
|
-
slug = "".join(c for c in slug if c.isalnum() or c == "_")
|
|
169
|
-
return slug.lower()[:64]
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def now_iso() -> str:
|
|
173
|
-
"""Get current time in ISO format."""
|
|
174
|
-
return datetime.now(timezone.utc).isoformat()
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
# =============================================================================
|
|
178
|
-
# FastAPI App
|
|
179
|
-
# =============================================================================
|
|
180
|
-
|
|
181
|
-
app = FastAPI(title="Agent Mail Test Server", version="0.1.0")
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
@app.on_event("startup")
|
|
185
|
-
async def startup():
|
|
186
|
-
"""Initialize database on startup."""
|
|
187
|
-
init_db()
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
# =============================================================================
|
|
191
|
-
# Health Endpoints
|
|
192
|
-
# =============================================================================
|
|
193
|
-
|
|
194
|
-
@app.get("/health/liveness")
|
|
195
|
-
async def health_liveness():
|
|
196
|
-
"""Liveness check for container health."""
|
|
197
|
-
return {"status": "ok", "timestamp": now_iso()}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
@app.get("/health/readiness")
|
|
201
|
-
async def health_readiness():
|
|
202
|
-
"""Readiness check - verify database is accessible."""
|
|
203
|
-
try:
|
|
204
|
-
with get_db() as conn:
|
|
205
|
-
conn.execute("SELECT 1")
|
|
206
|
-
return {"status": "ready", "timestamp": now_iso()}
|
|
207
|
-
except Exception as e:
|
|
208
|
-
raise HTTPException(status_code=503, detail=str(e))
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
# =============================================================================
|
|
212
|
-
# MCP JSON-RPC Endpoint
|
|
213
|
-
# =============================================================================
|
|
214
|
-
|
|
215
|
-
class MCPRequest(BaseModel):
|
|
216
|
-
"""MCP JSON-RPC request format."""
|
|
217
|
-
jsonrpc: str = "2.0"
|
|
218
|
-
id: str
|
|
219
|
-
method: str
|
|
220
|
-
params: dict[str, Any] = {}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
class MCPError(BaseModel):
|
|
224
|
-
"""MCP JSON-RPC error format."""
|
|
225
|
-
code: int
|
|
226
|
-
message: str
|
|
227
|
-
data: Any = None
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
class MCPResponse(BaseModel):
|
|
231
|
-
"""MCP JSON-RPC response format."""
|
|
232
|
-
jsonrpc: str = "2.0"
|
|
233
|
-
id: str
|
|
234
|
-
result: Any = None
|
|
235
|
-
error: MCPError | None = None
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
@app.post("/mcp/")
|
|
239
|
-
async def mcp_endpoint(request: MCPRequest):
|
|
240
|
-
"""
|
|
241
|
-
MCP JSON-RPC endpoint.
|
|
242
|
-
|
|
243
|
-
Handles tools/call method for Agent Mail operations.
|
|
244
|
-
"""
|
|
245
|
-
if request.method != "tools/call":
|
|
246
|
-
return MCPResponse(
|
|
247
|
-
id=request.id,
|
|
248
|
-
error=MCPError(
|
|
249
|
-
code=-32601,
|
|
250
|
-
message=f"Method not found: {request.method}",
|
|
251
|
-
)
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
tool_name = request.params.get("name", "")
|
|
255
|
-
arguments = request.params.get("arguments", {})
|
|
256
|
-
|
|
257
|
-
try:
|
|
258
|
-
result = await dispatch_tool(tool_name, arguments)
|
|
259
|
-
return MCPResponse(id=request.id, result=result)
|
|
260
|
-
except ValueError as e:
|
|
261
|
-
return MCPResponse(
|
|
262
|
-
id=request.id,
|
|
263
|
-
error=MCPError(code=-32602, message=str(e))
|
|
264
|
-
)
|
|
265
|
-
except Exception as e:
|
|
266
|
-
return MCPResponse(
|
|
267
|
-
id=request.id,
|
|
268
|
-
error=MCPError(code=-32000, message=str(e))
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
# =============================================================================
|
|
273
|
-
# Tool Dispatcher
|
|
274
|
-
# =============================================================================
|
|
275
|
-
|
|
276
|
-
async def dispatch_tool(name: str, args: dict[str, Any]) -> Any:
|
|
277
|
-
"""Dispatch tool call to appropriate handler."""
|
|
278
|
-
tools = {
|
|
279
|
-
"ensure_project": tool_ensure_project,
|
|
280
|
-
"register_agent": tool_register_agent,
|
|
281
|
-
"send_message": tool_send_message,
|
|
282
|
-
"fetch_inbox": tool_fetch_inbox,
|
|
283
|
-
"mark_message_read": tool_mark_message_read,
|
|
284
|
-
"summarize_thread": tool_summarize_thread,
|
|
285
|
-
"file_reservation_paths": tool_file_reservation_paths,
|
|
286
|
-
"release_file_reservations": tool_release_file_reservations,
|
|
287
|
-
"acknowledge_message": tool_acknowledge_message,
|
|
288
|
-
"search_messages": tool_search_messages,
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
handler = tools.get(name)
|
|
292
|
-
if not handler:
|
|
293
|
-
raise ValueError(f"Unknown tool: {name}")
|
|
294
|
-
|
|
295
|
-
return await handler(args)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
# =============================================================================
|
|
299
|
-
# Tool Implementations
|
|
300
|
-
# =============================================================================
|
|
301
|
-
|
|
302
|
-
async def tool_ensure_project(args: dict[str, Any]) -> dict:
|
|
303
|
-
"""Create or get a project by human_key."""
|
|
304
|
-
human_key = args.get("human_key")
|
|
305
|
-
if not human_key:
|
|
306
|
-
raise ValueError("human_key is required")
|
|
307
|
-
|
|
308
|
-
slug = generate_slug(human_key)
|
|
309
|
-
|
|
310
|
-
with get_db() as conn:
|
|
311
|
-
# Try to find existing project
|
|
312
|
-
row = conn.execute(
|
|
313
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
314
|
-
(human_key,)
|
|
315
|
-
).fetchone()
|
|
316
|
-
|
|
317
|
-
if row:
|
|
318
|
-
return dict(row)
|
|
319
|
-
|
|
320
|
-
# Create new project
|
|
321
|
-
cursor = conn.execute(
|
|
322
|
-
"INSERT INTO projects (slug, human_key, created_at) VALUES (?, ?, ?)",
|
|
323
|
-
(slug, human_key, now_iso())
|
|
324
|
-
)
|
|
325
|
-
project_id = cursor.lastrowid
|
|
326
|
-
|
|
327
|
-
row = conn.execute(
|
|
328
|
-
"SELECT * FROM projects WHERE id = ?",
|
|
329
|
-
(project_id,)
|
|
330
|
-
).fetchone()
|
|
331
|
-
|
|
332
|
-
return dict(row)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
async def tool_register_agent(args: dict[str, Any]) -> dict:
|
|
336
|
-
"""Register an agent with a project."""
|
|
337
|
-
project_key = args.get("project_key")
|
|
338
|
-
program = args.get("program", "unknown")
|
|
339
|
-
model = args.get("model", "unknown")
|
|
340
|
-
name = args.get("name")
|
|
341
|
-
task_description = args.get("task_description", "")
|
|
342
|
-
|
|
343
|
-
if not project_key:
|
|
344
|
-
raise ValueError("project_key is required")
|
|
345
|
-
|
|
346
|
-
with get_db() as conn:
|
|
347
|
-
# Get project
|
|
348
|
-
project = conn.execute(
|
|
349
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
350
|
-
(project_key,)
|
|
351
|
-
).fetchone()
|
|
352
|
-
|
|
353
|
-
if not project:
|
|
354
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
355
|
-
|
|
356
|
-
project_id = project["id"]
|
|
357
|
-
|
|
358
|
-
# Generate name if not provided
|
|
359
|
-
if not name:
|
|
360
|
-
# Keep trying until we get a unique name
|
|
361
|
-
for _ in range(100):
|
|
362
|
-
name = generate_agent_name()
|
|
363
|
-
existing = conn.execute(
|
|
364
|
-
"SELECT id FROM agents WHERE name = ? AND project_id = ?",
|
|
365
|
-
(name, project_id)
|
|
366
|
-
).fetchone()
|
|
367
|
-
if not existing:
|
|
368
|
-
break
|
|
369
|
-
else:
|
|
370
|
-
name = f"{generate_agent_name()}_{uuid.uuid4().hex[:4]}"
|
|
371
|
-
|
|
372
|
-
# Check if agent already exists
|
|
373
|
-
existing = conn.execute(
|
|
374
|
-
"SELECT * FROM agents WHERE name = ? AND project_id = ?",
|
|
375
|
-
(name, project_id)
|
|
376
|
-
).fetchone()
|
|
377
|
-
|
|
378
|
-
if existing:
|
|
379
|
-
# Update last_active_ts
|
|
380
|
-
conn.execute(
|
|
381
|
-
"UPDATE agents SET last_active_ts = ? WHERE id = ?",
|
|
382
|
-
(now_iso(), existing["id"])
|
|
383
|
-
)
|
|
384
|
-
return dict(existing)
|
|
385
|
-
|
|
386
|
-
# Create new agent
|
|
387
|
-
now = now_iso()
|
|
388
|
-
cursor = conn.execute(
|
|
389
|
-
"""INSERT INTO agents
|
|
390
|
-
(name, program, model, task_description, inception_ts, last_active_ts, project_id)
|
|
391
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
392
|
-
(name, program, model, task_description, now, now, project_id)
|
|
393
|
-
)
|
|
394
|
-
agent_id = cursor.lastrowid
|
|
395
|
-
|
|
396
|
-
row = conn.execute(
|
|
397
|
-
"SELECT * FROM agents WHERE id = ?",
|
|
398
|
-
(agent_id,)
|
|
399
|
-
).fetchone()
|
|
400
|
-
|
|
401
|
-
return dict(row)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
async def tool_send_message(args: dict[str, Any]) -> dict:
|
|
405
|
-
"""Send a message to other agents."""
|
|
406
|
-
project_key = args.get("project_key")
|
|
407
|
-
sender_name = args.get("sender_name")
|
|
408
|
-
to = args.get("to", [])
|
|
409
|
-
subject = args.get("subject", "")
|
|
410
|
-
body_md = args.get("body_md", "")
|
|
411
|
-
thread_id = args.get("thread_id")
|
|
412
|
-
importance = args.get("importance", "normal")
|
|
413
|
-
ack_required = args.get("ack_required", False)
|
|
414
|
-
|
|
415
|
-
if not project_key:
|
|
416
|
-
raise ValueError("project_key is required")
|
|
417
|
-
if not sender_name:
|
|
418
|
-
raise ValueError("sender_name is required")
|
|
419
|
-
if not to:
|
|
420
|
-
raise ValueError("to is required (list of recipient names)")
|
|
421
|
-
|
|
422
|
-
with get_db() as conn:
|
|
423
|
-
# Get project
|
|
424
|
-
project = conn.execute(
|
|
425
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
426
|
-
(project_key,)
|
|
427
|
-
).fetchone()
|
|
428
|
-
if not project:
|
|
429
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
430
|
-
|
|
431
|
-
project_id = project["id"]
|
|
432
|
-
|
|
433
|
-
# Get sender agent
|
|
434
|
-
sender = conn.execute(
|
|
435
|
-
"SELECT * FROM agents WHERE name = ? AND project_id = ?",
|
|
436
|
-
(sender_name, project_id)
|
|
437
|
-
).fetchone()
|
|
438
|
-
if not sender:
|
|
439
|
-
raise ValueError(f"Sender agent not found: {sender_name}")
|
|
440
|
-
|
|
441
|
-
# Create message
|
|
442
|
-
cursor = conn.execute(
|
|
443
|
-
"""INSERT INTO messages
|
|
444
|
-
(project_id, sender_id, subject, body_md, thread_id, importance, ack_required, created_ts)
|
|
445
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
446
|
-
(project_id, sender["id"], subject, body_md, thread_id, importance,
|
|
447
|
-
1 if ack_required else 0, now_iso())
|
|
448
|
-
)
|
|
449
|
-
message_id = cursor.lastrowid
|
|
450
|
-
|
|
451
|
-
# Add recipients
|
|
452
|
-
for recipient_name in to:
|
|
453
|
-
recipient = conn.execute(
|
|
454
|
-
"SELECT * FROM agents WHERE name = ? AND project_id = ?",
|
|
455
|
-
(recipient_name, project_id)
|
|
456
|
-
).fetchone()
|
|
457
|
-
|
|
458
|
-
if recipient:
|
|
459
|
-
conn.execute(
|
|
460
|
-
"INSERT INTO message_recipients (message_id, agent_id) VALUES (?, ?)",
|
|
461
|
-
(message_id, recipient["id"])
|
|
462
|
-
)
|
|
463
|
-
|
|
464
|
-
return {
|
|
465
|
-
"id": message_id,
|
|
466
|
-
"subject": subject,
|
|
467
|
-
"sent_to": to,
|
|
468
|
-
"created_ts": now_iso(),
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
async def tool_fetch_inbox(args: dict[str, Any]) -> list[dict]:
|
|
473
|
-
"""Fetch inbox messages for an agent."""
|
|
474
|
-
project_key = args.get("project_key")
|
|
475
|
-
agent_name = args.get("agent_name")
|
|
476
|
-
limit = args.get("limit", 10)
|
|
477
|
-
include_bodies = args.get("include_bodies", False)
|
|
478
|
-
urgent_only = args.get("urgent_only", False)
|
|
479
|
-
since_ts = args.get("since_ts")
|
|
480
|
-
|
|
481
|
-
if not project_key:
|
|
482
|
-
raise ValueError("project_key is required")
|
|
483
|
-
if not agent_name:
|
|
484
|
-
raise ValueError("agent_name is required")
|
|
485
|
-
|
|
486
|
-
with get_db() as conn:
|
|
487
|
-
# Get project and agent
|
|
488
|
-
project = conn.execute(
|
|
489
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
490
|
-
(project_key,)
|
|
491
|
-
).fetchone()
|
|
492
|
-
if not project:
|
|
493
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
494
|
-
|
|
495
|
-
agent = conn.execute(
|
|
496
|
-
"SELECT * FROM agents WHERE name = ? AND project_id = ?",
|
|
497
|
-
(agent_name, project["id"])
|
|
498
|
-
).fetchone()
|
|
499
|
-
if not agent:
|
|
500
|
-
raise ValueError(f"Agent not found: {agent_name}")
|
|
501
|
-
|
|
502
|
-
# Build query
|
|
503
|
-
query = """
|
|
504
|
-
SELECT m.*, a.name as from_name
|
|
505
|
-
FROM messages m
|
|
506
|
-
JOIN message_recipients mr ON m.id = mr.message_id
|
|
507
|
-
JOIN agents a ON m.sender_id = a.id
|
|
508
|
-
WHERE mr.agent_id = ?
|
|
509
|
-
"""
|
|
510
|
-
params: list[Any] = [agent["id"]]
|
|
511
|
-
|
|
512
|
-
if urgent_only:
|
|
513
|
-
query += " AND m.importance = 'urgent'"
|
|
514
|
-
|
|
515
|
-
if since_ts:
|
|
516
|
-
query += " AND m.created_ts > ?"
|
|
517
|
-
params.append(since_ts)
|
|
518
|
-
|
|
519
|
-
query += " ORDER BY m.created_ts DESC LIMIT ?"
|
|
520
|
-
params.append(limit)
|
|
521
|
-
|
|
522
|
-
rows = conn.execute(query, params).fetchall()
|
|
523
|
-
|
|
524
|
-
messages = []
|
|
525
|
-
for row in rows:
|
|
526
|
-
msg = {
|
|
527
|
-
"id": row["id"],
|
|
528
|
-
"subject": row["subject"],
|
|
529
|
-
"from": row["from_name"],
|
|
530
|
-
"created_ts": row["created_ts"],
|
|
531
|
-
"importance": row["importance"],
|
|
532
|
-
"ack_required": bool(row["ack_required"]),
|
|
533
|
-
"thread_id": row["thread_id"],
|
|
534
|
-
"kind": row["kind"],
|
|
535
|
-
}
|
|
536
|
-
if include_bodies:
|
|
537
|
-
msg["body_md"] = row["body_md"]
|
|
538
|
-
messages.append(msg)
|
|
539
|
-
|
|
540
|
-
return messages
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
async def tool_mark_message_read(args: dict[str, Any]) -> dict:
|
|
544
|
-
"""Mark a message as read."""
|
|
545
|
-
project_key = args.get("project_key")
|
|
546
|
-
agent_name = args.get("agent_name")
|
|
547
|
-
message_id = args.get("message_id")
|
|
548
|
-
|
|
549
|
-
if not all([project_key, agent_name, message_id]):
|
|
550
|
-
raise ValueError("project_key, agent_name, and message_id are required")
|
|
551
|
-
|
|
552
|
-
with get_db() as conn:
|
|
553
|
-
# Get agent
|
|
554
|
-
project = conn.execute(
|
|
555
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
556
|
-
(project_key,)
|
|
557
|
-
).fetchone()
|
|
558
|
-
if not project:
|
|
559
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
560
|
-
|
|
561
|
-
agent = conn.execute(
|
|
562
|
-
"SELECT * FROM agents WHERE name = ? AND project_id = ?",
|
|
563
|
-
(agent_name, project["id"])
|
|
564
|
-
).fetchone()
|
|
565
|
-
if not agent:
|
|
566
|
-
raise ValueError(f"Agent not found: {agent_name}")
|
|
567
|
-
|
|
568
|
-
# Update read timestamp
|
|
569
|
-
conn.execute(
|
|
570
|
-
"""UPDATE message_recipients
|
|
571
|
-
SET read_at = ?
|
|
572
|
-
WHERE message_id = ? AND agent_id = ?""",
|
|
573
|
-
(now_iso(), message_id, agent["id"])
|
|
574
|
-
)
|
|
575
|
-
|
|
576
|
-
return {"message_id": message_id, "read_at": now_iso()}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
async def tool_summarize_thread(args: dict[str, Any]) -> dict:
|
|
580
|
-
"""Summarize a message thread."""
|
|
581
|
-
project_key = args.get("project_key")
|
|
582
|
-
thread_id = args.get("thread_id")
|
|
583
|
-
include_examples = args.get("include_examples", False)
|
|
584
|
-
|
|
585
|
-
if not project_key:
|
|
586
|
-
raise ValueError("project_key is required")
|
|
587
|
-
if not thread_id:
|
|
588
|
-
raise ValueError("thread_id is required")
|
|
589
|
-
|
|
590
|
-
with get_db() as conn:
|
|
591
|
-
# Get project
|
|
592
|
-
project = conn.execute(
|
|
593
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
594
|
-
(project_key,)
|
|
595
|
-
).fetchone()
|
|
596
|
-
if not project:
|
|
597
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
598
|
-
|
|
599
|
-
# Get messages in thread
|
|
600
|
-
rows = conn.execute(
|
|
601
|
-
"""SELECT m.*, a.name as from_name
|
|
602
|
-
FROM messages m
|
|
603
|
-
JOIN agents a ON m.sender_id = a.id
|
|
604
|
-
WHERE m.thread_id = ? AND m.project_id = ?
|
|
605
|
-
ORDER BY m.created_ts ASC""",
|
|
606
|
-
(thread_id, project["id"])
|
|
607
|
-
).fetchall()
|
|
608
|
-
|
|
609
|
-
# Build summary
|
|
610
|
-
participants = list(set(row["from_name"] for row in rows))
|
|
611
|
-
|
|
612
|
-
# Simple key points extraction (just use subjects for now)
|
|
613
|
-
key_points = [row["subject"] for row in rows[:5]]
|
|
614
|
-
|
|
615
|
-
# Action items (messages with "urgent" importance)
|
|
616
|
-
action_items = [
|
|
617
|
-
row["subject"] for row in rows
|
|
618
|
-
if row["importance"] == "urgent"
|
|
619
|
-
]
|
|
620
|
-
|
|
621
|
-
result = {
|
|
622
|
-
"thread_id": thread_id,
|
|
623
|
-
"summary": {
|
|
624
|
-
"participants": participants,
|
|
625
|
-
"key_points": key_points,
|
|
626
|
-
"action_items": action_items,
|
|
627
|
-
"total_messages": len(rows),
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
if include_examples and rows:
|
|
632
|
-
examples = []
|
|
633
|
-
for row in rows[:3]:
|
|
634
|
-
examples.append({
|
|
635
|
-
"id": row["id"],
|
|
636
|
-
"subject": row["subject"],
|
|
637
|
-
"from": row["from_name"],
|
|
638
|
-
"body_md": row["body_md"],
|
|
639
|
-
})
|
|
640
|
-
result["examples"] = examples
|
|
641
|
-
|
|
642
|
-
return result
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
async def tool_file_reservation_paths(args: dict[str, Any]) -> dict:
|
|
646
|
-
"""Reserve file paths for exclusive editing."""
|
|
647
|
-
project_key = args.get("project_key")
|
|
648
|
-
agent_name = args.get("agent_name")
|
|
649
|
-
paths = args.get("paths", [])
|
|
650
|
-
ttl_seconds = args.get("ttl_seconds", 3600)
|
|
651
|
-
exclusive = args.get("exclusive", True)
|
|
652
|
-
reason = args.get("reason", "")
|
|
653
|
-
|
|
654
|
-
if not project_key:
|
|
655
|
-
raise ValueError("project_key is required")
|
|
656
|
-
if not agent_name:
|
|
657
|
-
raise ValueError("agent_name is required")
|
|
658
|
-
if not paths:
|
|
659
|
-
raise ValueError("paths is required (list of path patterns)")
|
|
660
|
-
|
|
661
|
-
with get_db() as conn:
|
|
662
|
-
# Get project and agent
|
|
663
|
-
project = conn.execute(
|
|
664
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
665
|
-
(project_key,)
|
|
666
|
-
).fetchone()
|
|
667
|
-
if not project:
|
|
668
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
669
|
-
|
|
670
|
-
agent = conn.execute(
|
|
671
|
-
"SELECT * FROM agents WHERE name = ? AND project_id = ?",
|
|
672
|
-
(agent_name, project["id"])
|
|
673
|
-
).fetchone()
|
|
674
|
-
if not agent:
|
|
675
|
-
raise ValueError(f"Agent not found: {agent_name}")
|
|
676
|
-
|
|
677
|
-
project_id = project["id"]
|
|
678
|
-
agent_id = agent["id"]
|
|
679
|
-
|
|
680
|
-
# Check for conflicts with existing reservations
|
|
681
|
-
conflicts = []
|
|
682
|
-
granted = []
|
|
683
|
-
now = now_iso()
|
|
684
|
-
expires = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat()
|
|
685
|
-
|
|
686
|
-
# Clean up expired reservations first
|
|
687
|
-
conn.execute(
|
|
688
|
-
"DELETE FROM file_reservations WHERE expires_ts < ?",
|
|
689
|
-
(now,)
|
|
690
|
-
)
|
|
691
|
-
|
|
692
|
-
for path in paths:
|
|
693
|
-
# Check for conflicting exclusive reservations
|
|
694
|
-
# Simple matching: exact match or glob patterns
|
|
695
|
-
conflicting = conn.execute(
|
|
696
|
-
"""SELECT fr.*, a.name as holder_name
|
|
697
|
-
FROM file_reservations fr
|
|
698
|
-
JOIN agents a ON fr.agent_id = a.id
|
|
699
|
-
WHERE fr.project_id = ?
|
|
700
|
-
AND fr.agent_id != ?
|
|
701
|
-
AND fr.exclusive = 1
|
|
702
|
-
AND (fr.path_pattern = ? OR fr.path_pattern LIKE ? OR ? LIKE fr.path_pattern)""",
|
|
703
|
-
(project_id, agent_id, path, path.replace("*", "%"), path.replace("*", "%"))
|
|
704
|
-
).fetchall()
|
|
705
|
-
|
|
706
|
-
if conflicting:
|
|
707
|
-
conflicts.append({
|
|
708
|
-
"path": path,
|
|
709
|
-
"holders": [r["holder_name"] for r in conflicting],
|
|
710
|
-
})
|
|
711
|
-
else:
|
|
712
|
-
# Grant the reservation
|
|
713
|
-
cursor = conn.execute(
|
|
714
|
-
"""INSERT INTO file_reservations
|
|
715
|
-
(project_id, agent_id, path_pattern, exclusive, reason, created_ts, expires_ts)
|
|
716
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
717
|
-
(project_id, agent_id, path, 1 if exclusive else 0, reason, now, expires)
|
|
718
|
-
)
|
|
719
|
-
granted.append({
|
|
720
|
-
"id": cursor.lastrowid,
|
|
721
|
-
"path_pattern": path,
|
|
722
|
-
"exclusive": exclusive,
|
|
723
|
-
"reason": reason,
|
|
724
|
-
"expires_ts": expires,
|
|
725
|
-
})
|
|
726
|
-
|
|
727
|
-
return {
|
|
728
|
-
"granted": granted,
|
|
729
|
-
"conflicts": conflicts,
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
async def tool_release_file_reservations(args: dict[str, Any]) -> dict:
|
|
734
|
-
"""Release file reservations."""
|
|
735
|
-
project_key = args.get("project_key")
|
|
736
|
-
agent_name = args.get("agent_name")
|
|
737
|
-
paths = args.get("paths")
|
|
738
|
-
file_reservation_ids = args.get("file_reservation_ids")
|
|
739
|
-
|
|
740
|
-
if not project_key:
|
|
741
|
-
raise ValueError("project_key is required")
|
|
742
|
-
if not agent_name:
|
|
743
|
-
raise ValueError("agent_name is required")
|
|
744
|
-
|
|
745
|
-
with get_db() as conn:
|
|
746
|
-
# Get project and agent
|
|
747
|
-
project = conn.execute(
|
|
748
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
749
|
-
(project_key,)
|
|
750
|
-
).fetchone()
|
|
751
|
-
if not project:
|
|
752
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
753
|
-
|
|
754
|
-
agent = conn.execute(
|
|
755
|
-
"SELECT * FROM agents WHERE name = ? AND project_id = ?",
|
|
756
|
-
(agent_name, project["id"])
|
|
757
|
-
).fetchone()
|
|
758
|
-
if not agent:
|
|
759
|
-
raise ValueError(f"Agent not found: {agent_name}")
|
|
760
|
-
|
|
761
|
-
# Build delete query
|
|
762
|
-
if file_reservation_ids:
|
|
763
|
-
# Delete by IDs
|
|
764
|
-
placeholders = ",".join("?" * len(file_reservation_ids))
|
|
765
|
-
cursor = conn.execute(
|
|
766
|
-
f"""DELETE FROM file_reservations
|
|
767
|
-
WHERE id IN ({placeholders}) AND agent_id = ?""",
|
|
768
|
-
(*file_reservation_ids, agent["id"])
|
|
769
|
-
)
|
|
770
|
-
elif paths:
|
|
771
|
-
# Delete by paths
|
|
772
|
-
placeholders = ",".join("?" * len(paths))
|
|
773
|
-
cursor = conn.execute(
|
|
774
|
-
f"""DELETE FROM file_reservations
|
|
775
|
-
WHERE path_pattern IN ({placeholders}) AND agent_id = ?""",
|
|
776
|
-
(*paths, agent["id"])
|
|
777
|
-
)
|
|
778
|
-
else:
|
|
779
|
-
# Delete all for this agent
|
|
780
|
-
cursor = conn.execute(
|
|
781
|
-
"DELETE FROM file_reservations WHERE agent_id = ?",
|
|
782
|
-
(agent["id"],)
|
|
783
|
-
)
|
|
784
|
-
|
|
785
|
-
return {
|
|
786
|
-
"released": cursor.rowcount,
|
|
787
|
-
"released_at": now_iso(),
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
async def tool_acknowledge_message(args: dict[str, Any]) -> dict:
|
|
792
|
-
"""Acknowledge a message."""
|
|
793
|
-
project_key = args.get("project_key")
|
|
794
|
-
agent_name = args.get("agent_name")
|
|
795
|
-
message_id = args.get("message_id")
|
|
796
|
-
|
|
797
|
-
if not all([project_key, agent_name, message_id]):
|
|
798
|
-
raise ValueError("project_key, agent_name, and message_id are required")
|
|
799
|
-
|
|
800
|
-
with get_db() as conn:
|
|
801
|
-
# Get agent
|
|
802
|
-
project = conn.execute(
|
|
803
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
804
|
-
(project_key,)
|
|
805
|
-
).fetchone()
|
|
806
|
-
if not project:
|
|
807
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
808
|
-
|
|
809
|
-
agent = conn.execute(
|
|
810
|
-
"SELECT * FROM agents WHERE name = ? AND project_id = ?",
|
|
811
|
-
(agent_name, project["id"])
|
|
812
|
-
).fetchone()
|
|
813
|
-
if not agent:
|
|
814
|
-
raise ValueError(f"Agent not found: {agent_name}")
|
|
815
|
-
|
|
816
|
-
# Update ack timestamp
|
|
817
|
-
now = now_iso()
|
|
818
|
-
conn.execute(
|
|
819
|
-
"""UPDATE message_recipients
|
|
820
|
-
SET acked_at = ?
|
|
821
|
-
WHERE message_id = ? AND agent_id = ?""",
|
|
822
|
-
(now, message_id, agent["id"])
|
|
823
|
-
)
|
|
824
|
-
|
|
825
|
-
return {"message_id": message_id, "acked_at": now}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
async def tool_search_messages(args: dict[str, Any]) -> list[dict]:
|
|
829
|
-
"""Search messages using FTS5."""
|
|
830
|
-
project_key = args.get("project_key")
|
|
831
|
-
query = args.get("query", "")
|
|
832
|
-
limit = args.get("limit", 20)
|
|
833
|
-
|
|
834
|
-
if not project_key:
|
|
835
|
-
raise ValueError("project_key is required")
|
|
836
|
-
if not query:
|
|
837
|
-
raise ValueError("query is required")
|
|
838
|
-
|
|
839
|
-
with get_db() as conn:
|
|
840
|
-
# Get project
|
|
841
|
-
project = conn.execute(
|
|
842
|
-
"SELECT * FROM projects WHERE human_key = ?",
|
|
843
|
-
(project_key,)
|
|
844
|
-
).fetchone()
|
|
845
|
-
if not project:
|
|
846
|
-
raise ValueError(f"Project not found: {project_key}")
|
|
847
|
-
|
|
848
|
-
# Search using FTS5
|
|
849
|
-
rows = conn.execute(
|
|
850
|
-
"""SELECT m.*, a.name as from_name
|
|
851
|
-
FROM messages m
|
|
852
|
-
JOIN messages_fts fts ON m.id = fts.rowid
|
|
853
|
-
JOIN agents a ON m.sender_id = a.id
|
|
854
|
-
WHERE m.project_id = ? AND messages_fts MATCH ?
|
|
855
|
-
ORDER BY rank
|
|
856
|
-
LIMIT ?""",
|
|
857
|
-
(project["id"], query, limit)
|
|
858
|
-
).fetchall()
|
|
859
|
-
|
|
860
|
-
return [
|
|
861
|
-
{
|
|
862
|
-
"id": row["id"],
|
|
863
|
-
"subject": row["subject"],
|
|
864
|
-
"from": row["from_name"],
|
|
865
|
-
"created_ts": row["created_ts"],
|
|
866
|
-
"importance": row["importance"],
|
|
867
|
-
"thread_id": row["thread_id"],
|
|
868
|
-
}
|
|
869
|
-
for row in rows
|
|
870
|
-
]
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
# =============================================================================
|
|
874
|
-
# Main
|
|
875
|
-
# =============================================================================
|
|
876
|
-
|
|
877
|
-
if __name__ == "__main__":
|
|
878
|
-
import uvicorn
|
|
879
|
-
uvicorn.run(app, host="0.0.0.0", port=8765)
|