agent-relay-server 0.5.0 → 0.6.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/README.md +8 -8
- package/package.json +1 -1
- package/public/dashboard.js +106 -17
- package/public/icons/agent-relay-192.png +0 -0
- package/public/icons/agent-relay-512.png +0 -0
- package/public/icons/agent-relay.svg +14 -0
- package/public/index.html +91 -4
- package/public/manifest.webmanifest +33 -0
- package/public/sw.js +58 -0
- package/src/cli.ts +80 -17
- package/src/connectors.ts +256 -0
- package/src/db.ts +413 -25
- package/src/index.ts +25 -1
- package/src/routes.ts +380 -32
- package/src/security.ts +2 -1
- package/src/sse.ts +6 -0
- package/src/types.ts +92 -3
package/src/db.ts
CHANGED
|
@@ -5,13 +5,17 @@ import type {
|
|
|
5
5
|
AgentCard,
|
|
6
6
|
ActivityEvent,
|
|
7
7
|
ActivityEventInput,
|
|
8
|
+
AgentKind,
|
|
8
9
|
AgentSessionGuard,
|
|
10
|
+
ChannelBinding,
|
|
11
|
+
ChannelBindingMode,
|
|
12
|
+
ChannelRouteTarget,
|
|
13
|
+
ChannelSummary,
|
|
9
14
|
CreatePairInput,
|
|
10
15
|
HealthCheck,
|
|
11
16
|
HealthReport,
|
|
12
17
|
ManagedAgent,
|
|
13
18
|
Message,
|
|
14
|
-
MessageType,
|
|
15
19
|
Orchestrator,
|
|
16
20
|
OrchestratorStatus,
|
|
17
21
|
PairActionInput,
|
|
@@ -48,6 +52,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
48
52
|
CREATE TABLE IF NOT EXISTS agents (
|
|
49
53
|
id TEXT PRIMARY KEY,
|
|
50
54
|
name TEXT NOT NULL,
|
|
55
|
+
kind TEXT NOT NULL DEFAULT 'provider',
|
|
51
56
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
52
57
|
machine TEXT,
|
|
53
58
|
rig TEXT,
|
|
@@ -64,7 +69,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
64
69
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
70
|
from_agent TEXT NOT NULL,
|
|
66
71
|
to_target TEXT NOT NULL,
|
|
67
|
-
|
|
72
|
+
kind TEXT NOT NULL DEFAULT 'chat',
|
|
68
73
|
channel TEXT,
|
|
69
74
|
subject TEXT,
|
|
70
75
|
body TEXT NOT NULL,
|
|
@@ -75,6 +80,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
75
80
|
claimed_at INTEGER,
|
|
76
81
|
claim_expires_at INTEGER,
|
|
77
82
|
idempotency_key TEXT,
|
|
83
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
78
84
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
79
85
|
read_by TEXT NOT NULL DEFAULT '[]',
|
|
80
86
|
created_at INTEGER NOT NULL
|
|
@@ -199,6 +205,38 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
199
205
|
CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
|
|
200
206
|
CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
|
|
201
207
|
|
|
208
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
209
|
+
id TEXT PRIMARY KEY,
|
|
210
|
+
provider TEXT NOT NULL,
|
|
211
|
+
account_id TEXT NOT NULL,
|
|
212
|
+
display_name TEXT NOT NULL,
|
|
213
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
214
|
+
transport TEXT NOT NULL,
|
|
215
|
+
direction TEXT NOT NULL DEFAULT 'bidirectional',
|
|
216
|
+
topic_channels TEXT NOT NULL DEFAULT '[]',
|
|
217
|
+
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
218
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
219
|
+
created_at INTEGER NOT NULL,
|
|
220
|
+
updated_at INTEGER NOT NULL
|
|
221
|
+
);
|
|
222
|
+
CREATE INDEX IF NOT EXISTS idx_channels_agent ON channels(agent_id);
|
|
223
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_provider_account ON channels(provider, account_id);
|
|
224
|
+
|
|
225
|
+
CREATE TABLE IF NOT EXISTS channel_bindings (
|
|
226
|
+
id TEXT PRIMARY KEY,
|
|
227
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
228
|
+
conversation_key TEXT NOT NULL DEFAULT '',
|
|
229
|
+
conversation_id TEXT,
|
|
230
|
+
target_type TEXT NOT NULL,
|
|
231
|
+
target_id TEXT NOT NULL,
|
|
232
|
+
mode TEXT NOT NULL DEFAULT 'exclusive',
|
|
233
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
234
|
+
created_at INTEGER NOT NULL,
|
|
235
|
+
updated_at INTEGER NOT NULL
|
|
236
|
+
);
|
|
237
|
+
CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority);
|
|
238
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id);
|
|
239
|
+
|
|
202
240
|
CREATE TABLE IF NOT EXISTS orchestrators (
|
|
203
241
|
id TEXT PRIMARY KEY,
|
|
204
242
|
hostname TEXT NOT NULL,
|
|
@@ -238,9 +276,42 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
238
276
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
|
|
239
277
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
|
|
240
278
|
|
|
241
|
-
|
|
242
|
-
|
|
279
|
+
const channelBindingsSql = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
|
|
280
|
+
if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
|
|
281
|
+
db.transaction(() => {
|
|
282
|
+
db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
|
|
283
|
+
db.run(`
|
|
284
|
+
CREATE TABLE channel_bindings (
|
|
285
|
+
id TEXT PRIMARY KEY,
|
|
286
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
287
|
+
conversation_key TEXT NOT NULL DEFAULT '',
|
|
288
|
+
conversation_id TEXT,
|
|
289
|
+
target_type TEXT NOT NULL,
|
|
290
|
+
target_id TEXT NOT NULL,
|
|
291
|
+
mode TEXT NOT NULL DEFAULT 'exclusive',
|
|
292
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
293
|
+
created_at INTEGER NOT NULL,
|
|
294
|
+
updated_at INTEGER NOT NULL
|
|
295
|
+
)
|
|
296
|
+
`);
|
|
297
|
+
db.run(`
|
|
298
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
299
|
+
SELECT id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at
|
|
300
|
+
FROM channel_bindings_old
|
|
301
|
+
`);
|
|
302
|
+
db.run("DROP TABLE channel_bindings_old");
|
|
303
|
+
})();
|
|
304
|
+
}
|
|
305
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
|
|
306
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
|
|
307
|
+
|
|
308
|
+
if (!colNames.includes("kind")) {
|
|
309
|
+
db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
|
|
310
|
+
}
|
|
311
|
+
if (!colNames.includes("payload")) {
|
|
312
|
+
db.run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
|
|
243
313
|
}
|
|
314
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
|
|
244
315
|
|
|
245
316
|
// Backfill thread_id for pre-migration rows (self-threaded).
|
|
246
317
|
db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
|
|
@@ -280,19 +351,33 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
280
351
|
if (!agentColNames.includes("epoch")) {
|
|
281
352
|
db.run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
|
|
282
353
|
}
|
|
354
|
+
if (!agentColNames.includes("kind")) {
|
|
355
|
+
db.run("ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'provider'");
|
|
356
|
+
db.run(`
|
|
357
|
+
UPDATE agents
|
|
358
|
+
SET kind = CASE
|
|
359
|
+
WHEN id = 'user' THEN 'user'
|
|
360
|
+
WHEN id = 'system' THEN 'system'
|
|
361
|
+
WHEN json_extract(meta, '$.kind') = 'channel' THEN 'channel'
|
|
362
|
+
WHEN EXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'channel') THEN 'channel'
|
|
363
|
+
ELSE 'provider'
|
|
364
|
+
END
|
|
365
|
+
`);
|
|
366
|
+
}
|
|
283
367
|
db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
|
|
368
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
|
|
284
369
|
|
|
285
370
|
// Built-in agents — registered unconditionally so sends from these ids
|
|
286
371
|
// pass the sendMessage validation. The reaper exempts these by checking
|
|
287
372
|
// meta.builtin (or by id for "user").
|
|
288
373
|
const now = Date.now();
|
|
289
374
|
const builtinStmt = db.prepare(`
|
|
290
|
-
INSERT INTO agents (id, name, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
291
|
-
VALUES (?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
|
|
375
|
+
INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
376
|
+
VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
|
|
292
377
|
ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
|
|
293
378
|
`);
|
|
294
|
-
builtinStmt.run("user", "User", '["human"]', now, now);
|
|
295
|
-
builtinStmt.run("system", "System", '["system"]', now, now);
|
|
379
|
+
builtinStmt.run("user", "User", "user", '["human"]', now, now);
|
|
380
|
+
builtinStmt.run("system", "System", "system", '["system"]', now, now);
|
|
296
381
|
|
|
297
382
|
// One-shot migration: backfill message_reads from legacy read_by JSON
|
|
298
383
|
// if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
|
|
@@ -333,7 +418,19 @@ function stringValue(value: unknown): string | undefined {
|
|
|
333
418
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
334
419
|
}
|
|
335
420
|
|
|
421
|
+
function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
|
|
422
|
+
if (input.kind) return input.kind;
|
|
423
|
+
if (input.id === "user") return "user";
|
|
424
|
+
if (input.id === "system") return "system";
|
|
425
|
+
const metaKind = stringValue(input.meta?.kind);
|
|
426
|
+
if (metaKind === "channel" || metaKind === "communication-channel") return "channel";
|
|
427
|
+
if (metaKind === "orchestrator") return "orchestrator";
|
|
428
|
+
if ((input.tags ?? []).includes("channel") || (input.capabilities ?? []).includes("channel")) return "channel";
|
|
429
|
+
return "provider";
|
|
430
|
+
}
|
|
431
|
+
|
|
336
432
|
function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
|
|
433
|
+
if (inferAgentKind(input) !== "provider") return undefined;
|
|
337
434
|
const meta = input.meta ?? {};
|
|
338
435
|
const values = [
|
|
339
436
|
input.id,
|
|
@@ -367,6 +464,7 @@ function rowToAgent(row: any): AgentCard {
|
|
|
367
464
|
return {
|
|
368
465
|
id: row.id,
|
|
369
466
|
name: row.name,
|
|
467
|
+
kind: row.kind ?? "provider",
|
|
370
468
|
label: row.label ?? undefined,
|
|
371
469
|
tags: parseStringArray(row.tags),
|
|
372
470
|
machine: row.machine ?? undefined,
|
|
@@ -387,7 +485,7 @@ function rowToMessage(row: any): Message {
|
|
|
387
485
|
id: row.id,
|
|
388
486
|
from: row.from_agent,
|
|
389
487
|
to: row.to_target,
|
|
390
|
-
|
|
488
|
+
kind: row.kind ?? "chat",
|
|
391
489
|
channel: row.channel ?? undefined,
|
|
392
490
|
subject: row.subject ?? undefined,
|
|
393
491
|
body: row.body,
|
|
@@ -398,6 +496,7 @@ function rowToMessage(row: any): Message {
|
|
|
398
496
|
claimedAt: row.claimed_at ?? undefined,
|
|
399
497
|
claimExpiresAt: row.claim_expires_at ?? undefined,
|
|
400
498
|
idempotencyKey: row.idempotency_key ?? undefined,
|
|
499
|
+
payload: parseJson(row.payload ?? "{}", {}),
|
|
401
500
|
meta: parseJson(row.meta, {}),
|
|
402
501
|
readBy: parseJson(row.read_by_agents ?? "[]", []),
|
|
403
502
|
createdAt: row.created_at,
|
|
@@ -503,6 +602,65 @@ function rowToActivityEvent(row: any): ActivityEvent {
|
|
|
503
602
|
};
|
|
504
603
|
}
|
|
505
604
|
|
|
605
|
+
function rowToChannelBinding(row: any): ChannelBinding {
|
|
606
|
+
const target = row.target_type === "broadcast"
|
|
607
|
+
? { type: "broadcast" } as ChannelRouteTarget
|
|
608
|
+
: { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
|
|
609
|
+
return {
|
|
610
|
+
id: row.id,
|
|
611
|
+
channelId: row.channel_id,
|
|
612
|
+
conversationId: row.conversation_id ?? undefined,
|
|
613
|
+
target,
|
|
614
|
+
mode: row.mode as ChannelBindingMode,
|
|
615
|
+
priority: row.priority,
|
|
616
|
+
createdAt: row.created_at,
|
|
617
|
+
updatedAt: row.updated_at,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
|
|
622
|
+
if (target.type === "agent") return target.id;
|
|
623
|
+
if (target.type === "label") return `label:${target.id}`;
|
|
624
|
+
if (target.type === "tag") return `tag:${target.id}`;
|
|
625
|
+
if (target.type === "capability") return `cap:${target.id}`;
|
|
626
|
+
if (target.type === "broadcast") return "broadcast";
|
|
627
|
+
if (target.type === "orchestrator") return `orchestrator:${target.id}`;
|
|
628
|
+
return "";
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function rowToChannelSummary(row: any): ChannelSummary {
|
|
632
|
+
const binding = row.binding_id ? rowToChannelBinding({
|
|
633
|
+
id: row.binding_id,
|
|
634
|
+
channel_id: row.id,
|
|
635
|
+
conversation_id: row.binding_conversation_id,
|
|
636
|
+
target_type: row.binding_target_type,
|
|
637
|
+
target_id: row.binding_target_id,
|
|
638
|
+
mode: row.binding_mode,
|
|
639
|
+
priority: row.binding_priority,
|
|
640
|
+
created_at: row.binding_created_at,
|
|
641
|
+
updated_at: row.binding_updated_at,
|
|
642
|
+
}) : undefined;
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
id: row.id,
|
|
646
|
+
name: row.display_name,
|
|
647
|
+
type: row.provider,
|
|
648
|
+
transport: row.transport,
|
|
649
|
+
agentId: row.agent_id,
|
|
650
|
+
accountId: row.account_id,
|
|
651
|
+
status: row.agent_status,
|
|
652
|
+
ready: row.agent_ready === 1,
|
|
653
|
+
direction: row.direction,
|
|
654
|
+
target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
|
|
655
|
+
binding,
|
|
656
|
+
topicChannels: parseStringArray(row.topic_channels),
|
|
657
|
+
capabilities: parseStringArray(row.channel_capabilities),
|
|
658
|
+
tags: parseStringArray(row.agent_tags),
|
|
659
|
+
lastSeen: row.agent_last_seen,
|
|
660
|
+
meta: parseJson(row.channel_meta, {}),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
506
664
|
const MSG_SELECT = `SELECT m.*, (
|
|
507
665
|
SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
|
|
508
666
|
) AS read_by_agents FROM messages m`;
|
|
@@ -511,6 +669,13 @@ const MSG_SELECT = `SELECT m.*, (
|
|
|
511
669
|
|
|
512
670
|
export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
513
671
|
const now = Date.now();
|
|
672
|
+
const kind = inferAgentKind(input);
|
|
673
|
+
if (kind === "channel") {
|
|
674
|
+
const expectedId = expectedChannelAgentId(input);
|
|
675
|
+
if (input.id !== expectedId) {
|
|
676
|
+
throw new ValidationError(`channel agent id must be canonical: ${expectedId}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
514
679
|
const tags = tagsWithProvider(input);
|
|
515
680
|
// Preserve the existing label across re-registrations unless the caller
|
|
516
681
|
// explicitly sends one (including null to clear).
|
|
@@ -518,10 +683,11 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
518
683
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
519
684
|
const instanceProvided = Boolean(input.instanceId);
|
|
520
685
|
const stmt = db.prepare(`
|
|
521
|
-
INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
|
|
522
|
-
VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
|
|
686
|
+
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
|
|
687
|
+
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
|
|
523
688
|
ON CONFLICT(id) DO UPDATE SET
|
|
524
689
|
name = $name,
|
|
690
|
+
kind = $kind,
|
|
525
691
|
label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
|
|
526
692
|
tags = $tags,
|
|
527
693
|
machine = coalesce($machine, agents.machine),
|
|
@@ -541,6 +707,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
541
707
|
stmt.run({
|
|
542
708
|
$id: input.id,
|
|
543
709
|
$name: input.name,
|
|
710
|
+
$kind: kind,
|
|
544
711
|
$label: input.label ?? null,
|
|
545
712
|
$labelProvided: labelProvided ? 1 : 0,
|
|
546
713
|
$tags: JSON.stringify(tags),
|
|
@@ -557,7 +724,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
557
724
|
$now: now,
|
|
558
725
|
});
|
|
559
726
|
|
|
560
|
-
|
|
727
|
+
const agent = getAgent(input.id)!;
|
|
728
|
+
if (agent.kind === "channel") upsertChannelForAgent(agent);
|
|
729
|
+
return agent;
|
|
561
730
|
}
|
|
562
731
|
|
|
563
732
|
export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
|
|
@@ -646,6 +815,201 @@ export function heartbeat(id: string, guard?: AgentSessionGuard): boolean {
|
|
|
646
815
|
return result.changes > 0;
|
|
647
816
|
}
|
|
648
817
|
|
|
818
|
+
function channelProviderForAgent(agent: AgentCard): string {
|
|
819
|
+
return stringValue(agent.meta?.provider) ??
|
|
820
|
+
stringValue(agent.meta?.channelType) ??
|
|
821
|
+
stringValue(agent.meta?.transport) ??
|
|
822
|
+
agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
|
|
823
|
+
"custom";
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function channelProviderForInput(input: Pick<RegisterAgentInput, "tags" | "meta">): string {
|
|
827
|
+
return stringValue(input.meta?.provider) ??
|
|
828
|
+
stringValue(input.meta?.channelType) ??
|
|
829
|
+
stringValue(input.meta?.transport) ??
|
|
830
|
+
(input.tags ?? []).find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
|
|
831
|
+
"custom";
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function channelAccountIdForAgent(agent: AgentCard, provider: string): string {
|
|
835
|
+
const accountId = stringValue(agent.meta?.accountId);
|
|
836
|
+
if (accountId) return accountId;
|
|
837
|
+
const prefix = `${provider}:`;
|
|
838
|
+
return agent.id.startsWith(prefix) ? agent.id.slice(prefix.length) : agent.id;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function channelAccountIdForInput(input: Pick<RegisterAgentInput, "id" | "meta">, provider: string): string {
|
|
842
|
+
const accountId = stringValue(input.meta?.accountId);
|
|
843
|
+
if (accountId) return accountId;
|
|
844
|
+
const prefix = `${provider}:`;
|
|
845
|
+
return input.id.startsWith(prefix) ? input.id.slice(prefix.length) : input.id;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function expectedChannelAgentId(input: Pick<RegisterAgentInput, "id" | "tags" | "meta">): string {
|
|
849
|
+
const provider = channelProviderForInput(input);
|
|
850
|
+
const accountId = channelAccountIdForInput(input, provider);
|
|
851
|
+
return `${provider}:${accountId}`;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"] {
|
|
855
|
+
const direction = stringValue(agent.meta?.direction);
|
|
856
|
+
return direction === "inbound" || direction === "outbound" || direction === "bidirectional" ? direction : "bidirectional";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
|
|
860
|
+
if (!target) return undefined;
|
|
861
|
+
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
862
|
+
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
863
|
+
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
864
|
+
if (target === "broadcast") return { type: "broadcast" };
|
|
865
|
+
if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
|
|
866
|
+
return { type: "agent", id: target };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function upsertChannelForAgent(agent: AgentCard): void {
|
|
870
|
+
const now = Date.now();
|
|
871
|
+
const provider = channelProviderForAgent(agent);
|
|
872
|
+
const accountId = channelAccountIdForAgent(agent, provider);
|
|
873
|
+
const channelId = `${provider}:${accountId}`;
|
|
874
|
+
const transport = stringValue(agent.meta?.transport) ?? provider;
|
|
875
|
+
const displayName = stringValue(agent.meta?.displayName) ?? agent.name;
|
|
876
|
+
const topicChannels = Array.isArray(agent.meta?.topicChannels)
|
|
877
|
+
? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
878
|
+
: [];
|
|
879
|
+
|
|
880
|
+
db.prepare(`
|
|
881
|
+
INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
|
|
882
|
+
VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
|
|
883
|
+
ON CONFLICT(provider, account_id) DO UPDATE SET
|
|
884
|
+
id = $id,
|
|
885
|
+
provider = $provider,
|
|
886
|
+
account_id = $accountId,
|
|
887
|
+
display_name = $displayName,
|
|
888
|
+
agent_id = $agentId,
|
|
889
|
+
transport = $transport,
|
|
890
|
+
direction = $direction,
|
|
891
|
+
topic_channels = $topicChannels,
|
|
892
|
+
capabilities = $capabilities,
|
|
893
|
+
meta = $meta,
|
|
894
|
+
updated_at = $now
|
|
895
|
+
`).run({
|
|
896
|
+
$id: channelId,
|
|
897
|
+
$provider: provider,
|
|
898
|
+
$accountId: accountId,
|
|
899
|
+
$displayName: displayName,
|
|
900
|
+
$agentId: agent.id,
|
|
901
|
+
$transport: transport,
|
|
902
|
+
$direction: channelDirectionForAgent(agent),
|
|
903
|
+
$topicChannels: JSON.stringify(topicChannels),
|
|
904
|
+
$capabilities: JSON.stringify(agent.capabilities),
|
|
905
|
+
$meta: JSON.stringify(agent.meta ?? {}),
|
|
906
|
+
$now: now,
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
const defaultTarget = routeTargetFromLegacyTarget(stringValue(agent.meta?.target));
|
|
910
|
+
if (defaultTarget) {
|
|
911
|
+
upsertChannelBinding({
|
|
912
|
+
channelId,
|
|
913
|
+
target: defaultTarget,
|
|
914
|
+
mode: defaultTarget.type === "agent" ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export function listChannels(): ChannelSummary[] {
|
|
920
|
+
const rows = db.prepare(`
|
|
921
|
+
SELECT
|
|
922
|
+
c.*,
|
|
923
|
+
c.capabilities AS channel_capabilities,
|
|
924
|
+
c.meta AS channel_meta,
|
|
925
|
+
a.status AS agent_status,
|
|
926
|
+
a.ready AS agent_ready,
|
|
927
|
+
a.tags AS agent_tags,
|
|
928
|
+
a.last_seen AS agent_last_seen,
|
|
929
|
+
b.id AS binding_id,
|
|
930
|
+
b.conversation_id AS binding_conversation_id,
|
|
931
|
+
b.target_type AS binding_target_type,
|
|
932
|
+
b.target_id AS binding_target_id,
|
|
933
|
+
b.mode AS binding_mode,
|
|
934
|
+
b.priority AS binding_priority,
|
|
935
|
+
b.created_at AS binding_created_at,
|
|
936
|
+
b.updated_at AS binding_updated_at
|
|
937
|
+
FROM channels c
|
|
938
|
+
JOIN agents a ON a.id = c.agent_id
|
|
939
|
+
LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
|
|
940
|
+
ORDER BY a.ready DESC, c.display_name COLLATE NOCASE
|
|
941
|
+
`).all() as any[];
|
|
942
|
+
return rows.map(rowToChannelSummary);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function getChannel(channelId: string): ChannelSummary | null {
|
|
946
|
+
return listChannels().find((channel) => channel.id === channelId) ?? null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export function listChannelBindings(channelId?: string): ChannelBinding[] {
|
|
950
|
+
const rows = channelId
|
|
951
|
+
? db.prepare("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
|
|
952
|
+
: db.prepare("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
|
|
953
|
+
return rows.map(rowToChannelBinding);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export function upsertChannelBinding(input: {
|
|
957
|
+
channelId: string;
|
|
958
|
+
conversationId?: string;
|
|
959
|
+
target: ChannelRouteTarget;
|
|
960
|
+
mode?: ChannelBindingMode;
|
|
961
|
+
priority?: number;
|
|
962
|
+
}): ChannelBinding {
|
|
963
|
+
if (!getChannel(input.channelId)) throw new ValidationError(`channel ${input.channelId} not found`);
|
|
964
|
+
const conversationKey = input.conversationId ?? "";
|
|
965
|
+
const targetId = input.target.type === "broadcast" ? "" : input.target.id;
|
|
966
|
+
const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
|
|
967
|
+
const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
|
|
968
|
+
const now = Date.now();
|
|
969
|
+
db.prepare(`
|
|
970
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
971
|
+
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
|
|
972
|
+
ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
|
|
973
|
+
mode = $mode,
|
|
974
|
+
priority = $priority,
|
|
975
|
+
updated_at = $now
|
|
976
|
+
`).run({
|
|
977
|
+
$id: id,
|
|
978
|
+
$channelId: input.channelId,
|
|
979
|
+
$conversationKey: conversationKey,
|
|
980
|
+
$conversationId: input.conversationId ?? null,
|
|
981
|
+
$targetType: input.target.type,
|
|
982
|
+
$targetId: targetId,
|
|
983
|
+
$mode: input.mode ?? "exclusive",
|
|
984
|
+
$priority: input.priority ?? 0,
|
|
985
|
+
$now: now,
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
|
|
989
|
+
return rowToChannelBinding(row);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
|
|
993
|
+
const rows = conversationId
|
|
994
|
+
? db.prepare(`
|
|
995
|
+
SELECT * FROM channel_bindings
|
|
996
|
+
WHERE channel_id = ? AND conversation_key IN (?, '')
|
|
997
|
+
ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
|
|
998
|
+
`).all(channelId, conversationId, conversationId) as any[]
|
|
999
|
+
: db.prepare(`
|
|
1000
|
+
SELECT * FROM channel_bindings
|
|
1001
|
+
WHERE channel_id = ? AND conversation_key = ''
|
|
1002
|
+
ORDER BY priority DESC, updated_at DESC
|
|
1003
|
+
`).all(channelId) as any[];
|
|
1004
|
+
if (!conversationId) return rows.map(rowToChannelBinding);
|
|
1005
|
+
const exact = rows.filter((row) => row.conversation_key === conversationId);
|
|
1006
|
+
return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export function resolveChannelRoute(channelId: string, conversationId?: string): ChannelBinding | null {
|
|
1010
|
+
return resolveChannelRoutes(channelId, conversationId)[0] ?? null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
649
1013
|
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
650
1014
|
const now = Date.now();
|
|
651
1015
|
const cutoff = now - ttlMs;
|
|
@@ -833,14 +1197,16 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
833
1197
|
message = sendMessage({
|
|
834
1198
|
from: "system",
|
|
835
1199
|
to: task.target,
|
|
836
|
-
|
|
1200
|
+
kind: "task",
|
|
837
1201
|
channel: task.channel,
|
|
838
1202
|
subject: `[${task.severity}] ${task.title}`,
|
|
839
1203
|
body: taskMessageBody(task),
|
|
840
1204
|
claimable: true,
|
|
841
|
-
|
|
1205
|
+
payload: {
|
|
842
1206
|
taskId: task.id,
|
|
843
1207
|
source: task.source,
|
|
1208
|
+
title: task.title,
|
|
1209
|
+
status: task.status,
|
|
844
1210
|
severity: task.severity,
|
|
845
1211
|
dedupeKey: task.dedupeKey ?? null,
|
|
846
1212
|
externalUrl: task.externalUrl ?? null,
|
|
@@ -1175,10 +1541,10 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
|
|
|
1175
1541
|
return sendMessage({
|
|
1176
1542
|
from: "system",
|
|
1177
1543
|
to,
|
|
1178
|
-
|
|
1544
|
+
kind: "pair",
|
|
1179
1545
|
subject,
|
|
1180
1546
|
body,
|
|
1181
|
-
|
|
1547
|
+
payload: {
|
|
1182
1548
|
pairId: pair.id,
|
|
1183
1549
|
pairEvent: event,
|
|
1184
1550
|
requesterId: pair.requesterId,
|
|
@@ -1326,9 +1692,10 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
|
|
|
1326
1692
|
const message = sendMessage({
|
|
1327
1693
|
from: input.from,
|
|
1328
1694
|
to,
|
|
1695
|
+
kind: "pair",
|
|
1329
1696
|
subject: input.subject ?? `Pair ${pair.id}`,
|
|
1330
1697
|
body: input.body,
|
|
1331
|
-
|
|
1698
|
+
payload: {
|
|
1332
1699
|
pairId: pair.id,
|
|
1333
1700
|
pairEvent: "message",
|
|
1334
1701
|
requesterId: pair.requesterId,
|
|
@@ -1352,9 +1719,20 @@ function isDeliveryAgent(agent: AgentCard): boolean {
|
|
|
1352
1719
|
return agent.status !== "offline" &&
|
|
1353
1720
|
agent.id !== "user" &&
|
|
1354
1721
|
agent.id !== "system" &&
|
|
1722
|
+
agent.kind !== "channel" &&
|
|
1355
1723
|
agent.meta?.kind !== "channel";
|
|
1356
1724
|
}
|
|
1357
1725
|
|
|
1726
|
+
function isChannelAgentId(agentId: string): boolean {
|
|
1727
|
+
const agent = getAgent(agentId);
|
|
1728
|
+
return Boolean(agent && (
|
|
1729
|
+
agent.kind === "channel" ||
|
|
1730
|
+
agent.meta?.kind === "channel" ||
|
|
1731
|
+
agent.tags.includes("channel") ||
|
|
1732
|
+
agent.capabilities.includes("channel")
|
|
1733
|
+
));
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1358
1736
|
function matchingDeliveryAgents(target: string): AgentCard[] {
|
|
1359
1737
|
if (!target) return [];
|
|
1360
1738
|
const candidates = listAgents().filter(isDeliveryAgent);
|
|
@@ -1382,10 +1760,18 @@ function claimableAllowedForTarget(target: string): boolean {
|
|
|
1382
1760
|
|
|
1383
1761
|
function shouldStoreClaimable(input: SendMessageInput): boolean {
|
|
1384
1762
|
if (!input.claimable) return false;
|
|
1385
|
-
if (input.
|
|
1763
|
+
if (input.kind === "task" || input.kind === "system") return true;
|
|
1764
|
+
if (input.kind === "channel.event") return true;
|
|
1386
1765
|
return claimableAllowedForTarget(input.to);
|
|
1387
1766
|
}
|
|
1388
1767
|
|
|
1768
|
+
function inferMessageKind(input: SendMessageInput): Message["kind"] {
|
|
1769
|
+
if (input.kind) return input.kind;
|
|
1770
|
+
if (input.claimable) return "task";
|
|
1771
|
+
if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
|
|
1772
|
+
return "chat";
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1389
1775
|
export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
|
|
1390
1776
|
const now = Date.now();
|
|
1391
1777
|
|
|
@@ -1408,17 +1794,18 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
1408
1794
|
}
|
|
1409
1795
|
|
|
1410
1796
|
const insert = db.prepare(`
|
|
1411
|
-
INSERT INTO messages (from_agent, to_target,
|
|
1412
|
-
VALUES ($from, $to, $
|
|
1797
|
+
INSERT INTO messages (from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, payload, meta, created_at)
|
|
1798
|
+
VALUES ($from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $payload, $meta, $now)
|
|
1413
1799
|
`);
|
|
1414
1800
|
const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
|
|
1415
1801
|
const claimable = shouldStoreClaimable(input);
|
|
1802
|
+
const kind = inferMessageKind(input);
|
|
1416
1803
|
|
|
1417
1804
|
const id = db.transaction(() => {
|
|
1418
1805
|
const result = insert.run({
|
|
1419
1806
|
$from: input.from,
|
|
1420
1807
|
$to: input.to,
|
|
1421
|
-
$
|
|
1808
|
+
$kind: kind,
|
|
1422
1809
|
$channel: input.channel ?? null,
|
|
1423
1810
|
$subject: input.subject ?? null,
|
|
1424
1811
|
$body: input.body,
|
|
@@ -1426,6 +1813,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
1426
1813
|
$replyTo: input.replyTo ?? null,
|
|
1427
1814
|
$claimable: claimable ? 1 : 0,
|
|
1428
1815
|
$idempotencyKey: input.idempotencyKey ?? null,
|
|
1816
|
+
$payload: JSON.stringify(input.payload ?? {}),
|
|
1429
1817
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
1430
1818
|
$now: now,
|
|
1431
1819
|
});
|
|
@@ -1483,8 +1871,8 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
|
|
|
1483
1871
|
const messageClaim = claimMessageRow(messageId, agentId, now);
|
|
1484
1872
|
if (!messageClaim.ok) return messageClaim;
|
|
1485
1873
|
|
|
1486
|
-
const taskId = typeof msg.
|
|
1487
|
-
? msg.
|
|
1874
|
+
const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
|
|
1875
|
+
? msg.payload.taskId
|
|
1488
1876
|
: null;
|
|
1489
1877
|
if (!taskId) return { ok: true };
|
|
1490
1878
|
|
|
@@ -1534,8 +1922,8 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
|
|
|
1534
1922
|
let task: Task | undefined;
|
|
1535
1923
|
db.transaction(() => {
|
|
1536
1924
|
db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
|
|
1537
|
-
const taskId = typeof msg.
|
|
1538
|
-
? msg.
|
|
1925
|
+
const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
|
|
1926
|
+
? msg.payload.taskId
|
|
1539
1927
|
: null;
|
|
1540
1928
|
if (taskId) {
|
|
1541
1929
|
db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|