code-graph-context 2.8.0 → 2.9.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/dist/mcp/constants.js +24 -1
- package/dist/mcp/tools/index.js +3 -0
- package/dist/mcp/tools/swarm-claim-task.tool.js +35 -0
- package/dist/mcp/tools/swarm-cleanup.tool.js +55 -3
- package/dist/mcp/tools/swarm-constants.js +28 -0
- package/dist/mcp/tools/swarm-message.tool.js +362 -0
- package/package.json +1 -1
package/dist/mcp/constants.js
CHANGED
|
@@ -43,6 +43,7 @@ export const TOOL_NAMES = {
|
|
|
43
43
|
saveSessionNote: 'save_session_note',
|
|
44
44
|
recallSessionNotes: 'recall_session_notes',
|
|
45
45
|
cleanupSession: 'cleanup_session',
|
|
46
|
+
swarmMessage: 'swarm_message',
|
|
46
47
|
};
|
|
47
48
|
// Tool Metadata
|
|
48
49
|
export const TOOL_METADATA = {
|
|
@@ -221,7 +222,7 @@ Returns pheromones with current intensity after decay. minIntensity default: 0.3
|
|
|
221
222
|
},
|
|
222
223
|
[TOOL_NAMES.swarmCleanup]: {
|
|
223
224
|
title: 'Swarm Cleanup',
|
|
224
|
-
description: `Bulk delete pheromones. Specify swarmId, agentId, or all:true. Warning pheromones preserved by default (override with keepTypes:[]). Use dryRun:true to preview.`,
|
|
225
|
+
description: `Bulk delete pheromones, tasks, and messages. Specify swarmId, agentId, or all:true. Warning pheromones preserved by default (override with keepTypes:[]). Messages and tasks are deleted when swarmId is provided. Use dryRun:true to preview.`,
|
|
225
226
|
},
|
|
226
227
|
[TOOL_NAMES.swarmPostTask]: {
|
|
227
228
|
title: 'Swarm Post Task',
|
|
@@ -343,6 +344,28 @@ Parameters:
|
|
|
343
344
|
|
|
344
345
|
Returns counts of deleted notes, bookmarks, and edges.`,
|
|
345
346
|
},
|
|
347
|
+
[TOOL_NAMES.swarmMessage]: {
|
|
348
|
+
title: 'Swarm Message',
|
|
349
|
+
description: `Direct agent-to-agent messaging via Neo4j. Unlike pheromones (passive/decay-based), messages are explicit and delivered when agents claim tasks.
|
|
350
|
+
|
|
351
|
+
**Actions:**
|
|
352
|
+
- send: Post a message to a specific agent or broadcast to all agents in a swarm
|
|
353
|
+
- read: Retrieve unread messages (or all messages) for an agent
|
|
354
|
+
- acknowledge: Mark messages as read
|
|
355
|
+
|
|
356
|
+
**Categories:** blocked, conflict, finding, request, alert, handoff
|
|
357
|
+
|
|
358
|
+
**Key behavior:**
|
|
359
|
+
- Messages sent to a specific agent are delivered when that agent calls swarm_claim_task
|
|
360
|
+
- Broadcast messages (toAgentId omitted) are visible to all agents in the swarm
|
|
361
|
+
- Messages auto-expire after 4 hours (configurable via ttlMs)
|
|
362
|
+
- Use this for critical coordination signals that agents MUST see, not optional context
|
|
363
|
+
|
|
364
|
+
**Examples:**
|
|
365
|
+
- Agent finds a breaking type error: send alert to all
|
|
366
|
+
- Agent is blocked on a file another agent owns: send blocked to that agent
|
|
367
|
+
- Agent discovers context another agent needs: send finding to that agent`,
|
|
368
|
+
},
|
|
346
369
|
};
|
|
347
370
|
// Default Values
|
|
348
371
|
export const DEFAULTS = {
|
package/dist/mcp/tools/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { createSwarmClaimTaskTool } from './swarm-claim-task.tool.js';
|
|
|
21
21
|
import { createSwarmCleanupTool } from './swarm-cleanup.tool.js';
|
|
22
22
|
import { createSwarmCompleteTaskTool } from './swarm-complete-task.tool.js';
|
|
23
23
|
import { createSwarmGetTasksTool } from './swarm-get-tasks.tool.js';
|
|
24
|
+
import { createSwarmMessageTool } from './swarm-message.tool.js';
|
|
24
25
|
import { createSwarmPheromoneTool } from './swarm-pheromone.tool.js';
|
|
25
26
|
import { createSwarmPostTaskTool } from './swarm-post-task.tool.js';
|
|
26
27
|
import { createSwarmSenseTool } from './swarm-sense.tool.js';
|
|
@@ -73,6 +74,8 @@ export const registerAllTools = (server) => {
|
|
|
73
74
|
createSwarmClaimTaskTool(server);
|
|
74
75
|
createSwarmCompleteTaskTool(server);
|
|
75
76
|
createSwarmGetTasksTool(server);
|
|
77
|
+
// Register swarm messaging tools (direct agent-to-agent communication)
|
|
78
|
+
createSwarmMessageTool(server);
|
|
76
79
|
// Register session bookmark tools (cross-session context continuity)
|
|
77
80
|
createSaveSessionBookmarkTool(server);
|
|
78
81
|
createRestoreSessionBookmarkTool(server);
|
|
@@ -12,6 +12,7 @@ import { Neo4jService } from '../../storage/neo4j/neo4j.service.js';
|
|
|
12
12
|
import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
|
|
13
13
|
import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
|
|
14
14
|
import { TASK_TYPES, TASK_PRIORITIES } from './swarm-constants.js';
|
|
15
|
+
import { PENDING_MESSAGES_FOR_AGENT_QUERY, AUTO_ACKNOWLEDGE_QUERY } from './swarm-message.tool.js';
|
|
15
16
|
/** Maximum retries when racing for a task */
|
|
16
17
|
const MAX_CLAIM_RETRIES = 3;
|
|
17
18
|
/** Delay between retries (ms) */
|
|
@@ -435,6 +436,39 @@ export const createSwarmClaimTaskTool = (server) => {
|
|
|
435
436
|
name: t.name,
|
|
436
437
|
filePath: t.filePath,
|
|
437
438
|
}));
|
|
439
|
+
// Fetch pending messages for this agent (direct delivery on claim)
|
|
440
|
+
let pendingMessages = [];
|
|
441
|
+
try {
|
|
442
|
+
const msgResult = await neo4jService.run(PENDING_MESSAGES_FOR_AGENT_QUERY, {
|
|
443
|
+
projectId: resolvedProjectId,
|
|
444
|
+
swarmId,
|
|
445
|
+
agentId,
|
|
446
|
+
});
|
|
447
|
+
if (msgResult.length > 0) {
|
|
448
|
+
pendingMessages = msgResult.map((m) => {
|
|
449
|
+
const ts = typeof m.timestamp === 'object' && m.timestamp?.toNumber ? m.timestamp.toNumber() : m.timestamp;
|
|
450
|
+
return {
|
|
451
|
+
id: m.id,
|
|
452
|
+
from: m.fromAgentId,
|
|
453
|
+
category: m.category,
|
|
454
|
+
content: m.content,
|
|
455
|
+
taskId: m.taskId ?? undefined,
|
|
456
|
+
filePaths: m.filePaths?.length > 0 ? m.filePaths : undefined,
|
|
457
|
+
age: ts ? `${Math.round((Date.now() - ts) / 1000)}s ago` : null,
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
// Auto-acknowledge delivered messages
|
|
461
|
+
const deliveredIds = pendingMessages.map((m) => m.id);
|
|
462
|
+
await neo4jService.run(AUTO_ACKNOWLEDGE_QUERY, {
|
|
463
|
+
messageIds: deliveredIds,
|
|
464
|
+
agentId,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch (msgError) {
|
|
469
|
+
// Non-fatal: message delivery failure shouldn't block task claim
|
|
470
|
+
await debugLog('Swarm claim task: message delivery failed (non-fatal)', { error: String(msgError) });
|
|
471
|
+
}
|
|
438
472
|
// Slim response - only essential fields for agent to do work
|
|
439
473
|
return createSuccessResponse(JSON.stringify({
|
|
440
474
|
action: actionLabel,
|
|
@@ -450,6 +484,7 @@ export const createSwarmClaimTaskTool = (server) => {
|
|
|
450
484
|
targetFilePaths: task.targetFilePaths,
|
|
451
485
|
...(task.dependencies?.length > 0 && { dependencies: task.dependencies }),
|
|
452
486
|
},
|
|
487
|
+
...(pendingMessages.length > 0 && { messages: pendingMessages }),
|
|
453
488
|
...(retryCount > 0 && { retryAttempts: retryCount }),
|
|
454
489
|
}));
|
|
455
490
|
}
|
|
@@ -75,6 +75,22 @@ const COUNT_ALL_QUERY = `
|
|
|
75
75
|
WHERE p.projectId = $projectId AND NOT p.type IN $keepTypes
|
|
76
76
|
RETURN count(p) as count, collect(DISTINCT p.agentId) as agents, collect(DISTINCT p.swarmId) as swarms, collect(DISTINCT p.type) as types
|
|
77
77
|
`;
|
|
78
|
+
/**
|
|
79
|
+
* Neo4j query to delete SwarmMessage nodes by swarm ID
|
|
80
|
+
*/
|
|
81
|
+
const CLEANUP_MESSAGES_BY_SWARM_QUERY = `
|
|
82
|
+
MATCH (m:SwarmMessage)
|
|
83
|
+
WHERE m.projectId = $projectId
|
|
84
|
+
AND m.swarmId = $swarmId
|
|
85
|
+
WITH m, m.category as category
|
|
86
|
+
DELETE m
|
|
87
|
+
RETURN count(m) as deleted, collect(DISTINCT category) as categories
|
|
88
|
+
`;
|
|
89
|
+
const COUNT_MESSAGES_BY_SWARM_QUERY = `
|
|
90
|
+
MATCH (m:SwarmMessage)
|
|
91
|
+
WHERE m.projectId = $projectId AND m.swarmId = $swarmId
|
|
92
|
+
RETURN count(m) as count, collect(DISTINCT m.category) as categories
|
|
93
|
+
`;
|
|
78
94
|
export const createSwarmCleanupTool = (server) => {
|
|
79
95
|
server.registerTool(TOOL_NAMES.swarmCleanup, {
|
|
80
96
|
title: TOOL_METADATA[TOOL_NAMES.swarmCleanup].title,
|
|
@@ -143,6 +159,15 @@ export const createSwarmCleanupTool = (server) => {
|
|
|
143
159
|
typeof taskCount === 'object' && 'toNumber' in taskCount ? taskCount.toNumber() : taskCount;
|
|
144
160
|
taskStatuses = taskResult[0]?.statuses ?? [];
|
|
145
161
|
}
|
|
162
|
+
let messageCount = 0;
|
|
163
|
+
let messageCategories = [];
|
|
164
|
+
if (swarmId) {
|
|
165
|
+
const msgResult = await neo4jService.run(COUNT_MESSAGES_BY_SWARM_QUERY, params);
|
|
166
|
+
messageCount = msgResult[0]?.count ?? 0;
|
|
167
|
+
messageCount =
|
|
168
|
+
typeof messageCount === 'object' && 'toNumber' in messageCount ? messageCount.toNumber() : messageCount;
|
|
169
|
+
messageCategories = msgResult[0]?.categories ?? [];
|
|
170
|
+
}
|
|
146
171
|
return createSuccessResponse(JSON.stringify({
|
|
147
172
|
success: true,
|
|
148
173
|
dryRun: true,
|
|
@@ -160,6 +185,12 @@ export const createSwarmCleanupTool = (server) => {
|
|
|
160
185
|
statuses: taskStatuses,
|
|
161
186
|
}
|
|
162
187
|
: null,
|
|
188
|
+
messages: swarmId
|
|
189
|
+
? {
|
|
190
|
+
wouldDelete: messageCount,
|
|
191
|
+
categories: messageCategories,
|
|
192
|
+
}
|
|
193
|
+
: null,
|
|
163
194
|
keepTypes,
|
|
164
195
|
projectId: resolvedProjectId,
|
|
165
196
|
}));
|
|
@@ -179,6 +210,23 @@ export const createSwarmCleanupTool = (server) => {
|
|
|
179
210
|
: tasksDeleted;
|
|
180
211
|
taskStatuses = taskResult[0]?.statuses ?? [];
|
|
181
212
|
}
|
|
213
|
+
// Delete messages if swarmId provided
|
|
214
|
+
let messagesDeleted = 0;
|
|
215
|
+
let messageCategories = [];
|
|
216
|
+
if (swarmId) {
|
|
217
|
+
const msgResult = await neo4jService.run(CLEANUP_MESSAGES_BY_SWARM_QUERY, params);
|
|
218
|
+
messagesDeleted = msgResult[0]?.deleted ?? 0;
|
|
219
|
+
messagesDeleted =
|
|
220
|
+
typeof messagesDeleted === 'object' && 'toNumber' in messagesDeleted
|
|
221
|
+
? messagesDeleted.toNumber()
|
|
222
|
+
: messagesDeleted;
|
|
223
|
+
messageCategories = msgResult[0]?.categories ?? [];
|
|
224
|
+
}
|
|
225
|
+
const parts = [`${pheromonesDeleted} pheromones`];
|
|
226
|
+
if (swarmId && includeTasks)
|
|
227
|
+
parts.push(`${tasksDeleted} tasks`);
|
|
228
|
+
if (swarmId)
|
|
229
|
+
parts.push(`${messagesDeleted} messages`);
|
|
182
230
|
return createSuccessResponse(JSON.stringify({
|
|
183
231
|
success: true,
|
|
184
232
|
mode,
|
|
@@ -195,11 +243,15 @@ export const createSwarmCleanupTool = (server) => {
|
|
|
195
243
|
statuses: taskStatuses,
|
|
196
244
|
}
|
|
197
245
|
: null,
|
|
246
|
+
messages: swarmId
|
|
247
|
+
? {
|
|
248
|
+
deleted: messagesDeleted,
|
|
249
|
+
categories: messageCategories,
|
|
250
|
+
}
|
|
251
|
+
: null,
|
|
198
252
|
keepTypes,
|
|
199
253
|
projectId: resolvedProjectId,
|
|
200
|
-
message:
|
|
201
|
-
? `Cleaned up ${pheromonesDeleted} pheromones and ${tasksDeleted} tasks`
|
|
202
|
-
: `Cleaned up ${pheromonesDeleted} pheromones`,
|
|
254
|
+
message: `Cleaned up ${parts.join(', ')}`,
|
|
203
255
|
}));
|
|
204
256
|
}
|
|
205
257
|
catch (error) {
|
|
@@ -79,6 +79,34 @@ export const WORKFLOW_STATES = ['exploring', 'claiming', 'modifying', 'completed
|
|
|
79
79
|
*/
|
|
80
80
|
export const FLAG_TYPES = ['warning', 'proposal', 'needs_review', 'session_context'];
|
|
81
81
|
// ============================================================================
|
|
82
|
+
// SWARM MESSAGING CONSTANTS
|
|
83
|
+
// ============================================================================
|
|
84
|
+
/**
|
|
85
|
+
* Message categories for direct agent-to-agent communication.
|
|
86
|
+
* Unlike pheromones (passive, decay-based), messages are explicit and persistent until read.
|
|
87
|
+
*/
|
|
88
|
+
export const MESSAGE_CATEGORIES = {
|
|
89
|
+
blocked: 'Agent is blocked and needs help',
|
|
90
|
+
conflict: 'File or resource conflict detected',
|
|
91
|
+
finding: 'Important discovery another agent should know',
|
|
92
|
+
request: 'Direct request to another agent',
|
|
93
|
+
alert: 'Urgent notification (e.g., breaking change, test failure)',
|
|
94
|
+
handoff: 'Context handoff between agents (e.g., partial work)',
|
|
95
|
+
};
|
|
96
|
+
export const MESSAGE_CATEGORY_KEYS = Object.keys(MESSAGE_CATEGORIES);
|
|
97
|
+
/**
|
|
98
|
+
* Default TTL for messages (4 hours). Messages older than this are auto-cleaned.
|
|
99
|
+
*/
|
|
100
|
+
export const MESSAGE_DEFAULT_TTL_MS = 4 * 60 * 60 * 1000;
|
|
101
|
+
/**
|
|
102
|
+
* Generate a unique message ID
|
|
103
|
+
*/
|
|
104
|
+
export const generateMessageId = () => {
|
|
105
|
+
const timestamp = Date.now().toString(36);
|
|
106
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
107
|
+
return `msg_${timestamp}_${random}`;
|
|
108
|
+
};
|
|
109
|
+
// ============================================================================
|
|
82
110
|
// ORCHESTRATOR CONSTANTS
|
|
83
111
|
// ============================================================================
|
|
84
112
|
/**
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm Message Tool
|
|
3
|
+
* Direct agent-to-agent messaging via Neo4j for reliable coordination.
|
|
4
|
+
*
|
|
5
|
+
* Unlike pheromones (passive, decay-based stigmergy), messages are explicit
|
|
6
|
+
* and delivered to agents when they claim tasks. This ensures critical
|
|
7
|
+
* coordination signals (blocked, conflict, findings) are reliably received.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { Neo4jService } from '../../storage/neo4j/neo4j.service.js';
|
|
11
|
+
import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
|
|
12
|
+
import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
|
|
13
|
+
import { MESSAGE_CATEGORY_KEYS, MESSAGE_DEFAULT_TTL_MS, generateMessageId } from './swarm-constants.js';
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// NEO4J QUERIES
|
|
16
|
+
// ============================================================================
|
|
17
|
+
/**
|
|
18
|
+
* Send a message. Creates a SwarmMessage node with optional target agent.
|
|
19
|
+
* Broadcast messages (no toAgentId) are visible to all agents in the swarm.
|
|
20
|
+
*/
|
|
21
|
+
const SEND_MESSAGE_QUERY = `
|
|
22
|
+
CREATE (m:SwarmMessage {
|
|
23
|
+
id: $messageId,
|
|
24
|
+
projectId: $projectId,
|
|
25
|
+
swarmId: $swarmId,
|
|
26
|
+
fromAgentId: $fromAgentId,
|
|
27
|
+
toAgentId: $toAgentId,
|
|
28
|
+
category: $category,
|
|
29
|
+
content: $content,
|
|
30
|
+
taskId: $taskId,
|
|
31
|
+
filePaths: $filePaths,
|
|
32
|
+
timestamp: timestamp(),
|
|
33
|
+
expiresAt: timestamp() + $ttlMs,
|
|
34
|
+
readBy: []
|
|
35
|
+
})
|
|
36
|
+
RETURN m.id as id,
|
|
37
|
+
m.swarmId as swarmId,
|
|
38
|
+
m.fromAgentId as fromAgentId,
|
|
39
|
+
m.toAgentId as toAgentId,
|
|
40
|
+
m.category as category,
|
|
41
|
+
m.timestamp as timestamp,
|
|
42
|
+
m.expiresAt as expiresAt
|
|
43
|
+
`;
|
|
44
|
+
/**
|
|
45
|
+
* Read messages for an agent. Returns messages that are:
|
|
46
|
+
* 1. Addressed to this agent specifically, OR
|
|
47
|
+
* 2. Broadcast (toAgentId is null) to the same swarm
|
|
48
|
+
* AND not yet expired.
|
|
49
|
+
* Optionally filters to unread-only (not in readBy list).
|
|
50
|
+
*/
|
|
51
|
+
const READ_MESSAGES_QUERY = `
|
|
52
|
+
MATCH (m:SwarmMessage)
|
|
53
|
+
WHERE m.projectId = $projectId
|
|
54
|
+
AND m.swarmId = $swarmId
|
|
55
|
+
AND m.expiresAt > timestamp()
|
|
56
|
+
AND (m.toAgentId IS NULL OR m.toAgentId = $agentId)
|
|
57
|
+
AND ($unreadOnly = false OR NOT $agentId IN m.readBy)
|
|
58
|
+
AND ($categories IS NULL OR size($categories) = 0 OR m.category IN $categories)
|
|
59
|
+
AND ($fromAgentId IS NULL OR m.fromAgentId = $fromAgentId)
|
|
60
|
+
RETURN m.id as id,
|
|
61
|
+
m.swarmId as swarmId,
|
|
62
|
+
m.fromAgentId as fromAgentId,
|
|
63
|
+
m.toAgentId as toAgentId,
|
|
64
|
+
m.category as category,
|
|
65
|
+
m.content as content,
|
|
66
|
+
m.taskId as taskId,
|
|
67
|
+
m.filePaths as filePaths,
|
|
68
|
+
m.timestamp as timestamp,
|
|
69
|
+
m.expiresAt as expiresAt,
|
|
70
|
+
m.readBy as readBy,
|
|
71
|
+
NOT $agentId IN m.readBy as isUnread
|
|
72
|
+
ORDER BY m.timestamp DESC
|
|
73
|
+
LIMIT toInteger($limit)
|
|
74
|
+
`;
|
|
75
|
+
/**
|
|
76
|
+
* Acknowledge (mark as read) specific messages for an agent.
|
|
77
|
+
* Uses APOC to atomically add agentId to readBy array.
|
|
78
|
+
*/
|
|
79
|
+
const ACKNOWLEDGE_MESSAGES_QUERY = `
|
|
80
|
+
UNWIND $messageIds as msgId
|
|
81
|
+
MATCH (m:SwarmMessage {id: msgId, projectId: $projectId})
|
|
82
|
+
WHERE NOT $agentId IN m.readBy
|
|
83
|
+
SET m.readBy = m.readBy + $agentId
|
|
84
|
+
RETURN m.id as id, m.category as category
|
|
85
|
+
`;
|
|
86
|
+
/**
|
|
87
|
+
* Acknowledge ALL unread messages for an agent in a swarm.
|
|
88
|
+
*/
|
|
89
|
+
const ACKNOWLEDGE_ALL_QUERY = `
|
|
90
|
+
MATCH (m:SwarmMessage)
|
|
91
|
+
WHERE m.projectId = $projectId
|
|
92
|
+
AND m.swarmId = $swarmId
|
|
93
|
+
AND (m.toAgentId IS NULL OR m.toAgentId = $agentId)
|
|
94
|
+
AND NOT $agentId IN m.readBy
|
|
95
|
+
AND m.expiresAt > timestamp()
|
|
96
|
+
SET m.readBy = m.readBy + $agentId
|
|
97
|
+
RETURN count(m) as acknowledged
|
|
98
|
+
`;
|
|
99
|
+
/**
|
|
100
|
+
* Fetch pending messages for delivery during task claim.
|
|
101
|
+
* Returns unread messages addressed to or broadcast to the agent.
|
|
102
|
+
* Used internally by swarm_claim_task integration.
|
|
103
|
+
*/
|
|
104
|
+
export const PENDING_MESSAGES_FOR_AGENT_QUERY = `
|
|
105
|
+
MATCH (m:SwarmMessage)
|
|
106
|
+
WHERE m.projectId = $projectId
|
|
107
|
+
AND m.swarmId = $swarmId
|
|
108
|
+
AND m.expiresAt > timestamp()
|
|
109
|
+
AND (m.toAgentId IS NULL OR m.toAgentId = $agentId)
|
|
110
|
+
AND NOT $agentId IN m.readBy
|
|
111
|
+
RETURN m.id as id,
|
|
112
|
+
m.fromAgentId as fromAgentId,
|
|
113
|
+
m.category as category,
|
|
114
|
+
m.content as content,
|
|
115
|
+
m.taskId as taskId,
|
|
116
|
+
m.filePaths as filePaths,
|
|
117
|
+
m.timestamp as timestamp
|
|
118
|
+
ORDER BY
|
|
119
|
+
CASE m.category
|
|
120
|
+
WHEN 'alert' THEN 0
|
|
121
|
+
WHEN 'conflict' THEN 1
|
|
122
|
+
WHEN 'blocked' THEN 2
|
|
123
|
+
WHEN 'request' THEN 3
|
|
124
|
+
WHEN 'finding' THEN 4
|
|
125
|
+
WHEN 'handoff' THEN 5
|
|
126
|
+
ELSE 6
|
|
127
|
+
END,
|
|
128
|
+
m.timestamp DESC
|
|
129
|
+
LIMIT 10
|
|
130
|
+
`;
|
|
131
|
+
/**
|
|
132
|
+
* Auto-acknowledge messages that were delivered during claim.
|
|
133
|
+
*/
|
|
134
|
+
export const AUTO_ACKNOWLEDGE_QUERY = `
|
|
135
|
+
UNWIND $messageIds as msgId
|
|
136
|
+
MATCH (m:SwarmMessage {id: msgId})
|
|
137
|
+
WHERE NOT $agentId IN m.readBy
|
|
138
|
+
SET m.readBy = m.readBy + $agentId
|
|
139
|
+
RETURN count(m) as acknowledged
|
|
140
|
+
`;
|
|
141
|
+
/**
|
|
142
|
+
* Cleanup expired messages for a swarm.
|
|
143
|
+
*/
|
|
144
|
+
const CLEANUP_EXPIRED_QUERY = `
|
|
145
|
+
MATCH (m:SwarmMessage)
|
|
146
|
+
WHERE m.projectId = $projectId
|
|
147
|
+
AND ($swarmId IS NULL OR m.swarmId = $swarmId)
|
|
148
|
+
AND m.expiresAt < timestamp()
|
|
149
|
+
DELETE m
|
|
150
|
+
RETURN count(m) as cleaned
|
|
151
|
+
`;
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// TOOL CREATION
|
|
154
|
+
// ============================================================================
|
|
155
|
+
export const createSwarmMessageTool = (server) => {
|
|
156
|
+
server.registerTool(TOOL_NAMES.swarmMessage, {
|
|
157
|
+
title: TOOL_METADATA[TOOL_NAMES.swarmMessage].title,
|
|
158
|
+
description: TOOL_METADATA[TOOL_NAMES.swarmMessage].description,
|
|
159
|
+
inputSchema: {
|
|
160
|
+
projectId: z.string().describe('Project ID, name, or path'),
|
|
161
|
+
swarmId: z.string().describe('Swarm ID for scoping messages'),
|
|
162
|
+
agentId: z.string().describe('Your unique agent identifier'),
|
|
163
|
+
action: z
|
|
164
|
+
.enum(['send', 'read', 'acknowledge'])
|
|
165
|
+
.describe('Action: send (post message), read (get messages), acknowledge (mark as read)'),
|
|
166
|
+
// Send parameters
|
|
167
|
+
toAgentId: z
|
|
168
|
+
.string()
|
|
169
|
+
.optional()
|
|
170
|
+
.describe('Target agent ID. Omit for broadcast to all swarm agents.'),
|
|
171
|
+
category: z
|
|
172
|
+
.enum(MESSAGE_CATEGORY_KEYS)
|
|
173
|
+
.optional()
|
|
174
|
+
.describe('Message category: blocked (need help), conflict (resource clash), finding (important discovery), ' +
|
|
175
|
+
'request (direct ask), alert (urgent notification), handoff (context transfer)'),
|
|
176
|
+
content: z
|
|
177
|
+
.string()
|
|
178
|
+
.optional()
|
|
179
|
+
.describe('Message content (required for send action)'),
|
|
180
|
+
taskId: z
|
|
181
|
+
.string()
|
|
182
|
+
.optional()
|
|
183
|
+
.describe('Related task ID for context'),
|
|
184
|
+
filePaths: z
|
|
185
|
+
.array(z.string())
|
|
186
|
+
.optional()
|
|
187
|
+
.describe('File paths relevant to this message'),
|
|
188
|
+
ttlMs: z
|
|
189
|
+
.number()
|
|
190
|
+
.int()
|
|
191
|
+
.optional()
|
|
192
|
+
.describe(`Time-to-live in ms (default: ${MESSAGE_DEFAULT_TTL_MS / 3600000}h). Set 0 for swarm lifetime.`),
|
|
193
|
+
// Read parameters
|
|
194
|
+
unreadOnly: z
|
|
195
|
+
.boolean()
|
|
196
|
+
.optional()
|
|
197
|
+
.default(true)
|
|
198
|
+
.describe('Only return unread messages (default: true)'),
|
|
199
|
+
categories: z
|
|
200
|
+
.array(z.enum(MESSAGE_CATEGORY_KEYS))
|
|
201
|
+
.optional()
|
|
202
|
+
.describe('Filter by message categories'),
|
|
203
|
+
fromAgentId: z
|
|
204
|
+
.string()
|
|
205
|
+
.optional()
|
|
206
|
+
.describe('Filter messages from a specific agent'),
|
|
207
|
+
limit: z
|
|
208
|
+
.number()
|
|
209
|
+
.int()
|
|
210
|
+
.min(1)
|
|
211
|
+
.max(100)
|
|
212
|
+
.optional()
|
|
213
|
+
.default(20)
|
|
214
|
+
.describe('Maximum messages to return (default: 20)'),
|
|
215
|
+
// Acknowledge parameters
|
|
216
|
+
messageIds: z
|
|
217
|
+
.array(z.string())
|
|
218
|
+
.optional()
|
|
219
|
+
.describe('Specific message IDs to acknowledge. Omit to acknowledge all unread.'),
|
|
220
|
+
// Maintenance
|
|
221
|
+
cleanup: z
|
|
222
|
+
.boolean()
|
|
223
|
+
.optional()
|
|
224
|
+
.default(false)
|
|
225
|
+
.describe('Also clean up expired messages'),
|
|
226
|
+
},
|
|
227
|
+
}, async ({ projectId, swarmId, agentId, action, toAgentId, category, content, taskId, filePaths, ttlMs, unreadOnly = true, categories, fromAgentId, limit = 20, messageIds, cleanup = false, }) => {
|
|
228
|
+
const neo4jService = new Neo4jService();
|
|
229
|
+
const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
|
|
230
|
+
if (!projectResult.success) {
|
|
231
|
+
await neo4jService.close();
|
|
232
|
+
return projectResult.error;
|
|
233
|
+
}
|
|
234
|
+
const resolvedProjectId = projectResult.projectId;
|
|
235
|
+
try {
|
|
236
|
+
// Optional cleanup of expired messages
|
|
237
|
+
let cleanedCount = 0;
|
|
238
|
+
if (cleanup) {
|
|
239
|
+
const cleanResult = await neo4jService.run(CLEANUP_EXPIRED_QUERY, {
|
|
240
|
+
projectId: resolvedProjectId,
|
|
241
|
+
swarmId,
|
|
242
|
+
});
|
|
243
|
+
cleanedCount = cleanResult[0]?.cleaned ?? 0;
|
|
244
|
+
}
|
|
245
|
+
// ── SEND ──────────────────────────────────────────────────────
|
|
246
|
+
if (action === 'send') {
|
|
247
|
+
if (!category) {
|
|
248
|
+
return createErrorResponse('category is required for send action');
|
|
249
|
+
}
|
|
250
|
+
if (!content) {
|
|
251
|
+
return createErrorResponse('content is required for send action');
|
|
252
|
+
}
|
|
253
|
+
const messageId = generateMessageId();
|
|
254
|
+
const effectiveTtl = ttlMs ?? MESSAGE_DEFAULT_TTL_MS;
|
|
255
|
+
const result = await neo4jService.run(SEND_MESSAGE_QUERY, {
|
|
256
|
+
messageId,
|
|
257
|
+
projectId: resolvedProjectId,
|
|
258
|
+
swarmId,
|
|
259
|
+
fromAgentId: agentId,
|
|
260
|
+
toAgentId: toAgentId ?? null,
|
|
261
|
+
category,
|
|
262
|
+
content,
|
|
263
|
+
taskId: taskId ?? null,
|
|
264
|
+
filePaths: filePaths ?? [],
|
|
265
|
+
ttlMs: effectiveTtl,
|
|
266
|
+
});
|
|
267
|
+
if (result.length === 0) {
|
|
268
|
+
return createErrorResponse('Failed to create message');
|
|
269
|
+
}
|
|
270
|
+
const msg = result[0];
|
|
271
|
+
const ts = typeof msg.timestamp === 'object' && msg.timestamp?.toNumber ? msg.timestamp.toNumber() : msg.timestamp;
|
|
272
|
+
return createSuccessResponse(JSON.stringify({
|
|
273
|
+
action: 'sent',
|
|
274
|
+
message: {
|
|
275
|
+
id: messageId,
|
|
276
|
+
swarmId: msg.swarmId,
|
|
277
|
+
from: msg.fromAgentId,
|
|
278
|
+
to: msg.toAgentId ?? 'broadcast',
|
|
279
|
+
category: msg.category,
|
|
280
|
+
expiresIn: effectiveTtl > 0 ? `${Math.round(effectiveTtl / 60000)} minutes` : 'never',
|
|
281
|
+
},
|
|
282
|
+
...(cleanedCount > 0 && { expiredCleaned: cleanedCount }),
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
// ── READ ──────────────────────────────────────────────────────
|
|
286
|
+
if (action === 'read') {
|
|
287
|
+
const result = await neo4jService.run(READ_MESSAGES_QUERY, {
|
|
288
|
+
projectId: resolvedProjectId,
|
|
289
|
+
swarmId,
|
|
290
|
+
agentId,
|
|
291
|
+
unreadOnly,
|
|
292
|
+
categories: categories ?? null,
|
|
293
|
+
fromAgentId: fromAgentId ?? null,
|
|
294
|
+
limit: Math.floor(limit),
|
|
295
|
+
});
|
|
296
|
+
const messages = result.map((m) => {
|
|
297
|
+
const ts = typeof m.timestamp === 'object' && m.timestamp?.toNumber ? m.timestamp.toNumber() : m.timestamp;
|
|
298
|
+
return {
|
|
299
|
+
id: m.id,
|
|
300
|
+
from: m.fromAgentId,
|
|
301
|
+
to: m.toAgentId ?? 'broadcast',
|
|
302
|
+
category: m.category,
|
|
303
|
+
content: m.content,
|
|
304
|
+
taskId: m.taskId ?? undefined,
|
|
305
|
+
filePaths: m.filePaths?.length > 0 ? m.filePaths : undefined,
|
|
306
|
+
isUnread: m.isUnread,
|
|
307
|
+
age: ts ? `${Math.round((Date.now() - ts) / 1000)}s ago` : null,
|
|
308
|
+
};
|
|
309
|
+
});
|
|
310
|
+
return createSuccessResponse(JSON.stringify({
|
|
311
|
+
action: 'read',
|
|
312
|
+
swarmId,
|
|
313
|
+
forAgent: agentId,
|
|
314
|
+
count: messages.length,
|
|
315
|
+
messages,
|
|
316
|
+
...(cleanedCount > 0 && { expiredCleaned: cleanedCount }),
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
// ── ACKNOWLEDGE ───────────────────────────────────────────────
|
|
320
|
+
if (action === 'acknowledge') {
|
|
321
|
+
if (messageIds && messageIds.length > 0) {
|
|
322
|
+
// Acknowledge specific messages
|
|
323
|
+
const result = await neo4jService.run(ACKNOWLEDGE_MESSAGES_QUERY, {
|
|
324
|
+
messageIds,
|
|
325
|
+
projectId: resolvedProjectId,
|
|
326
|
+
agentId,
|
|
327
|
+
});
|
|
328
|
+
return createSuccessResponse(JSON.stringify({
|
|
329
|
+
action: 'acknowledged',
|
|
330
|
+
count: result.length,
|
|
331
|
+
messageIds: result.map((r) => r.id),
|
|
332
|
+
...(cleanedCount > 0 && { expiredCleaned: cleanedCount }),
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
// Acknowledge all unread
|
|
337
|
+
const result = await neo4jService.run(ACKNOWLEDGE_ALL_QUERY, {
|
|
338
|
+
projectId: resolvedProjectId,
|
|
339
|
+
swarmId,
|
|
340
|
+
agentId,
|
|
341
|
+
});
|
|
342
|
+
const count = typeof result[0]?.acknowledged === 'object'
|
|
343
|
+
? result[0].acknowledged.toNumber()
|
|
344
|
+
: result[0]?.acknowledged ?? 0;
|
|
345
|
+
return createSuccessResponse(JSON.stringify({
|
|
346
|
+
action: 'acknowledged_all',
|
|
347
|
+
count,
|
|
348
|
+
...(cleanedCount > 0 && { expiredCleaned: cleanedCount }),
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return createErrorResponse(`Unknown action: ${action}`);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
await debugLog('Swarm message error', { error: String(error) });
|
|
356
|
+
return createErrorResponse(error instanceof Error ? error : String(error));
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
await neo4jService.close();
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
};
|
package/package.json
CHANGED