@sleep2agi/commhub-server 0.8.4 → 0.8.5-preview.1
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/README.md +6 -1
- package/package.json +1 -1
- package/src/index.ts +80 -5
- package/src/send_dedup.test.ts +160 -0
- package/src/send_dedup.ts +192 -0
- package/src/tools.ts +174 -8
package/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# @sleep2agi/commhub-server
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@sleep2agi/commhub-server)
|
|
4
|
+
[](https://www.npmjs.com/package/@sleep2agi/commhub-server)
|
|
5
|
+
[](https://github.com/sleep2agi/agent-network/blob/main/LICENSE)
|
|
6
|
+
[](https://anet.sh)
|
|
7
|
+
|
|
3
8
|
CommHub: MCP Streamable HTTP + SSE push + REST API for an AI agent network. Single-process Bun server, SQLite-backed, zero config when launched through `anet`.
|
|
4
9
|
|
|
5
|
-
The supported path is to install the `anet` CLI (`@sleep2agi/agent-network`, currently v2.2.
|
|
10
|
+
The supported path is to install the `anet` CLI (`@sleep2agi/agent-network`, currently v2.2.10 at v0.10.11) and run `anet hub start`, which wires up the port, default admin account, recovery admin `utok_`, and local config for you.
|
|
6
11
|
|
|
7
12
|
## Quick start (verified)
|
|
8
13
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5-preview.1",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { db, logTaskEvent, logAudit } from "./db.js";
|
|
|
6
6
|
import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
|
|
7
7
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
8
8
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
9
|
+
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
9
10
|
|
|
10
11
|
const PORT = Number(process.env.PORT) || 9200;
|
|
11
12
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -340,6 +341,36 @@ function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null
|
|
|
340
341
|
return !!role && role !== "viewer";
|
|
341
342
|
}
|
|
342
343
|
|
|
344
|
+
type RestDeliveryTarget =
|
|
345
|
+
| { state: "online"; alias: string; session: any }
|
|
346
|
+
| { state: "offline"; alias: string; session: any; message: string }
|
|
347
|
+
| { state: "not_found"; alias: string; message: string };
|
|
348
|
+
|
|
349
|
+
function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
|
|
350
|
+
const params: any[] = [alias];
|
|
351
|
+
let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
|
|
352
|
+
if (networkId) {
|
|
353
|
+
sql += " AND network_id = ?2";
|
|
354
|
+
params.push(networkId);
|
|
355
|
+
}
|
|
356
|
+
const session = db.get<any>(sql, ...params);
|
|
357
|
+
if (!session) {
|
|
358
|
+
return { state: "not_found", alias, message: `alias not found: ${alias}` };
|
|
359
|
+
}
|
|
360
|
+
const lastSeen = session.last_seen_at || session.updated_at;
|
|
361
|
+
const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
|
|
362
|
+
const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
|
|
363
|
+
if (String(session.status || "").toLowerCase() === "offline" || stale) {
|
|
364
|
+
return {
|
|
365
|
+
state: "offline",
|
|
366
|
+
alias,
|
|
367
|
+
session,
|
|
368
|
+
message: `alias is offline; task queued in inbox: ${alias}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return { state: "online", alias, session };
|
|
372
|
+
}
|
|
373
|
+
|
|
343
374
|
// ── REST input schema ───────────────────────────────
|
|
344
375
|
const TaskSchema = z.object({
|
|
345
376
|
alias: z.string().min(1).max(200),
|
|
@@ -1198,10 +1229,39 @@ Bun.serve({
|
|
|
1198
1229
|
}
|
|
1199
1230
|
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
1200
1231
|
const targetAlias = canonical.alias;
|
|
1232
|
+
const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
|
|
1233
|
+
if (target.state === "not_found") {
|
|
1234
|
+
return withCors(req, Response.json({
|
|
1235
|
+
ok: false,
|
|
1236
|
+
error: "alias_not_found",
|
|
1237
|
+
message: target.message,
|
|
1238
|
+
alias: targetAlias,
|
|
1239
|
+
queued: false,
|
|
1240
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
1241
|
+
}, { status: 404 }));
|
|
1242
|
+
}
|
|
1201
1243
|
const id = crypto.randomUUID();
|
|
1202
1244
|
const fromSession = body.from || "api";
|
|
1203
1245
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
1204
1246
|
const metaJson = normalizeMetaJson((body as any).meta);
|
|
1247
|
+
|
|
1248
|
+
// #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
|
|
1249
|
+
// (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
|
|
1250
|
+
// is rejected with a structured `duplicate_send` error so dashboard
|
|
1251
|
+
// dispatch buttons and scripted REST callers get the same guarantee
|
|
1252
|
+
// as agent-driven MCP traffic.
|
|
1253
|
+
const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task);
|
|
1254
|
+
if (dedup.duplicate) {
|
|
1255
|
+
const payload = buildDuplicateSendPayload({
|
|
1256
|
+
from: fromSession,
|
|
1257
|
+
to: targetAlias,
|
|
1258
|
+
ageMs: dedup.ageMs,
|
|
1259
|
+
windowMs: sharedSendDedup.windowMs,
|
|
1260
|
+
});
|
|
1261
|
+
console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
1262
|
+
return withCors(req, Response.json(payload, { status: 429 }));
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1205
1265
|
// Mirror send_task MCP: write inbox + tasks rows in a single
|
|
1206
1266
|
// transaction so the dispatch is visible to dashboard's Tasks page
|
|
1207
1267
|
// and the parent_task_id lineage chain. Previously this endpoint
|
|
@@ -1232,11 +1292,26 @@ Bun.serve({
|
|
|
1232
1292
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
1233
1293
|
if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
|
|
1234
1294
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1295
|
+
if (target.state === "online") {
|
|
1296
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
|
|
1297
|
+
}
|
|
1298
|
+
// #212 — stamp the dedup index only after the inbox/tasks insert
|
|
1299
|
+
// succeeds. Mirrors the MCP `send_task` path so a failed write
|
|
1300
|
+
// never shadows a legitimate retry.
|
|
1301
|
+
sharedSendDedup.record(fromSession, targetAlias, body.task);
|
|
1302
|
+
if (target.state === "offline") {
|
|
1303
|
+
return withCors(req, Response.json({
|
|
1304
|
+
ok: false,
|
|
1305
|
+
error: "alias_offline",
|
|
1306
|
+
message: target.message,
|
|
1307
|
+
alias: targetAlias,
|
|
1308
|
+
queued: true,
|
|
1309
|
+
task_id: id,
|
|
1310
|
+
message_id: id,
|
|
1311
|
+
session_status: target.session.status ?? "offline",
|
|
1312
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
1313
|
+
}, { status: 202 }));
|
|
1314
|
+
}
|
|
1240
1315
|
return withCors(req, Response.json({ ok: true, task_id: id, message_id: id, ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}) }));
|
|
1241
1316
|
}
|
|
1242
1317
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Unit test for the #212 send_task dedup guardrail.
|
|
2
|
+
//
|
|
3
|
+
// Pure-function module — no DB, no MCP server, just verifies the dedup
|
|
4
|
+
// algebra: same key in window is rejected; different key (cross-from,
|
|
5
|
+
// cross-to, cross-content) is allowed; expired entry restored; disabled
|
|
6
|
+
// window short-circuits everything; structured payload contains both the
|
|
7
|
+
// Chinese hint the LLM is supposed to act on and machine-parseable
|
|
8
|
+
// details.
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
SendDedup,
|
|
12
|
+
buildDuplicateSendPayload,
|
|
13
|
+
readDedupConfig,
|
|
14
|
+
} from "./send_dedup.js";
|
|
15
|
+
|
|
16
|
+
describe("readDedupConfig", () => {
|
|
17
|
+
it("defaults to 300000 ms window and 4096 max keys", () => {
|
|
18
|
+
const cfg = readDedupConfig({});
|
|
19
|
+
expect(cfg.windowMs).toBe(300_000);
|
|
20
|
+
expect(cfg.maxKeys).toBe(4096);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("respects COMMHUB_SEND_DEDUP_WINDOW_MS=0 to disable", () => {
|
|
24
|
+
const cfg = readDedupConfig({ COMMHUB_SEND_DEDUP_WINDOW_MS: "0" });
|
|
25
|
+
expect(cfg.windowMs).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("parses custom window and max keys from env", () => {
|
|
29
|
+
const cfg = readDedupConfig({
|
|
30
|
+
COMMHUB_SEND_DEDUP_WINDOW_MS: "60000",
|
|
31
|
+
COMMHUB_SEND_DEDUP_MAX_KEYS: "256",
|
|
32
|
+
});
|
|
33
|
+
expect(cfg.windowMs).toBe(60_000);
|
|
34
|
+
expect(cfg.maxKeys).toBe(256);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("clamps negative window to 0 and small max keys to 64", () => {
|
|
38
|
+
const cfg = readDedupConfig({
|
|
39
|
+
COMMHUB_SEND_DEDUP_WINDOW_MS: "-1",
|
|
40
|
+
COMMHUB_SEND_DEDUP_MAX_KEYS: "1",
|
|
41
|
+
});
|
|
42
|
+
expect(cfg.windowMs).toBe(0);
|
|
43
|
+
expect(cfg.maxKeys).toBe(64);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("SendDedup", () => {
|
|
48
|
+
it("flags a repeat send within the window", () => {
|
|
49
|
+
const d = new SendDedup({ windowMs: 60_000, maxKeys: 32 });
|
|
50
|
+
const t0 = 1_700_000_000_000;
|
|
51
|
+
|
|
52
|
+
expect(d.check("alice", "bob", "hello", t0)).toEqual({ duplicate: false });
|
|
53
|
+
d.record("alice", "bob", "hello", t0);
|
|
54
|
+
|
|
55
|
+
const second = d.check("alice", "bob", "hello", t0 + 1000);
|
|
56
|
+
expect(second.duplicate).toBe(true);
|
|
57
|
+
if (second.duplicate) {
|
|
58
|
+
expect(second.lastSentMs).toBe(t0);
|
|
59
|
+
expect(second.ageMs).toBe(1000);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("allows the same content after the window elapses", () => {
|
|
64
|
+
const d = new SendDedup({ windowMs: 60_000, maxKeys: 32 });
|
|
65
|
+
const t0 = 1_700_000_000_000;
|
|
66
|
+
d.record("alice", "bob", "hello", t0);
|
|
67
|
+
|
|
68
|
+
const stillIn = d.check("alice", "bob", "hello", t0 + 59_000);
|
|
69
|
+
expect(stillIn.duplicate).toBe(true);
|
|
70
|
+
|
|
71
|
+
const past = d.check("alice", "bob", "hello", t0 + 60_001);
|
|
72
|
+
expect(past).toEqual({ duplicate: false });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not conflate different senders, targets, or contents", () => {
|
|
76
|
+
const d = new SendDedup({ windowMs: 60_000, maxKeys: 32 });
|
|
77
|
+
const t0 = 1_700_000_000_000;
|
|
78
|
+
d.record("alice", "bob", "hello", t0);
|
|
79
|
+
|
|
80
|
+
// Different sender → allowed.
|
|
81
|
+
expect(d.check("carol", "bob", "hello", t0 + 1000)).toEqual({ duplicate: false });
|
|
82
|
+
// Different target → allowed.
|
|
83
|
+
expect(d.check("alice", "dave", "hello", t0 + 1000)).toEqual({ duplicate: false });
|
|
84
|
+
// Different content → allowed (even with trailing whitespace tweak).
|
|
85
|
+
expect(d.check("alice", "bob", "hello ", t0 + 1000)).toEqual({ duplicate: false });
|
|
86
|
+
// Repeated original → still flagged.
|
|
87
|
+
expect(d.check("alice", "bob", "hello", t0 + 1000).duplicate).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("treats windowMs=0 as fully disabled", () => {
|
|
91
|
+
const d = new SendDedup({ windowMs: 0, maxKeys: 32 });
|
|
92
|
+
expect(d.enabled).toBe(false);
|
|
93
|
+
d.record("alice", "bob", "hello", 1);
|
|
94
|
+
expect(d.check("alice", "bob", "hello", 2)).toEqual({ duplicate: false });
|
|
95
|
+
expect(d.size).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("opportunistically evicts expired entries during check", () => {
|
|
99
|
+
const d = new SendDedup({ windowMs: 10_000, maxKeys: 32 });
|
|
100
|
+
d.record("alice", "bob", "hello", 1000);
|
|
101
|
+
d.record("alice", "bob", "world", 1500);
|
|
102
|
+
expect(d.size).toBe(2);
|
|
103
|
+
|
|
104
|
+
// Far past both entries' window — check should evict both.
|
|
105
|
+
expect(d.check("alice", "bob", "third", 100_000)).toEqual({ duplicate: false });
|
|
106
|
+
expect(d.size).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("caps map size by oldest-first eviction when exceeding maxKeys", () => {
|
|
110
|
+
const d = new SendDedup({ windowMs: 600_000, maxKeys: 64 });
|
|
111
|
+
for (let i = 0; i < 100; i++) {
|
|
112
|
+
d.record(`from-${i}`, "bob", `content-${i}`, 1_000 + i);
|
|
113
|
+
}
|
|
114
|
+
// Eviction triggers when size > maxKeys; after eviction we keep ~90 %
|
|
115
|
+
// of maxKeys, so size should land below maxKeys.
|
|
116
|
+
expect(d.size).toBeLessThanOrEqual(64);
|
|
117
|
+
expect(d.size).toBeGreaterThan(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("hashes content rather than holding the raw bytes", () => {
|
|
121
|
+
// The key function is the public surface that proves we don't keep
|
|
122
|
+
// raw content in memory between sends — the map keys themselves are
|
|
123
|
+
// safe to log/inspect.
|
|
124
|
+
const key = SendDedup.key("alice", "bob", "secret payload");
|
|
125
|
+
expect(key.startsWith("alice|bob|")).toBe(true);
|
|
126
|
+
expect(key).toMatch(/\|[0-9a-f]{64}$/);
|
|
127
|
+
expect(key).not.toContain("secret");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("buildDuplicateSendPayload", () => {
|
|
132
|
+
it("contains the Chinese LLM-facing hint with target alias and window minutes", () => {
|
|
133
|
+
const payload = buildDuplicateSendPayload({
|
|
134
|
+
from: "A站Grok",
|
|
135
|
+
to: "A站负责人",
|
|
136
|
+
ageMs: 12_345,
|
|
137
|
+
windowMs: 300_000,
|
|
138
|
+
});
|
|
139
|
+
expect(payload.ok).toBe(false);
|
|
140
|
+
expect(payload.error).toBe("duplicate_send");
|
|
141
|
+
expect(payload.message).toContain("5 分钟内已发给 A站负责人");
|
|
142
|
+
expect(payload.message).toContain("改写内容或等待");
|
|
143
|
+
expect(payload.details.age_ms).toBe(12_345);
|
|
144
|
+
expect(payload.details.window_ms).toBe(300_000);
|
|
145
|
+
expect(payload.details.from).toBe("A站Grok");
|
|
146
|
+
expect(payload.details.target).toBe("A站负责人");
|
|
147
|
+
expect(payload.details.hint_en).toContain("change the content or wait");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("rounds the window display to whole minutes", () => {
|
|
151
|
+
const payload = buildDuplicateSendPayload({
|
|
152
|
+
from: "from-x",
|
|
153
|
+
to: "to-y",
|
|
154
|
+
ageMs: 0,
|
|
155
|
+
windowMs: 90_000,
|
|
156
|
+
});
|
|
157
|
+
// 90 s rounds to 2 min for the human-readable hint.
|
|
158
|
+
expect(payload.message).toContain("2 分钟内");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// #212 — commhub-server send_task dedup guardrail.
|
|
2
|
+
//
|
|
3
|
+
// Vincent's A站Grok ran away on 2026-06-10 and sent the same task to the
|
|
4
|
+
// same target 50+ times within five LLM turns (4692 chunks in a single
|
|
5
|
+
// turn). It ignored three STOP replies — by the time a reply landed back
|
|
6
|
+
// in its inbox the LLM had already decided to dispatch again. The dispatch
|
|
7
|
+
// path was the agent's own commhub_send_task MCP tool call against
|
|
8
|
+
// commhub-server's HTTP MCP transport (#204 preview.6 wiring), so the
|
|
9
|
+
// agent-node runtime never saw the bytes and could not intervene
|
|
10
|
+
// client-side.
|
|
11
|
+
//
|
|
12
|
+
// This module is the server-side guardrail: any `send_task` (whether
|
|
13
|
+
// through MCP `tools/call` or the REST `/api/task` endpoint) is checked
|
|
14
|
+
// against an in-memory dedup index keyed by `(from_session, target_alias,
|
|
15
|
+
// sha256(content))`. A second call to the same key within the configured
|
|
16
|
+
// window is rejected with a structured `duplicate_send` error containing
|
|
17
|
+
// a human-readable hint the LLM can act on ("change the task content or
|
|
18
|
+
// wait").
|
|
19
|
+
//
|
|
20
|
+
// Design notes:
|
|
21
|
+
// - Keyed by content hash, not the raw content, so we never hold the
|
|
22
|
+
// task body in memory longer than the request itself.
|
|
23
|
+
// - In-memory only. Process restart wipes the index, which is the
|
|
24
|
+
// desired failure mode (servers restart far more rarely than the
|
|
25
|
+
// 5-minute default window, and a fresh window after restart is safer
|
|
26
|
+
// than rehydrating from disk).
|
|
27
|
+
// - Opportunistic eviction during every `shouldDedup` call keeps the
|
|
28
|
+
// map bounded without a separate sweep timer.
|
|
29
|
+
// - Window = 0 disables the guardrail entirely (escape hatch for the
|
|
30
|
+
// rare cases where a workflow genuinely needs to fan out the same
|
|
31
|
+
// task repeatedly — e.g. a batch sweep that produces identical
|
|
32
|
+
// payloads by design).
|
|
33
|
+
// - The structured response shape is identical between MCP and REST
|
|
34
|
+
// callers so the LLM gets the same parseable hint regardless of
|
|
35
|
+
// transport.
|
|
36
|
+
|
|
37
|
+
import { createHash } from "crypto";
|
|
38
|
+
|
|
39
|
+
export type DedupConfig = {
|
|
40
|
+
/** Window in ms; 0 disables guardrail. Default 300000 (5 min). */
|
|
41
|
+
windowMs: number;
|
|
42
|
+
/** Max keys to retain; opportunistically evicted past this. Default 4096. */
|
|
43
|
+
maxKeys: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function readDedupConfig(env: NodeJS.ProcessEnv = process.env): DedupConfig {
|
|
47
|
+
const rawWindow = env.COMMHUB_SEND_DEDUP_WINDOW_MS;
|
|
48
|
+
const windowMs = rawWindow !== undefined ? Math.max(0, Number(rawWindow)) : 300_000;
|
|
49
|
+
const rawMax = env.COMMHUB_SEND_DEDUP_MAX_KEYS;
|
|
50
|
+
const maxKeys = rawMax !== undefined ? Math.max(64, Number(rawMax)) : 4096;
|
|
51
|
+
return {
|
|
52
|
+
windowMs: Number.isFinite(windowMs) ? windowMs : 300_000,
|
|
53
|
+
maxKeys: Number.isFinite(maxKeys) ? maxKeys : 4096,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type DedupCheck =
|
|
58
|
+
| { duplicate: false }
|
|
59
|
+
| { duplicate: true; lastSentMs: number; ageMs: number };
|
|
60
|
+
|
|
61
|
+
export class SendDedup {
|
|
62
|
+
private last = new Map<string, number>();
|
|
63
|
+
private cfg: DedupConfig;
|
|
64
|
+
|
|
65
|
+
constructor(cfg: Partial<DedupConfig> = {}) {
|
|
66
|
+
const fallback = readDedupConfig();
|
|
67
|
+
this.cfg = { windowMs: cfg.windowMs ?? fallback.windowMs, maxKeys: cfg.maxKeys ?? fallback.maxKeys };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether the dedup guardrail is enabled at all. */
|
|
71
|
+
get enabled(): boolean {
|
|
72
|
+
return this.cfg.windowMs > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get windowMs(): number {
|
|
76
|
+
return this.cfg.windowMs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Visible for tests only. */
|
|
80
|
+
get size(): number {
|
|
81
|
+
return this.last.size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static key(from: string, to: string, content: string): string {
|
|
85
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
86
|
+
return `${from}|${to}|${hash}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check whether (from, to, content) was sent recently. Returns
|
|
91
|
+
* `{ duplicate: false }` when the call should proceed, or
|
|
92
|
+
* `{ duplicate: true, lastSentMs, ageMs }` when the caller should be
|
|
93
|
+
* rejected with a `duplicate_send` error.
|
|
94
|
+
*
|
|
95
|
+
* Does NOT record the new send — callers should call `record(...)`
|
|
96
|
+
* AFTER the underlying side effect (inbox insert + pushEvent) succeeds,
|
|
97
|
+
* so failed sends don't accidentally block legitimate retries.
|
|
98
|
+
*/
|
|
99
|
+
check(from: string, to: string, content: string, nowMs: number = Date.now()): DedupCheck {
|
|
100
|
+
if (!this.enabled) return { duplicate: false };
|
|
101
|
+
this.evictExpired(nowMs);
|
|
102
|
+
const k = SendDedup.key(from, to, content);
|
|
103
|
+
const lastSentMs = this.last.get(k);
|
|
104
|
+
if (lastSentMs === undefined) return { duplicate: false };
|
|
105
|
+
const ageMs = nowMs - lastSentMs;
|
|
106
|
+
if (ageMs >= this.cfg.windowMs) {
|
|
107
|
+
this.last.delete(k);
|
|
108
|
+
return { duplicate: false };
|
|
109
|
+
}
|
|
110
|
+
return { duplicate: true, lastSentMs, ageMs };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Record a successful send for future dedup checks. */
|
|
114
|
+
record(from: string, to: string, content: string, nowMs: number = Date.now()): void {
|
|
115
|
+
if (!this.enabled) return;
|
|
116
|
+
const k = SendDedup.key(from, to, content);
|
|
117
|
+
this.last.set(k, nowMs);
|
|
118
|
+
if (this.last.size > this.cfg.maxKeys) this.evictOldest();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Visible for tests. Drops everything. */
|
|
122
|
+
clear(): void {
|
|
123
|
+
this.last.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private evictExpired(nowMs: number): void {
|
|
127
|
+
const cutoff = nowMs - this.cfg.windowMs;
|
|
128
|
+
for (const [k, ts] of this.last) {
|
|
129
|
+
if (ts < cutoff) this.last.delete(k);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private evictOldest(): void {
|
|
134
|
+
const target = Math.max(64, Math.floor(this.cfg.maxKeys * 0.9));
|
|
135
|
+
if (this.last.size <= target) return;
|
|
136
|
+
const entries = Array.from(this.last.entries()).sort((a, b) => a[1] - b[1]);
|
|
137
|
+
for (let i = 0; i < this.last.size - target; i++) this.last.delete(entries[i][0]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Process-wide singleton consumed by both the MCP `send_task` tool and
|
|
142
|
+
// the REST `/api/task` endpoint so they share a single dedup index
|
|
143
|
+
// regardless of which transport the agent hits. Tests reach for it via
|
|
144
|
+
// `sharedSendDedup.clear()` to reset between cases.
|
|
145
|
+
export const sharedSendDedup = new SendDedup();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build the structured `duplicate_send` error payload returned to the
|
|
149
|
+
* caller (MCP wraps it inside `content[0].text` JSON; REST returns it
|
|
150
|
+
* directly with HTTP 429). The Chinese hint matches what 通信龙 specified
|
|
151
|
+
* in the #212 dispatch so the LLM has a consistent piece of text to
|
|
152
|
+
* reason about and rewrite around.
|
|
153
|
+
*/
|
|
154
|
+
export function buildDuplicateSendPayload(args: {
|
|
155
|
+
from: string;
|
|
156
|
+
to: string;
|
|
157
|
+
ageMs: number;
|
|
158
|
+
windowMs: number;
|
|
159
|
+
}): {
|
|
160
|
+
ok: false;
|
|
161
|
+
error: "duplicate_send";
|
|
162
|
+
message: string;
|
|
163
|
+
details: {
|
|
164
|
+
from: string;
|
|
165
|
+
target: string;
|
|
166
|
+
age_ms: number;
|
|
167
|
+
window_ms: number;
|
|
168
|
+
hint_zh: string;
|
|
169
|
+
hint_en: string;
|
|
170
|
+
};
|
|
171
|
+
} {
|
|
172
|
+
const windowMin = Math.round(args.windowMs / 60_000);
|
|
173
|
+
const hintZh =
|
|
174
|
+
`同内容任务 ${windowMin} 分钟内已发给 ${args.to}, ` +
|
|
175
|
+
`如确需重发请改写内容或等待。 (Last sent ${args.ageMs}ms ago)`;
|
|
176
|
+
const hintEn =
|
|
177
|
+
`Same task content already sent to ${args.to} within the last ` +
|
|
178
|
+
`${windowMin} min — change the content or wait. (Last sent ${args.ageMs}ms ago)`;
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
error: "duplicate_send",
|
|
182
|
+
message: hintZh,
|
|
183
|
+
details: {
|
|
184
|
+
from: args.from,
|
|
185
|
+
target: args.to,
|
|
186
|
+
age_ms: args.ageMs,
|
|
187
|
+
window_ms: args.windowMs,
|
|
188
|
+
hint_zh: hintZh,
|
|
189
|
+
hint_en: hintEn,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
|
|
|
4
4
|
import { pushEvent } from "./push.js";
|
|
5
5
|
import { getUserNetworkRole } from "./auth.js";
|
|
6
6
|
import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
|
|
7
|
+
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
7
8
|
|
|
8
9
|
function ts(): string {
|
|
9
10
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -111,12 +112,75 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
111
112
|
return sql;
|
|
112
113
|
};
|
|
113
114
|
|
|
115
|
+
type DeliveryTarget =
|
|
116
|
+
| { state: "online"; alias: string; session: any }
|
|
117
|
+
| { state: "offline"; alias: string; session: any; message: string }
|
|
118
|
+
| { state: "not_found"; alias: string; message: string };
|
|
119
|
+
|
|
114
120
|
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
115
121
|
const params: any[] = [alias];
|
|
116
|
-
let sql = "SELECT status FROM sessions WHERE alias = ?1";
|
|
122
|
+
let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
|
|
117
123
|
sql = addScope(sql, params, networkId);
|
|
118
124
|
return db.get<any>(sql, ...params);
|
|
119
125
|
};
|
|
126
|
+
|
|
127
|
+
const resolveDeliveryTarget = (alias: string, networkId?: string | null): DeliveryTarget => {
|
|
128
|
+
const session = scopedSessionStatus(alias, networkId);
|
|
129
|
+
if (!session) {
|
|
130
|
+
return {
|
|
131
|
+
state: "not_found",
|
|
132
|
+
alias,
|
|
133
|
+
message: `alias not found: ${alias}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const lastSeen = session.last_seen_at || session.updated_at;
|
|
137
|
+
const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
|
|
138
|
+
const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
|
|
139
|
+
if (String(session.status || "").toLowerCase() === "offline" || stale) {
|
|
140
|
+
return {
|
|
141
|
+
state: "offline",
|
|
142
|
+
alias,
|
|
143
|
+
session,
|
|
144
|
+
message: `alias is offline; message queued in inbox: ${alias}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { state: "online", alias, session };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const deliveryTargetReply = (target: DeliveryTarget, ids: Record<string, string> = {}) => {
|
|
151
|
+
if (target.state === "not_found") {
|
|
152
|
+
return {
|
|
153
|
+
content: [{
|
|
154
|
+
type: "text" as const,
|
|
155
|
+
text: JSON.stringify({
|
|
156
|
+
ok: false,
|
|
157
|
+
error: "alias_not_found",
|
|
158
|
+
message: target.message,
|
|
159
|
+
alias: target.alias,
|
|
160
|
+
queued: false,
|
|
161
|
+
...ids,
|
|
162
|
+
}),
|
|
163
|
+
}],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (target.state === "offline") {
|
|
167
|
+
return {
|
|
168
|
+
content: [{
|
|
169
|
+
type: "text" as const,
|
|
170
|
+
text: JSON.stringify({
|
|
171
|
+
ok: false,
|
|
172
|
+
error: "alias_offline",
|
|
173
|
+
message: target.message,
|
|
174
|
+
alias: target.alias,
|
|
175
|
+
queued: true,
|
|
176
|
+
session_status: target.session.status ?? "offline",
|
|
177
|
+
...ids,
|
|
178
|
+
}),
|
|
179
|
+
}],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
120
184
|
// ═══════════════════════════════════════════
|
|
121
185
|
// Child Agent Tools (4)
|
|
122
186
|
// ═══════════════════════════════════════════
|
|
@@ -657,6 +721,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
657
721
|
|
|
658
722
|
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
659
723
|
const targetAlias = canonical.alias;
|
|
724
|
+
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
725
|
+
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
726
|
+
|
|
727
|
+
// #212 dedup guardrail. If this exact (from, to, content) has already
|
|
728
|
+
// been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
|
|
729
|
+
// we refuse the call and surface a structured `duplicate_send`
|
|
730
|
+
// error. The LLM receives the Chinese hint inside details.message
|
|
731
|
+
// and can act on it (rewrite the task or wait). See A站Grok #212
|
|
732
|
+
// incident: 50+ identical dispatches across 5 LLM turns ignored
|
|
733
|
+
// three STOP replies — the LLM cannot be trusted to debounce
|
|
734
|
+
// itself, so the runtime layer must.
|
|
735
|
+
const dedup = sharedSendDedup.check(from_session, targetAlias, task);
|
|
736
|
+
if (dedup.duplicate) {
|
|
737
|
+
const payload = buildDuplicateSendPayload({
|
|
738
|
+
from: from_session,
|
|
739
|
+
to: targetAlias,
|
|
740
|
+
ageMs: dedup.ageMs,
|
|
741
|
+
windowMs: sharedSendDedup.windowMs,
|
|
742
|
+
});
|
|
743
|
+
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
744
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
|
|
745
|
+
}
|
|
746
|
+
|
|
660
747
|
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
661
748
|
const id = uuidv4();
|
|
662
749
|
// 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
|
|
@@ -680,8 +767,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
680
767
|
db.run(touchSql, touchParams);
|
|
681
768
|
});
|
|
682
769
|
logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
|
|
683
|
-
|
|
684
|
-
|
|
770
|
+
// Only stamp the dedup index after the inbox/tasks transaction
|
|
771
|
+
// succeeds, so a failed insert never silently shadows a legitimate
|
|
772
|
+
// retry.
|
|
773
|
+
sharedSendDedup.record(from_session, targetAlias, task);
|
|
685
774
|
|
|
686
775
|
// SSE push by alias.
|
|
687
776
|
// The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
|
|
@@ -694,7 +783,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
694
783
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
695
784
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
696
785
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
697
|
-
|
|
786
|
+
if (target.state === "online") {
|
|
787
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (target.state === "offline") {
|
|
791
|
+
return {
|
|
792
|
+
content: [{
|
|
793
|
+
type: "text" as const,
|
|
794
|
+
text: JSON.stringify({
|
|
795
|
+
ok: false,
|
|
796
|
+
error: "alias_offline",
|
|
797
|
+
message: target.message,
|
|
798
|
+
alias: targetAlias,
|
|
799
|
+
queued: true,
|
|
800
|
+
task_id: id,
|
|
801
|
+
message_id: id,
|
|
802
|
+
session_status: target.session.status ?? "offline",
|
|
803
|
+
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
804
|
+
}),
|
|
805
|
+
}],
|
|
806
|
+
};
|
|
807
|
+
}
|
|
698
808
|
|
|
699
809
|
return {
|
|
700
810
|
content: [
|
|
@@ -704,7 +814,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
704
814
|
ok: true,
|
|
705
815
|
message_id: id,
|
|
706
816
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
707
|
-
session_status: session?.status ?? "unknown",
|
|
817
|
+
session_status: target.session?.status ?? "unknown",
|
|
708
818
|
}),
|
|
709
819
|
},
|
|
710
820
|
],
|
|
@@ -723,6 +833,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
723
833
|
async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
724
834
|
const effectiveNetId = getNetworkId(null);
|
|
725
835
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
836
|
+
const target = resolveDeliveryTarget(alias, effectiveNetId);
|
|
837
|
+
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
726
838
|
console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
|
|
727
839
|
const id = uuidv4();
|
|
728
840
|
db.run(
|
|
@@ -731,9 +843,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
731
843
|
[id, alias, message, from_session, effectiveNetId ?? null]
|
|
732
844
|
);
|
|
733
845
|
|
|
734
|
-
|
|
846
|
+
if (target.state === "online") {
|
|
847
|
+
pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
|
|
848
|
+
}
|
|
735
849
|
|
|
736
|
-
|
|
850
|
+
const offlineReply = deliveryTargetReply(target, { message_id: id });
|
|
851
|
+
if (offlineReply) return offlineReply;
|
|
737
852
|
|
|
738
853
|
return {
|
|
739
854
|
content: [
|
|
@@ -742,7 +857,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
742
857
|
text: JSON.stringify({
|
|
743
858
|
ok: true,
|
|
744
859
|
message_id: id,
|
|
745
|
-
session_status: session?.status ?? "unknown",
|
|
860
|
+
session_status: target.session?.status ?? "unknown",
|
|
746
861
|
}),
|
|
747
862
|
},
|
|
748
863
|
],
|
|
@@ -766,6 +881,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
766
881
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
767
882
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
768
883
|
const id = uuidv4();
|
|
884
|
+
let taskBefore: { status: string } | null = null;
|
|
885
|
+
if (in_reply_to) {
|
|
886
|
+
const taskParams: any[] = [in_reply_to];
|
|
887
|
+
let taskSql = "SELECT status FROM tasks WHERE task_id = ?1";
|
|
888
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
889
|
+
taskBefore = db.get<{ status: string }>(taskSql, ...taskParams) ?? null;
|
|
890
|
+
if (!taskBefore) {
|
|
891
|
+
return {
|
|
892
|
+
content: [{
|
|
893
|
+
type: "text" as const,
|
|
894
|
+
text: JSON.stringify({
|
|
895
|
+
ok: false,
|
|
896
|
+
error: "reply_task_not_found",
|
|
897
|
+
message: `cannot apply reply: task not found (${in_reply_to})`,
|
|
898
|
+
in_reply_to,
|
|
899
|
+
reply_queued: false,
|
|
900
|
+
}),
|
|
901
|
+
}],
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
|
|
905
|
+
return {
|
|
906
|
+
content: [{
|
|
907
|
+
type: "text" as const,
|
|
908
|
+
text: JSON.stringify({
|
|
909
|
+
ok: false,
|
|
910
|
+
error: "reply_task_terminal",
|
|
911
|
+
message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
|
|
912
|
+
in_reply_to,
|
|
913
|
+
task_status: taskBefore.status,
|
|
914
|
+
reply_queued: false,
|
|
915
|
+
}),
|
|
916
|
+
}],
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
}
|
|
769
920
|
const replyLogged = db.transaction(() => {
|
|
770
921
|
db.run(
|
|
771
922
|
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
@@ -789,6 +940,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
789
940
|
return false;
|
|
790
941
|
});
|
|
791
942
|
|
|
943
|
+
if (in_reply_to && !replyLogged) {
|
|
944
|
+
return {
|
|
945
|
+
content: [{
|
|
946
|
+
type: "text" as const,
|
|
947
|
+
text: JSON.stringify({
|
|
948
|
+
ok: false,
|
|
949
|
+
error: "reply_not_applied",
|
|
950
|
+
message: "reply was not applied to task",
|
|
951
|
+
in_reply_to,
|
|
952
|
+
reply_queued: false,
|
|
953
|
+
}),
|
|
954
|
+
}],
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
792
958
|
// Log event after commit (outside transaction)
|
|
793
959
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
794
960
|
|