@parall/agent-core 1.13.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,865 @@
1
+ import * as os from "node:os";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { MENTION_ALL_USER_ID } from "@parall/sdk";
6
+ import { buildEventBody, buildForkResultPrefix } from "./event-format.js";
7
+ import { routeTrigger } from "./routing.js";
8
+ import { clearDispatchMessageId, clearDispatchNoReply, clearSessionMessageId, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from "./session-state.js";
9
+ function resolveStepTarget(event) {
10
+ if (event.type === "task" || event.targetId.startsWith("tsk_")) {
11
+ return { target_type: "task", target_id: event.targetId };
12
+ }
13
+ if (event.targetId.startsWith("cht_")) {
14
+ return { target_type: "chat", target_id: event.targetId };
15
+ }
16
+ return { target_type: "", target_id: event.targetId || undefined };
17
+ }
18
+ async function fetchAllChats(client, orgId, chatInfoMap) {
19
+ let cursor;
20
+ let total = 0;
21
+ do {
22
+ const res = await client.getChats(orgId, { limit: 100, cursor });
23
+ for (const chat of res.data) {
24
+ chatInfoMap.set(chat.id, {
25
+ type: chat.type,
26
+ name: chat.name ?? null,
27
+ agentRoutingMode: chat.agent_routing_mode,
28
+ });
29
+ }
30
+ total += res.data.length;
31
+ cursor = res.has_more ? res.next_cursor : undefined;
32
+ } while (cursor);
33
+ return total;
34
+ }
35
+ export class ParallAgentGateway {
36
+ opts;
37
+ chatInfoMap = new Map();
38
+ activeDispatches = new Map();
39
+ dispatchedTasks = new Set();
40
+ dispatchedMessages = new Set();
41
+ forkStates = new Map();
42
+ dispatchState = {
43
+ mainDispatching: false,
44
+ activeForks: new Map(),
45
+ pendingForkResults: [],
46
+ mainBuffer: [],
47
+ };
48
+ sessionId = "";
49
+ activeSessionId;
50
+ heartbeatTimer = null;
51
+ hadSuccessfulHello = false;
52
+ lastHeartbeatAt = Date.now();
53
+ draining = false;
54
+ DISPATCHED_MESSAGES_CAP = 5000;
55
+ COLD_START_WINDOW_MS;
56
+ constructor(opts) {
57
+ this.opts = opts;
58
+ this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
59
+ }
60
+ async run(abortSignal) {
61
+ const { ws, log } = this.opts;
62
+ ws.onStateChange((state) => {
63
+ log?.info(`parall[${this.opts.accountId}]: connection state → ${state}`);
64
+ });
65
+ ws.on("hello", async (data) => {
66
+ await this.handleHello(data);
67
+ });
68
+ ws.on("chat.update", (data) => {
69
+ const changes = data.changes;
70
+ if (!changes)
71
+ return;
72
+ const existing = this.chatInfoMap.get(data.chat_id);
73
+ if (existing) {
74
+ this.chatInfoMap.set(data.chat_id, {
75
+ ...existing,
76
+ ...(typeof changes.type === "string" ? { type: changes.type } : {}),
77
+ ...(typeof changes.name === "string" ? { name: changes.name } : {}),
78
+ ...(typeof changes.agent_routing_mode === "string"
79
+ ? { agentRoutingMode: changes.agent_routing_mode }
80
+ : {}),
81
+ });
82
+ }
83
+ else if (typeof changes.type === "string") {
84
+ this.chatInfoMap.set(data.chat_id, {
85
+ type: changes.type,
86
+ name: typeof changes.name === "string" ? changes.name : null,
87
+ agentRoutingMode: (typeof changes.agent_routing_mode === "string"
88
+ ? changes.agent_routing_mode
89
+ : "passive"),
90
+ });
91
+ }
92
+ });
93
+ ws.on("message.new", async (data) => {
94
+ await this.handleMessage(data);
95
+ });
96
+ ws.on("agent_config.update", async (data) => {
97
+ this.opts.log?.info(`parall[${this.opts.accountId}]: config update notification (version=${data.version})`);
98
+ try {
99
+ await this.opts.onConfigUpdate?.(data);
100
+ }
101
+ catch (err) {
102
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: config update failed: ${String(err)}`);
103
+ }
104
+ });
105
+ ws.on("recovery.overflow", () => {
106
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: recovery.overflow — triggering full catch-up`);
107
+ this.catchUpFromDispatch().catch((err) => this.opts.log?.warn(`parall[${this.opts.accountId}]: overflow catch-up failed: ${String(err)}`));
108
+ });
109
+ ws.on("task.assigned", async (data) => {
110
+ if (data.assignee_id !== this.opts.agentUserId)
111
+ return;
112
+ if (data.status !== "todo" && data.status !== "in_progress")
113
+ return;
114
+ try {
115
+ const dispatched = await this.handleTaskAssignment(data, data.id);
116
+ if (dispatched) {
117
+ this.opts.client.ackDispatch(this.opts.config.org_id, { source_type: "task_activity", source_id: data.id }).catch(() => { });
118
+ }
119
+ }
120
+ catch (err) {
121
+ this.opts.log?.error(`parall[${this.opts.accountId}]: task dispatch failed for ${data.id}: ${String(err)}`);
122
+ }
123
+ });
124
+ this.opts.log?.info(`parall[${this.opts.accountId}]: connecting to ${this.opts.connectionLabel ?? "Parall WS"}...`);
125
+ await ws.connect();
126
+ return new Promise((resolve) => {
127
+ abortSignal.addEventListener("abort", async () => {
128
+ await this.shutdown();
129
+ resolve();
130
+ });
131
+ });
132
+ }
133
+ tryClaimMessage(id) {
134
+ if (this.dispatchedMessages.has(id))
135
+ return false;
136
+ if (this.dispatchedMessages.size >= this.DISPATCHED_MESSAGES_CAP) {
137
+ let toEvict = Math.floor(this.DISPATCHED_MESSAGES_CAP / 4);
138
+ for (const old of this.dispatchedMessages) {
139
+ this.dispatchedMessages.delete(old);
140
+ if (--toEvict <= 0)
141
+ break;
142
+ }
143
+ }
144
+ this.dispatchedMessages.add(id);
145
+ return true;
146
+ }
147
+ startTyping(chatId) {
148
+ const existing = this.activeDispatches.get(chatId);
149
+ if (existing) {
150
+ existing.count++;
151
+ return;
152
+ }
153
+ if (this.opts.ws.state === "connected")
154
+ this.opts.ws.sendTyping(chatId, "start");
155
+ const typingRefresh = setInterval(() => {
156
+ if (this.opts.ws.state === "connected")
157
+ this.opts.ws.sendTyping(chatId, "start");
158
+ }, 2000);
159
+ this.activeDispatches.set(chatId, { count: 1, typingTimer: typingRefresh });
160
+ }
161
+ stopTyping(chatId) {
162
+ const dispatch = this.activeDispatches.get(chatId);
163
+ if (!dispatch)
164
+ return;
165
+ dispatch.count--;
166
+ if (dispatch.count > 0)
167
+ return;
168
+ clearInterval(dispatch.typingTimer);
169
+ this.activeDispatches.delete(chatId);
170
+ if (this.opts.ws.state === "connected")
171
+ this.opts.ws.sendTyping(chatId, "stop");
172
+ }
173
+ buildDispatchContext(event, sessionKey) {
174
+ return {
175
+ accountId: this.opts.accountId,
176
+ apiUrl: this.opts.config.parall_url,
177
+ apiKey: this.opts.config.api_key,
178
+ orgId: this.opts.config.org_id,
179
+ agentUserId: this.opts.agentUserId,
180
+ runtimeType: this.opts.runtimeType,
181
+ runtimeKey: this.opts.runtimeKey,
182
+ sessionId: this.activeSessionId,
183
+ chatId: event.type === "message" ? event.targetId : undefined,
184
+ triggerMessageId: event.messageId,
185
+ noReply: event.noReply ?? false,
186
+ stepIdFilePath: this.opts.stepIdFilePathForSession?.(sessionKey),
187
+ client: this.opts.client,
188
+ log: this.opts.log,
189
+ };
190
+ }
191
+ async createInputStep(event) {
192
+ if (!this.activeSessionId)
193
+ return;
194
+ const target = resolveStepTarget(event);
195
+ try {
196
+ await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, {
197
+ step_type: "input",
198
+ target_type: target.target_type,
199
+ target_id: target.target_id,
200
+ content: {
201
+ trigger_type: event.type === "task" ? "task_assign" : "mention",
202
+ trigger_ref: event.type === "task" ? { task_id: event.targetId } : { message_id: event.messageId },
203
+ sender_id: event.senderId,
204
+ sender_name: event.senderName,
205
+ summary: event.body.substring(0, 200),
206
+ },
207
+ });
208
+ }
209
+ catch (err) {
210
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to create input step: ${String(err)}`);
211
+ }
212
+ }
213
+ async createRuntimeStep(event, runtimeEvent, stepIdFilePath) {
214
+ if (!this.activeSessionId)
215
+ return;
216
+ const target = resolveStepTarget(event);
217
+ try {
218
+ switch (runtimeEvent.type) {
219
+ case "thinking":
220
+ await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, {
221
+ step_type: "thinking",
222
+ target_type: target.target_type,
223
+ target_id: target.target_id,
224
+ content: { text: runtimeEvent.text },
225
+ group_key: runtimeEvent.groupKey,
226
+ });
227
+ break;
228
+ case "text":
229
+ await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, {
230
+ step_type: "text",
231
+ target_type: target.target_type,
232
+ target_id: target.target_id,
233
+ content: {
234
+ text: runtimeEvent.text,
235
+ suppressed: runtimeEvent.project !== true,
236
+ },
237
+ projection: runtimeEvent.project === true,
238
+ group_key: runtimeEvent.groupKey,
239
+ });
240
+ break;
241
+ case "tool_call": {
242
+ const step = await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, {
243
+ step_type: "tool_call",
244
+ target_type: target.target_type,
245
+ target_id: target.target_id,
246
+ content: {
247
+ call_id: runtimeEvent.callId,
248
+ tool_name: runtimeEvent.toolName,
249
+ tool_input: runtimeEvent.input,
250
+ status: "running",
251
+ started_at: runtimeEvent.startedAt ?? new Date().toISOString(),
252
+ },
253
+ group_key: runtimeEvent.groupKey,
254
+ runtime_key: runtimeEvent.callId,
255
+ });
256
+ if (stepIdFilePath) {
257
+ this.writeStepIdFile(stepIdFilePath, step.id);
258
+ }
259
+ break;
260
+ }
261
+ case "tool_result":
262
+ await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, {
263
+ step_type: "tool_result",
264
+ target_type: target.target_type,
265
+ target_id: target.target_id,
266
+ content: {
267
+ call_id: runtimeEvent.callId,
268
+ tool_name: runtimeEvent.toolName,
269
+ status: runtimeEvent.error ? "error" : "success",
270
+ output: runtimeEvent.output,
271
+ duration_ms: runtimeEvent.durationMs ?? 0,
272
+ collapsible: true,
273
+ },
274
+ group_key: runtimeEvent.groupKey,
275
+ });
276
+ if (stepIdFilePath) {
277
+ this.clearStepIdFile(stepIdFilePath);
278
+ }
279
+ break;
280
+ case "error":
281
+ await this.opts.client.createAgentStep(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, {
282
+ step_type: "text",
283
+ target_type: target.target_type,
284
+ target_id: target.target_id,
285
+ content: { text: runtimeEvent.message, suppressed: false },
286
+ projection: true,
287
+ });
288
+ break;
289
+ }
290
+ }
291
+ catch (err) {
292
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to create ${runtimeEvent.type} step: ${String(err)}`);
293
+ }
294
+ }
295
+ writeStepIdFile(filePath, stepId) {
296
+ try {
297
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
298
+ fs.writeFileSync(filePath, stepId, "utf8");
299
+ }
300
+ catch (err) {
301
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to write step id file ${filePath}: ${String(err)}`);
302
+ }
303
+ }
304
+ clearStepIdFile(filePath) {
305
+ try {
306
+ fs.writeFileSync(filePath, "", "utf8");
307
+ }
308
+ catch {
309
+ // Best-effort cleanup.
310
+ }
311
+ }
312
+ async createInputStepsForEarlierEvents(events) {
313
+ for (const event of events) {
314
+ await this.createInputStep(event);
315
+ }
316
+ }
317
+ async runDispatch(event, sessionKey, bodyForAgent, earlierEvents = []) {
318
+ setSessionChatId(sessionKey, event.targetId);
319
+ setSessionMessageId(sessionKey, event.messageId);
320
+ setDispatchMessageId(sessionKey, event.messageId);
321
+ setDispatchNoReply(sessionKey, event.noReply ?? false);
322
+ const dispatchContext = this.buildDispatchContext(event, sessionKey);
323
+ const stepIdFilePath = dispatchContext.stepIdFilePath;
324
+ try {
325
+ if (this.activeSessionId) {
326
+ try {
327
+ await this.opts.client.updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, { status: "active" });
328
+ }
329
+ catch (err) {
330
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to set session active: ${String(err)}`);
331
+ }
332
+ }
333
+ await this.createInputStep(event);
334
+ for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
335
+ event,
336
+ earlierEvents,
337
+ bodyForAgent,
338
+ sessionKey,
339
+ context: dispatchContext,
340
+ })) {
341
+ await this.createRuntimeStep(event, runtimeEvent, stepIdFilePath);
342
+ }
343
+ }
344
+ catch (err) {
345
+ await this.createRuntimeStep(event, {
346
+ type: "error",
347
+ message: `Dispatch failed: ${String(err)}`,
348
+ }, stepIdFilePath);
349
+ throw err;
350
+ }
351
+ finally {
352
+ clearSessionMessageId(sessionKey);
353
+ clearDispatchMessageId(sessionKey);
354
+ clearDispatchNoReply(sessionKey);
355
+ if (stepIdFilePath) {
356
+ this.clearStepIdFile(stepIdFilePath);
357
+ }
358
+ if (this.activeSessionId) {
359
+ try {
360
+ await this.opts.client.updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, { status: "idle" });
361
+ }
362
+ catch (err) {
363
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to set session idle: ${String(err)}`);
364
+ }
365
+ }
366
+ }
367
+ }
368
+ async runForkDrainLoop(fork) {
369
+ try {
370
+ while (fork.queue.length > 0) {
371
+ const items = fork.queue.splice(0);
372
+ const events = items.map((item) => item.event);
373
+ const last = events[events.length - 1];
374
+ const earlier = events.slice(0, -1);
375
+ try {
376
+ if (earlier.length > 0) {
377
+ await this.createInputStepsForEarlierEvents(earlier);
378
+ }
379
+ await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
380
+ fork.processedEvents.push(...events);
381
+ for (const item of items) {
382
+ item.resolve(true);
383
+ }
384
+ }
385
+ catch (err) {
386
+ this.opts.log?.error(`parall[${this.opts.accountId}]: fork dispatch failed for ${last.messageId}: ${String(err)}`);
387
+ for (const item of items) {
388
+ item.resolve(false);
389
+ }
390
+ break;
391
+ }
392
+ }
393
+ for (const remaining of fork.queue.splice(0)) {
394
+ remaining.resolve(false);
395
+ }
396
+ }
397
+ finally {
398
+ if (fork.processedEvents.length > 0) {
399
+ const first = fork.processedEvents[0];
400
+ this.dispatchState.pendingForkResults.push({
401
+ forkSessionKey: fork.fork.sessionKey,
402
+ sourceEvent: {
403
+ type: first.type,
404
+ targetId: fork.targetId,
405
+ summary: fork.processedEvents.length === 1
406
+ ? `${first.type} from ${first.senderName} in ${first.targetName ?? fork.targetId}`
407
+ : `${fork.processedEvents.length} events in ${first.targetName ?? fork.targetId}`,
408
+ },
409
+ actions: [],
410
+ });
411
+ }
412
+ this.forkStates.delete(fork.targetId);
413
+ this.dispatchState.activeForks.delete(fork.targetId);
414
+ if (this.opts.dispatchAdapter.cleanupFork) {
415
+ const cleanupOpts = {
416
+ fork: fork.fork,
417
+ context: {
418
+ accountId: this.opts.accountId,
419
+ apiUrl: this.opts.config.parall_url,
420
+ apiKey: this.opts.config.api_key,
421
+ orgId: this.opts.config.org_id,
422
+ agentUserId: this.opts.agentUserId,
423
+ runtimeType: this.opts.runtimeType,
424
+ runtimeKey: this.opts.runtimeKey,
425
+ sessionId: this.activeSessionId,
426
+ noReply: false,
427
+ client: this.opts.client,
428
+ log: this.opts.log,
429
+ },
430
+ };
431
+ await this.opts.dispatchAdapter.cleanupFork(cleanupOpts);
432
+ }
433
+ }
434
+ }
435
+ async drainMainBuffer() {
436
+ if (this.draining)
437
+ return;
438
+ this.draining = true;
439
+ try {
440
+ while (this.dispatchState.mainBuffer.length > 0 || this.dispatchState.pendingForkResults.length > 0) {
441
+ if (this.dispatchState.mainBuffer.length === 0 && this.dispatchState.pendingForkResults.length > 0) {
442
+ const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
443
+ const syntheticEvent = {
444
+ type: "message",
445
+ targetId: "_orchestrator",
446
+ targetType: "system",
447
+ senderId: "system",
448
+ senderName: "system",
449
+ messageId: `synthetic-${this.opts.accountId}-${randomUUID()}`,
450
+ body: "[Orchestrator: fork session(s) completed — review results above]",
451
+ };
452
+ this.dispatchState.mainCurrentTargetId = undefined;
453
+ await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
454
+ continue;
455
+ }
456
+ const targetId = this.dispatchState.mainBuffer[0].targetId;
457
+ const events = [];
458
+ while (this.dispatchState.mainBuffer[0]?.targetId === targetId) {
459
+ events.push(this.dispatchState.mainBuffer.shift());
460
+ }
461
+ const event = events[events.length - 1];
462
+ const earlier = events.slice(0, -1);
463
+ const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
464
+ this.dispatchState.mainCurrentTargetId = event.targetId;
465
+ if (earlier.length > 0) {
466
+ await this.createInputStepsForEarlierEvents(earlier);
467
+ }
468
+ await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
469
+ for (const bufferedEvent of events) {
470
+ const sourceType = bufferedEvent.ackSourceType ?? (bufferedEvent.type === "task" ? "task_activity" : "message");
471
+ const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
472
+ this.opts.client.ackDispatch(this.opts.config.org_id, {
473
+ source_type: sourceType,
474
+ source_id: sourceId,
475
+ }).catch(() => { });
476
+ }
477
+ }
478
+ }
479
+ finally {
480
+ this.draining = false;
481
+ this.dispatchState.mainDispatching = false;
482
+ this.dispatchState.mainCurrentTargetId = undefined;
483
+ }
484
+ }
485
+ async handleInboundEvent(event) {
486
+ const disposition = routeTrigger(event, this.dispatchState);
487
+ switch (disposition.action) {
488
+ case "main": {
489
+ const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
490
+ this.dispatchState.mainDispatching = true;
491
+ this.dispatchState.mainCurrentTargetId = event.targetId;
492
+ try {
493
+ await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
494
+ }
495
+ finally {
496
+ await this.drainMainBuffer();
497
+ }
498
+ return true;
499
+ }
500
+ case "buffer-main":
501
+ this.dispatchState.mainBuffer.push(event);
502
+ return false;
503
+ case "buffer-fork": {
504
+ const activeFork = this.forkStates.get(event.targetId);
505
+ if (!activeFork) {
506
+ this.dispatchState.mainBuffer.push(event);
507
+ return false;
508
+ }
509
+ return new Promise((resolve) => {
510
+ activeFork.queue.push({ event, resolve });
511
+ });
512
+ }
513
+ case "new-fork": {
514
+ if (!this.opts.dispatchAdapter.forkSession) {
515
+ this.dispatchState.mainBuffer.push(event);
516
+ return false;
517
+ }
518
+ const fork = await this.opts.dispatchAdapter.forkSession({
519
+ sessionKey: this.opts.runtimeKey,
520
+ context: this.buildDispatchContext(event, this.opts.runtimeKey),
521
+ });
522
+ if (!fork) {
523
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: fork failed, buffering event for main session`);
524
+ this.dispatchState.mainBuffer.push(event);
525
+ return false;
526
+ }
527
+ const activeFork = {
528
+ fork,
529
+ targetId: event.targetId,
530
+ queue: [],
531
+ processedEvents: [],
532
+ };
533
+ this.forkStates.set(event.targetId, activeFork);
534
+ this.dispatchState.activeForks.set(event.targetId, fork.sessionKey);
535
+ const firstEventPromise = new Promise((resolve) => {
536
+ activeFork.queue.push({ event, resolve });
537
+ });
538
+ this.runForkDrainLoop(activeFork).catch((err) => {
539
+ this.opts.log?.error(`parall[${this.opts.accountId}]: fork drain loop error: ${String(err)}`);
540
+ });
541
+ return firstEventPromise;
542
+ }
543
+ }
544
+ }
545
+ async getOrFetchChatInfo(chatId) {
546
+ const cached = this.chatInfoMap.get(chatId);
547
+ if (cached)
548
+ return cached;
549
+ try {
550
+ const chat = await this.opts.client.getChat(this.opts.config.org_id, chatId);
551
+ const chatInfo = {
552
+ type: chat.type,
553
+ name: chat.name ?? null,
554
+ agentRoutingMode: chat.agent_routing_mode,
555
+ };
556
+ this.chatInfoMap.set(chatId, chatInfo);
557
+ return chatInfo;
558
+ }
559
+ catch (err) {
560
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to resolve chat ${chatId}: ${String(err)}`);
561
+ return null;
562
+ }
563
+ }
564
+ async buildMessageDispatchDecision(chatId, message) {
565
+ if (message.message_type !== "text" && message.message_type !== "file") {
566
+ return { action: "skip" };
567
+ }
568
+ const chatInfo = await this.getOrFetchChatInfo(chatId);
569
+ if (!chatInfo)
570
+ return { action: "retry" };
571
+ let body = "";
572
+ let mediaFields = {};
573
+ if (message.message_type === "text") {
574
+ const content = message.content;
575
+ body = content.text?.trim() ?? "";
576
+ if (!body)
577
+ return { action: "skip" };
578
+ if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
579
+ const mentions = content.mentions ?? [];
580
+ const isMentioned = mentions.some((mention) => mention.user_id === this.opts.agentUserId || mention.user_id === MENTION_ALL_USER_ID);
581
+ if (!isMentioned)
582
+ return { action: "skip" };
583
+ }
584
+ }
585
+ else {
586
+ const content = message.content;
587
+ if (chatInfo.type === "group" && chatInfo.agentRoutingMode !== "active") {
588
+ return { action: "skip" };
589
+ }
590
+ body = content.caption?.trim() || `[file: ${content.file_name || "attachment"}]`;
591
+ if (content.attachment_id) {
592
+ try {
593
+ const fileRes = await this.opts.client.getFileUrl(content.attachment_id);
594
+ mediaFields = { MediaUrl: fileRes.url, MediaType: content.mime_type };
595
+ }
596
+ catch (err) {
597
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
598
+ }
599
+ }
600
+ }
601
+ return {
602
+ action: "dispatch",
603
+ event: {
604
+ type: "message",
605
+ targetId: chatId,
606
+ targetName: chatInfo.name ?? undefined,
607
+ targetType: chatInfo.type,
608
+ senderId: message.sender_id,
609
+ senderName: message.sender?.display_name ?? message.sender_id,
610
+ messageId: message.id,
611
+ body,
612
+ threadRootId: message.thread_root_id ?? undefined,
613
+ noReply: message.hints?.no_reply ?? false,
614
+ mediaFields: Object.keys(mediaFields).length > 0 ? mediaFields : undefined,
615
+ ackSourceType: "message",
616
+ ackSourceId: message.id,
617
+ },
618
+ };
619
+ }
620
+ async handleMessage(data) {
621
+ const chatId = data.chat_id;
622
+ if (data.sender_id === this.opts.agentUserId)
623
+ return;
624
+ if (data.message_type !== "text" && data.message_type !== "file")
625
+ return;
626
+ if (!this.tryClaimMessage(data.id))
627
+ return;
628
+ const decision = await this.buildMessageDispatchDecision(chatId, data);
629
+ if (decision.action !== "dispatch") {
630
+ this.dispatchedMessages.delete(data.id);
631
+ return;
632
+ }
633
+ const event = decision.event;
634
+ const willDispatch = !this.dispatchState.mainDispatching || this.dispatchState.mainCurrentTargetId !== chatId;
635
+ if (willDispatch)
636
+ this.startTyping(chatId);
637
+ try {
638
+ const dispatched = await this.handleInboundEvent(event);
639
+ if (dispatched) {
640
+ this.opts.client.ackDispatch(this.opts.config.org_id, { source_type: "message", source_id: data.id }).catch(() => { });
641
+ }
642
+ else {
643
+ this.dispatchedMessages.delete(data.id);
644
+ }
645
+ }
646
+ catch (err) {
647
+ this.opts.log?.error(`parall[${this.opts.accountId}]: event dispatch failed for ${data.id}: ${String(err)}`);
648
+ this.dispatchedMessages.delete(data.id);
649
+ }
650
+ finally {
651
+ if (willDispatch)
652
+ this.stopTyping(chatId);
653
+ }
654
+ }
655
+ async handleTaskAssignment(task, ackSourceId) {
656
+ const dedupeKey = `${task.id}:${task.updated_at}`;
657
+ if (this.dispatchedTasks.has(dedupeKey)) {
658
+ this.opts.log?.info(`parall[${this.opts.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
659
+ return false;
660
+ }
661
+ this.dispatchedTasks.add(dedupeKey);
662
+ this.opts.log?.info(`parall[${this.opts.accountId}]: task assigned: ${task.identifier ?? task.id} "${task.title}"`);
663
+ const parts = [`Title: ${task.title}`];
664
+ parts.push(`Status: ${task.status}`, `Priority: ${task.priority}`);
665
+ if (task.description)
666
+ parts.push("", task.description);
667
+ const event = {
668
+ type: "task",
669
+ targetId: task.id,
670
+ targetName: task.identifier ?? undefined,
671
+ targetType: "task",
672
+ senderId: task.creator_id,
673
+ senderName: "system",
674
+ messageId: task.id,
675
+ body: parts.join("\n"),
676
+ ackSourceType: "task_activity",
677
+ ackSourceId,
678
+ };
679
+ const dispatched = await this.handleInboundEvent(event);
680
+ if (!dispatched) {
681
+ this.dispatchedTasks.delete(dedupeKey);
682
+ }
683
+ return dispatched;
684
+ }
685
+ async catchUpFromDispatch(coldStart = false) {
686
+ const minAge = coldStart ? Date.now() - this.COLD_START_WINDOW_MS : 0;
687
+ let cursor;
688
+ let processed = 0;
689
+ let skippedOld = 0;
690
+ do {
691
+ const page = await this.opts.client.getDispatch(this.opts.config.org_id, {
692
+ limit: 50,
693
+ cursor,
694
+ });
695
+ for (const item of page.data ?? []) {
696
+ if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
697
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
698
+ skippedOld++;
699
+ continue;
700
+ }
701
+ processed++;
702
+ try {
703
+ let dispatched = false;
704
+ if (item.event_type === "task_assign" && item.task_id) {
705
+ let task = null;
706
+ let taskFetchFailed = false;
707
+ try {
708
+ task = await this.opts.client.getTask(this.opts.config.org_id, item.task_id);
709
+ }
710
+ catch (err) {
711
+ const status = err?.status;
712
+ if (status === 404) {
713
+ task = null;
714
+ }
715
+ else {
716
+ taskFetchFailed = true;
717
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: catch-up task fetch failed for ${item.task_id}, leaving pending: ${String(err)}`);
718
+ }
719
+ }
720
+ if (taskFetchFailed)
721
+ continue;
722
+ if (task) {
723
+ if (task.assignee_id !== this.opts.agentUserId) {
724
+ this.opts.log?.info(`parall[${this.opts.accountId}]: skipping stale task dispatch ${item.id} — reassigned to ${task.assignee_id}`);
725
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
726
+ continue;
727
+ }
728
+ dispatched = await this.handleTaskAssignment(task);
729
+ }
730
+ else {
731
+ dispatched = true;
732
+ }
733
+ }
734
+ else if (item.event_type === "message" && item.source_id && item.chat_id) {
735
+ if (!this.tryClaimMessage(item.source_id))
736
+ continue;
737
+ let msg = null;
738
+ let msgFetchFailed = false;
739
+ try {
740
+ msg = await this.opts.client.getMessage(item.source_id);
741
+ }
742
+ catch (err) {
743
+ const status = err?.status;
744
+ if (status === 404) {
745
+ msg = null;
746
+ }
747
+ else {
748
+ msgFetchFailed = true;
749
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: catch-up message fetch failed for ${item.source_id}, leaving pending: ${String(err)}`);
750
+ }
751
+ }
752
+ if (msgFetchFailed) {
753
+ this.dispatchedMessages.delete(item.source_id);
754
+ continue;
755
+ }
756
+ if (!msg || msg.sender_id === this.opts.agentUserId) {
757
+ this.dispatchedMessages.delete(item.source_id);
758
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
759
+ continue;
760
+ }
761
+ const decision = await this.buildMessageDispatchDecision(item.chat_id, msg);
762
+ if (decision.action === "retry") {
763
+ this.dispatchedMessages.delete(item.source_id);
764
+ continue;
765
+ }
766
+ if (decision.action === "skip") {
767
+ this.dispatchedMessages.delete(item.source_id);
768
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
769
+ continue;
770
+ }
771
+ dispatched = await this.handleInboundEvent(decision.event);
772
+ }
773
+ if (dispatched) {
774
+ this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
775
+ }
776
+ }
777
+ catch (err) {
778
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
779
+ }
780
+ }
781
+ cursor = page.has_more ? page.next_cursor : undefined;
782
+ } while (cursor);
783
+ if (processed > 0 || skippedOld > 0) {
784
+ this.opts.log?.info(`parall[${this.opts.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
785
+ }
786
+ }
787
+ async handleHello(data) {
788
+ const { client, config, log } = this.opts;
789
+ this.sessionId = data.session_id ?? "";
790
+ const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
791
+ try {
792
+ const count = await fetchAllChats(client, config.org_id, this.chatInfoMap);
793
+ log?.info(`parall[${this.opts.accountId}]: WebSocket connected, ${count} chats cached`);
794
+ if (!this.activeSessionId) {
795
+ try {
796
+ const session = await client.createAgentSession(config.org_id, this.opts.agentUserId, {
797
+ runtime_type: this.opts.runtimeType,
798
+ runtime_key: this.opts.runtimeKey,
799
+ runtime_ref: this.opts.runtimeRef,
800
+ });
801
+ this.activeSessionId = session.id;
802
+ log?.info(`parall[${this.opts.accountId}]: created agent session ${session.id}`);
803
+ }
804
+ catch (err) {
805
+ log?.warn(`parall[${this.opts.accountId}]: failed to create agent session: ${String(err)}`);
806
+ }
807
+ }
808
+ await this.opts.onSessionReady?.({
809
+ activeSessionId: this.activeSessionId,
810
+ ws: this.opts.ws,
811
+ runtimeKey: this.opts.runtimeKey,
812
+ });
813
+ if (this.heartbeatTimer)
814
+ clearInterval(this.heartbeatTimer);
815
+ this.lastHeartbeatAt = Date.now();
816
+ this.heartbeatTimer = setInterval(() => {
817
+ const now = Date.now();
818
+ const expectedMs = intervalSec * 1000;
819
+ const drift = now - this.lastHeartbeatAt - expectedMs;
820
+ if (drift > 15000) {
821
+ log?.warn(`parall[${this.opts.accountId}]: heartbeat drift ${drift}ms — event loop may be blocked`);
822
+ }
823
+ this.lastHeartbeatAt = now;
824
+ if (this.opts.ws.state !== "connected")
825
+ return;
826
+ this.opts.ws.sendAgentHeartbeat(this.sessionId, {
827
+ hostname: os.hostname(),
828
+ cores: os.cpus().length,
829
+ mem_total: os.totalmem(),
830
+ mem_free: os.freemem(),
831
+ uptime: os.uptime(),
832
+ });
833
+ }, intervalSec * 1000);
834
+ const isFirstHello = !this.hadSuccessfulHello;
835
+ this.hadSuccessfulHello = true;
836
+ this.catchUpFromDispatch(isFirstHello).catch((err) => {
837
+ log?.warn(`parall[${this.opts.accountId}]: dispatch catch-up failed: ${String(err)}`);
838
+ });
839
+ }
840
+ catch (err) {
841
+ log?.error(`parall[${this.opts.accountId}]: failed to fetch chats: ${String(err)}`);
842
+ }
843
+ }
844
+ async shutdown() {
845
+ if (this.heartbeatTimer)
846
+ clearInterval(this.heartbeatTimer);
847
+ for (const [, dispatch] of this.activeDispatches) {
848
+ clearInterval(dispatch.typingTimer);
849
+ }
850
+ this.activeDispatches.clear();
851
+ await this.opts.onBeforeDisconnect?.();
852
+ if (this.activeSessionId) {
853
+ try {
854
+ await this.opts.client.updateAgentSession(this.opts.config.org_id, this.opts.agentUserId, this.activeSessionId, {
855
+ status: "completed",
856
+ });
857
+ }
858
+ catch (err) {
859
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to complete session: ${String(err)}`);
860
+ }
861
+ }
862
+ this.opts.ws.disconnect();
863
+ this.opts.log?.info(`parall[${this.opts.accountId}]: disconnected`);
864
+ }
865
+ }