agent-relay-server 0.33.0 → 0.33.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.
@@ -0,0 +1,434 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
4
+ import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
5
+ import { parseJson } from "../utils";
6
+ import { isLiveIsolatedWorkspace } from "../workspace-phase";
7
+ import {
8
+ CONTRACT_REQUIREMENTS,
9
+ contractCompatibility,
10
+ parseRuntimeCapabilities,
11
+ parseRuntimeContracts,
12
+ parseRuntimePackage,
13
+ type RuntimeContracts,
14
+ } from "../contracts";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
+ import { matchAgents } from "../agent-ref";
17
+ import { getAgent, validateAgentSession } from "./agents.ts";
18
+ import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from "./artifacts.ts";
19
+ import { ClaimError, ValidationError, getDb } from "./connection.ts";
20
+ import { enforceQueueLimit, isChannelAgentId, matchingDeliveryAgents } from "./delivery.ts";
21
+ import { MSG_SELECT, normalizeReactionEmoji, rowToMessage } from "./mappers.ts";
22
+ import { getTask, insertTaskEvent, releaseExpiredClaims } from "./tasks.ts";
23
+ import type {
24
+ AgentCard,
25
+ ActivityEvent,
26
+ ActivityEventInput,
27
+ AgentKind,
28
+ AgentSessionGuard,
29
+ Artifact,
30
+ ArtifactBlob,
31
+ ArtifactKind,
32
+ ArtifactLink,
33
+ ArtifactSensitivity,
34
+ ArtifactVisibility,
35
+ AttachmentRef,
36
+ ChannelBinding,
37
+ ChannelBindingMode,
38
+ ChannelRouteTarget,
39
+ ChatHistoryImport,
40
+ ChatHistoryImportEntry,
41
+ ChannelSummary,
42
+ ChannelTargetHealth,
43
+ CreatePairInput,
44
+ HealthCheck,
45
+ HealthReport,
46
+ ManagedAgent,
47
+ ManagedSessionExitDiagnostics,
48
+ Message,
49
+ MessageDeliveryAttempt,
50
+ MessageDeliveryStatus,
51
+ Orchestrator,
52
+ OrchestratorHealth,
53
+ OrchestratorRuntimeInput,
54
+ OrchestratorStatus,
55
+ OrchestratorUpgradeState,
56
+ PairActionInput,
57
+ PairMessageInput,
58
+ PairSession,
59
+ PairStatus,
60
+ RegisterAgentInput,
61
+ ReplyObligation,
62
+ RegisterOrchestratorInput,
63
+ SendMessageInput,
64
+ PollQuery,
65
+ SpawnApprovalMode,
66
+ SpawnProvider,
67
+ Task,
68
+ TaskEvent,
69
+ TaskSeverity,
70
+ TaskStatus,
71
+ IntegrationEventInput,
72
+ IntegrationSummary,
73
+ IntegrationTaskStats,
74
+ InboxDraft,
75
+ InboxState,
76
+ InboxThreadState,
77
+ ContextSnapshot,
78
+ ContextState,
79
+ ProviderCapabilities,
80
+ TaskStatusInput,
81
+ WorkspaceMetadata,
82
+ WorkspaceRecord,
83
+ WorkspaceStatus,
84
+ } from "../types";
85
+
86
+ export function findMessageByIdempotencyKey(from: string, key: string): Message | null {
87
+ const row = getDb()
88
+ .query(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
89
+ .get(from, key) as any;
90
+ return row ? rowToMessage(row) : null;
91
+ }
92
+
93
+ export function policyNameFromTarget(target: string): string | null {
94
+ if (!target.startsWith("policy:")) return null;
95
+ const name = target.slice("policy:".length).trim();
96
+ return name || null;
97
+ }
98
+
99
+ export function spawnPolicyExists(policyName: string): boolean {
100
+ const row = getDb().query("SELECT 1 FROM config WHERE namespace = 'spawn-policy' AND key = ?").get(policyName);
101
+ return Boolean(row);
102
+ }
103
+
104
+ export function runningAgentForPolicy(policyName: string): string | null {
105
+ const row = getDb().query(`
106
+ SELECT agent_id
107
+ FROM managed_agent_state
108
+ WHERE policy_name = ? AND status = 'running' AND agent_id IS NOT NULL
109
+ `).get(policyName) as { agent_id?: string } | undefined;
110
+ if (!row?.agent_id) return null;
111
+ const agent = getAgent(row.agent_id);
112
+ if (!agent || agent.status === "offline") return null;
113
+ return row.agent_id;
114
+ }
115
+
116
+
117
+ export function claimableAllowedForTarget(target: string): boolean {
118
+ return matchingDeliveryAgents(target).length > 1;
119
+ }
120
+
121
+ export function shouldStoreClaimable(input: SendMessageInput): boolean {
122
+ if (!input.claimable) return false;
123
+ if (input.kind === "task" || input.kind === "system") return true;
124
+ if (input.kind === "channel.event") return true;
125
+ return claimableAllowedForTarget(input.to);
126
+ }
127
+
128
+ export function inferMessageKind(input: SendMessageInput): Message["kind"] {
129
+ if (input.kind) return input.kind;
130
+ if (input.claimable) return "task";
131
+ if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
132
+ return "chat";
133
+ }
134
+
135
+ export const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
136
+
137
+ export function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
138
+ if (!input.replyTo || threadId === null || hasAttachments) return null;
139
+ const row = getDb().query(`
140
+ ${MSG_SELECT}
141
+ WHERE m.from_agent = ?
142
+ AND m.to_target = ?
143
+ AND m.thread_id = ?
144
+ AND m.body = ?
145
+ AND coalesce(m.subject, '') = coalesce(?, '')
146
+ AND coalesce(m.channel, '') = coalesce(?, '')
147
+ AND m.created_at >= ?
148
+ ORDER BY m.created_at DESC
149
+ LIMIT 1
150
+ `).get(
151
+ input.from,
152
+ input.to,
153
+ threadId,
154
+ input.body,
155
+ input.subject ?? null,
156
+ input.channel ?? null,
157
+ now - REPLY_DUPLICATE_WINDOW_MS,
158
+ ) as any;
159
+ return row ? rowToMessage(row) : null;
160
+ }
161
+
162
+ // Event time may be queued-then-backfilled, so it can legitimately be older than the
163
+ // receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
164
+ // readers fall back to created_at) for absent/invalid values or anything more than a minute
165
+ // in the future (clock-skew guard). Only a real backfilled time is stored.
166
+ export function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
167
+ if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return null;
168
+ if (occurredAt <= 0 || occurredAt > receivedAt + 60_000) return null;
169
+ return Math.floor(occurredAt);
170
+ }
171
+
172
+ export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
173
+ const now = Date.now();
174
+ const payload = input.payload ?? {};
175
+ const attachmentRefs = cleanAttachmentRefs(payload);
176
+
177
+ if (!getAgent(input.from)) {
178
+ throw new ValidationError(`sender agent ${input.from} not registered`);
179
+ }
180
+ validateAttachmentRefs(attachmentRefs);
181
+
182
+ if (input.idempotencyKey) {
183
+ const existing = findMessageByIdempotencyKey(input.from, input.idempotencyKey);
184
+ if (existing) return { message: existing, created: false };
185
+ }
186
+
187
+ // Resolve thread: if replying, inherit from parent; reject unknown replyTo
188
+ // rather than silently orphaning the message (leaves thread_id NULL).
189
+ let threadId: number | null = null;
190
+ if (input.replyTo !== undefined && input.replyTo !== null) {
191
+ const parent = getMessage(input.replyTo);
192
+ if (!parent) throw new ValidationError(`replyTo message ${input.replyTo} not found`);
193
+ threadId = parent.threadId ?? parent.id;
194
+ }
195
+ const duplicateReply = findRecentDuplicateReply(input, threadId, now, attachmentRefs.length > 0);
196
+ if (duplicateReply) return { message: duplicateReply, created: false };
197
+
198
+ const insert = getDb().query(`
199
+ INSERT INTO messages (
200
+ from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, reply_expected, claimable,
201
+ idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
202
+ payload, meta, created_at, occurred_at
203
+ )
204
+ VALUES (
205
+ $from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $replyExpected, $claimable,
206
+ $idempotencyKey, $deliveryStatus, $queuedAt, $maxAgeSeconds, $resolvedToAgent,
207
+ $payload, $meta, $now, $occurredAt
208
+ )
209
+ `);
210
+ const setSelfThread = getDb().query("UPDATE messages SET thread_id = ? WHERE id = ?");
211
+ const claimable = shouldStoreClaimable(input);
212
+ const kind = inferMessageKind(input);
213
+ const policyName = policyNameFromTarget(input.to);
214
+ let deliveryStatus: Message["deliveryStatus"] = "pending";
215
+ let queuedAt: number | null = null;
216
+ let maxAgeSeconds = input.maxAgeSeconds ?? null;
217
+ let resolvedToAgent: string | null = null;
218
+
219
+ if (policyName) {
220
+ if (!spawnPolicyExists(policyName)) throw new ValidationError(`spawn policy ${policyName} not found`);
221
+ resolvedToAgent = runningAgentForPolicy(policyName);
222
+ if (!resolvedToAgent) {
223
+ deliveryStatus = "queued";
224
+ queuedAt = now;
225
+ maxAgeSeconds = maxAgeSeconds ?? 86_400;
226
+ }
227
+ }
228
+
229
+ const id = getDb().transaction(() => {
230
+ const result = insert.run({
231
+ $from: input.from,
232
+ $to: input.to,
233
+ $kind: kind,
234
+ $channel: input.channel ?? null,
235
+ $subject: input.subject ?? null,
236
+ $body: input.body,
237
+ $threadId: threadId,
238
+ $replyTo: input.replyTo ?? null,
239
+ // Server-owned reply obligation (#283): true by default; only an explicit false marks
240
+ // a notification. Stored 0/1 so the footer renderer + reply tracker key off one column.
241
+ $replyExpected: input.replyExpected === false ? 0 : 1,
242
+ $claimable: claimable ? 1 : 0,
243
+ $idempotencyKey: input.idempotencyKey ?? null,
244
+ $deliveryStatus: deliveryStatus,
245
+ $queuedAt: queuedAt,
246
+ $maxAgeSeconds: maxAgeSeconds,
247
+ $resolvedToAgent: resolvedToAgent,
248
+ $payload: JSON.stringify(payload),
249
+ $meta: JSON.stringify(input.meta ?? {}),
250
+ $now: now,
251
+ // Sanitize: only accept a plausible epoch-ms event time, else fall back to receive time.
252
+ $occurredAt: sanitizeOccurredAt(input.occurredAt, now),
253
+ });
254
+ const newId = Number(result.lastInsertRowid);
255
+ if (threadId === null) setSelfThread.run(newId, newId);
256
+ linkAttachmentRefs("message", newId, attachmentRefs, input.from);
257
+ if (policyName && deliveryStatus === "queued") enforceQueueLimit(`policy:${policyName}`);
258
+ return newId;
259
+ })();
260
+
261
+ return { message: getMessage(id)!, created: true };
262
+ }
263
+
264
+ export function sendMessage(input: SendMessageInput): Message {
265
+ return sendMessageWithResult(input).message;
266
+ }
267
+
268
+ export function getThread(messageId: number): Message[] {
269
+ const msg = getMessage(messageId);
270
+ if (!msg) return [];
271
+ const threadId = msg.threadId ?? msg.id;
272
+ return (
273
+ getDb()
274
+ .query(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC`)
275
+ .all(threadId) as any[]
276
+ ).map(rowToMessage);
277
+ }
278
+
279
+ export function setMessageReaction(input: {
280
+ messageId: number;
281
+ actorId: string;
282
+ emoji: string;
283
+ action?: "add" | "remove";
284
+ }): { ok: true; message: Message } | { ok: false; error: string } {
285
+ if (!getMessage(input.messageId)) return { ok: false, error: "message not found" };
286
+ const actorId = input.actorId.trim();
287
+ const emoji = normalizeReactionEmoji(input.emoji);
288
+ if (!actorId) return { ok: false, error: "actorId required" };
289
+ if (!emoji) return { ok: false, error: "emoji required" };
290
+
291
+ const now = Date.now();
292
+ if (input.action === "remove") {
293
+ getDb().query("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?")
294
+ .run(input.messageId, actorId, emoji);
295
+ } else {
296
+ getDb().query(`
297
+ INSERT INTO message_reactions (message_id, actor_id, emoji, created_at, updated_at)
298
+ VALUES (?, ?, ?, ?, ?)
299
+ ON CONFLICT(message_id, actor_id, emoji) DO UPDATE SET updated_at = excluded.updated_at
300
+ `).run(input.messageId, actorId, emoji, now, now);
301
+ }
302
+
303
+ return { ok: true, message: getMessage(input.messageId)! };
304
+ }
305
+
306
+ export function findMessageByTelegramSource(input: {
307
+ accountId?: string;
308
+ chatId: string;
309
+ messageId: string;
310
+ }): Message | null {
311
+ const rows = getDb().query(`
312
+ ${MSG_SELECT}
313
+ WHERE json_extract(m.payload, '$.source.telegram.chatId') = ?
314
+ AND json_extract(m.payload, '$.source.telegram.messageId') = ?
315
+ AND (? IS NULL OR json_extract(m.payload, '$.channel.accountId') = ?)
316
+ ORDER BY m.id DESC
317
+ LIMIT 1
318
+ `).all(input.chatId, input.messageId, input.accountId ?? null, input.accountId ?? null) as any[];
319
+ const row = rows[0];
320
+ return row ? rowToMessage(row) : null;
321
+ }
322
+
323
+ export function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
324
+ const expiresAt = now + CLAIM_LEASE_MS;
325
+ // Idempotent: a same-agent re-claim refreshes the lease (preserving the original claimed_at).
326
+ // Without this, a delivery failure that locally drops the claim strands the message until the
327
+ // lease expires (#261) — the holder's retry would otherwise 409 against its own claim.
328
+ const result = getDb().query(
329
+ "UPDATE messages SET claimed_by = ?, claimed_at = COALESCE(claimed_at, ?), claim_expires_at = ? WHERE id = ? AND (claimed_by IS NULL OR claimed_by = ?)"
330
+ ).run(agentId, now, expiresAt, messageId, agentId);
331
+
332
+ // Atomic: if changes === 0, someone else claimed it between our read and write
333
+ if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
334
+ return { ok: true };
335
+ }
336
+
337
+ export function claimMessage(messageId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
338
+ releaseExpiredClaims();
339
+ const session = validateAgentSession(agentId, guard);
340
+ if (!session.ok) return { ok: false, error: session.error };
341
+ const agent = getAgent(agentId);
342
+ if (!agent) return { ok: false, error: "claiming agent not found" };
343
+ if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
344
+
345
+ const msg = getMessage(messageId);
346
+ if (!msg) return { ok: false, error: "message not found" };
347
+ if (!msg.claimable) return { ok: false, error: "message is not claimable" };
348
+ // A same-agent re-claim is allowed (idempotent lease refresh, see claimMessageRow); only a
349
+ // claim held by a *different* agent blocks (#261).
350
+ if (msg.claimedBy && msg.claimedBy !== agentId) return { ok: false, error: `already claimed by ${msg.claimedBy}` };
351
+
352
+ try {
353
+ return getDb().transaction(() => {
354
+ const now = Date.now();
355
+ const expiresAt = now + CLAIM_LEASE_MS;
356
+ const messageClaim = claimMessageRow(messageId, agentId, now);
357
+ if (!messageClaim.ok) return messageClaim;
358
+
359
+ const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
360
+ ? msg.payload.taskId
361
+ : null;
362
+ if (!taskId) return { ok: true };
363
+
364
+ const task = getTask(taskId);
365
+ if (!task) return { ok: true };
366
+
367
+ // Same-agent re-claim: this agent already holds the linked task. Refresh the lease in place
368
+ // rather than re-transitioning (which would throw, since the task is no longer open/blocked).
369
+ if (task.claimedBy === agentId && ["claimed", "in_progress"].includes(task.status)) {
370
+ getDb().query("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?")
371
+ .run(expiresAt, now, taskId, agentId);
372
+ return { ok: true, task: getTask(taskId)! };
373
+ }
374
+
375
+ if (!["open", "blocked"].includes(task.status)) {
376
+ throw new ClaimError(`linked task is ${task.status}`);
377
+ }
378
+
379
+ const taskClaim = getDb().query(`
380
+ UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
381
+ WHERE id = ? AND message_id = ? AND status IN ('open', 'blocked')
382
+ `).run(agentId, now, expiresAt, now, taskId, messageId);
383
+ if (taskClaim.changes === 0) throw new ClaimError("linked task claim race — already claimed");
384
+
385
+ insertTaskEvent(taskId, {
386
+ source: "agent-relay",
387
+ type: "claimed",
388
+ severity: task.severity,
389
+ title: `Task claimed by ${agentId}`,
390
+ body: `Task claimed by ${agentId}`,
391
+ metadata: { agentId, messageId },
392
+ }, now);
393
+
394
+ return { ok: true, task: getTask(taskId)! };
395
+ })();
396
+ } catch (e) {
397
+ if (e instanceof ClaimError) return { ok: false, error: e.message };
398
+ throw e;
399
+ }
400
+ }
401
+
402
+ export function renewMessageClaim(messageId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
403
+ releaseExpiredClaims();
404
+ const session = validateAgentSession(agentId, guard);
405
+ if (!session.ok) return { ok: false, error: session.error };
406
+ const agent = getAgent(agentId);
407
+ if (!agent) return { ok: false, error: "claiming agent not found" };
408
+ if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
409
+ const msg = getMessage(messageId);
410
+ if (!msg) return { ok: false, error: "message not found" };
411
+ if (!msg.claimable) return { ok: false, error: "message is not claimable" };
412
+ if (msg.claimedBy !== agentId) return { ok: false, error: msg.claimedBy ? `claimed by ${msg.claimedBy}` : "message is not claimed" };
413
+
414
+ const now = Date.now();
415
+ const expiresAt = now + CLAIM_LEASE_MS;
416
+ let task: Task | undefined;
417
+ getDb().transaction(() => {
418
+ getDb().query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
419
+ const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
420
+ ? msg.payload.taskId
421
+ : null;
422
+ if (taskId) {
423
+ getDb().query("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
424
+ task = getTask(taskId) ?? undefined;
425
+ }
426
+ })();
427
+ return { ok: true, task };
428
+ }
429
+
430
+ export function getMessage(id: number): Message | null {
431
+ const row = getDb().query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
432
+ return row ? rowToMessage(row) : null;
433
+ }
434
+