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