@parall/parel-channel 1.37.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/dist/channel-prompt.d.ts +30 -0
- package/dist/channel-prompt.d.ts.map +1 -0
- package/dist/channel-prompt.js +54 -0
- package/dist/connect.d.ts +22 -0
- package/dist/connect.d.ts.map +1 -0
- package/dist/connect.js +57 -0
- package/dist/delivery.d.ts +24 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +120 -0
- package/dist/inbound.d.ts +28 -0
- package/dist/inbound.d.ts.map +1 -0
- package/dist/inbound.js +525 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/session.d.ts +110 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +284 -0
- package/package.json +37 -0
- package/parel.plugin.json +26 -0
- package/src/channel-prompt.ts +71 -0
- package/src/connect.ts +63 -0
- package/src/delivery.ts +144 -0
- package/src/inbound.ts +623 -0
- package/src/index.ts +28 -0
- package/src/session.ts +333 -0
package/dist/inbound.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { buildChannelPrompt } from './channel-prompt.js';
|
|
2
|
+
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
3
|
+
import { createChannelInputStep, createInputStep, createTraceStep, ensureSession, invalidateSession, markDispatchReceived, patchSession, REQUEST_TIMEOUT_MS, turnEnvelopeKey, } from './session.js';
|
|
4
|
+
export async function handleParallFrame(frame, ctx) {
|
|
5
|
+
let event;
|
|
6
|
+
try {
|
|
7
|
+
const raw = typeof frame.data === 'string' ? frame.data : new TextDecoder().decode(frame.data);
|
|
8
|
+
event = JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
if (event.type !== 'dispatch.new' || !event.data)
|
|
14
|
+
return [];
|
|
15
|
+
try {
|
|
16
|
+
return await effectsForDispatch(event.data, ctx);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
// A transient fetch failure on one frame must not fail the frame handler —
|
|
20
|
+
// the dispatch stays pending and the onOpen sweep replays it on reconnect.
|
|
21
|
+
console.error('[parel-channel] dispatch handling failed', err);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Sweep pending dispatches on (re)connect — the live WS push has no replay.
|
|
27
|
+
* Paginates with the server cursor so a long outage's backlog is not silently
|
|
28
|
+
* truncated (bounded to keep a pathological backlog from wedging onOpen; the
|
|
29
|
+
* remainder is picked up on the next reconnect sweep).
|
|
30
|
+
*/
|
|
31
|
+
export async function catchUpParall(ctx) {
|
|
32
|
+
const effects = [];
|
|
33
|
+
try {
|
|
34
|
+
const apiUrl = parallApiUrl(ctx);
|
|
35
|
+
const agk = requireAgk(ctx);
|
|
36
|
+
const orgId = parallOrgId(ctx);
|
|
37
|
+
let cursor = '';
|
|
38
|
+
// Page bound is a wedge guard, not a coverage target. Non-message
|
|
39
|
+
// dispatches stay pending (deliberately — see effectsForDispatch) and the
|
|
40
|
+
// list is FIFO, so the bound must comfortably exceed any realistic
|
|
41
|
+
// unsupported backlog or old rows could starve newer DMs out of the sweep.
|
|
42
|
+
// 40 pages ≈ 2000 dispatches per sweep; log when we hit the ceiling so a
|
|
43
|
+
// pathological backlog is visible instead of silent.
|
|
44
|
+
const maxPages = 40;
|
|
45
|
+
let page = 0;
|
|
46
|
+
for (; page < maxPages; page++) {
|
|
47
|
+
const url = `${apiUrl}/api/v1/orgs/${orgId}/dispatch?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
headers: { Authorization: `Bearer ${agk}` },
|
|
50
|
+
signal: AbortSignal.timeout(10_000),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
break;
|
|
54
|
+
const body = (await res.json().catch(() => null));
|
|
55
|
+
// Same-chat dispatches run SEQUENTIALLY (session reporting PATCHes one
|
|
56
|
+
// shared session per chat — concurrent replay would let an older
|
|
57
|
+
// dispatch overwrite trigger_message_id after a newer one and reorder
|
|
58
|
+
// input steps); different chats still run concurrently. Page order is
|
|
59
|
+
// FIFO, so per-chat envelope order is preserved. A failed dispatch
|
|
60
|
+
// resolves to [] and stays pending for the next sweep.
|
|
61
|
+
//
|
|
62
|
+
// channel_message dispatches carry no chat_id and their conversation
|
|
63
|
+
// (the session key) is only known after fetching the message — so they
|
|
64
|
+
// all share ONE sequential chain: per-conversation order is preserved
|
|
65
|
+
// at the cost of serializing across conversations, which is fine for
|
|
66
|
+
// the small external-IM backlogs a reconnect sweep sees.
|
|
67
|
+
const byChat = new Map();
|
|
68
|
+
const unkeyed = [];
|
|
69
|
+
for (const d of body?.data ?? []) {
|
|
70
|
+
const cid = String(d.source_type ?? '') === 'channel_message'
|
|
71
|
+
? CHANNEL_SWEEP_CHAIN
|
|
72
|
+
: d.chat_id
|
|
73
|
+
? String(d.chat_id)
|
|
74
|
+
: '';
|
|
75
|
+
if (!cid) {
|
|
76
|
+
unkeyed.push(d);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const group = byChat.get(cid);
|
|
80
|
+
if (group)
|
|
81
|
+
group.push(d);
|
|
82
|
+
else
|
|
83
|
+
byChat.set(cid, [d]);
|
|
84
|
+
}
|
|
85
|
+
const runOne = (d) => effectsForDispatch(d, ctx).catch((err) => {
|
|
86
|
+
console.error('[parel-channel] catch-up dispatch failed', err);
|
|
87
|
+
return [];
|
|
88
|
+
});
|
|
89
|
+
const chains = [...byChat.values()].map(async (group) => {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const d of group)
|
|
92
|
+
out.push(...(await runOne(d)));
|
|
93
|
+
return out;
|
|
94
|
+
});
|
|
95
|
+
const pageEffects = await Promise.all([...chains, ...unkeyed.map(runOne)]);
|
|
96
|
+
effects.push(...pageEffects.flat());
|
|
97
|
+
if (!body?.next_cursor)
|
|
98
|
+
break;
|
|
99
|
+
cursor = body.next_cursor;
|
|
100
|
+
}
|
|
101
|
+
if (page === maxPages) {
|
|
102
|
+
console.warn('[parel-channel] catch-up hit the page ceiling; remainder deferred to the next reconnect sweep');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
// Catch-up is best-effort: keep whatever pages completed and never fail
|
|
107
|
+
// the connection open over it.
|
|
108
|
+
console.error('[parel-channel] dispatch catch-up failed', err);
|
|
109
|
+
}
|
|
110
|
+
return effects;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Synthetic sweep group key for channel_message dispatches (no chat_id).
|
|
114
|
+
* Chat ids are cht_-prefixed nanoids, so this cannot collide with one.
|
|
115
|
+
*/
|
|
116
|
+
const CHANNEL_SWEEP_CHAIN = 'sweep:channel_message';
|
|
117
|
+
async function effectsForDispatch(data, ctx) {
|
|
118
|
+
const sourceType = String(data.source_type ?? '');
|
|
119
|
+
const sourceId = data.source_id ? String(data.source_id) : undefined;
|
|
120
|
+
const chatId = data.chat_id ? String(data.chat_id) : undefined;
|
|
121
|
+
const dispatchId = String(data.id ?? '');
|
|
122
|
+
if (sourceType === 'channel_message' && sourceId) {
|
|
123
|
+
// External IM message (chat_id is NULL by design — the conversation
|
|
124
|
+
// lives on the provider, not in a Parall chat).
|
|
125
|
+
return effectsForChannelDispatch(sourceId, dispatchId, ctx);
|
|
126
|
+
}
|
|
127
|
+
// Non-message dispatches (task_assign, schedule.fire, …) stay PENDING on
|
|
128
|
+
// purpose: they are meaningful work a future connector version will handle,
|
|
129
|
+
// and acking would silently drop it. Message dispatches that can NEVER
|
|
130
|
+
// become processable are acked-and-dropped below instead, so the pending
|
|
131
|
+
// queue (and the reconnect catch-up sweep) is bounded by real future work.
|
|
132
|
+
if (sourceType !== 'message' || !sourceId)
|
|
133
|
+
return [];
|
|
134
|
+
if (!chatId) {
|
|
135
|
+
// A message dispatch with no chat can never be routed or replied to.
|
|
136
|
+
return [ackEffect(ctx, sourceType, sourceId)];
|
|
137
|
+
}
|
|
138
|
+
const apiUrl = parallApiUrl(ctx);
|
|
139
|
+
const agk = requireAgk(ctx);
|
|
140
|
+
const orgId = parallOrgId(ctx);
|
|
141
|
+
// Pull the source message text (global by-id read; ACL = chat membership).
|
|
142
|
+
const m = await fetch(`${apiUrl}/api/v1/messages/${sourceId}`, {
|
|
143
|
+
headers: { Authorization: `Bearer ${agk}` },
|
|
144
|
+
signal: AbortSignal.timeout(10_000),
|
|
145
|
+
});
|
|
146
|
+
if (!m.ok) {
|
|
147
|
+
if (m.status === 404 || m.status === 403) {
|
|
148
|
+
// Deleted message / lost chat access — permanently unusable, drop it.
|
|
149
|
+
return [ackEffect(ctx, sourceType, sourceId)];
|
|
150
|
+
}
|
|
151
|
+
// Transient failure: keep pending, the catch-up sweep retries.
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
const msg = (await m.json().catch(() => null));
|
|
155
|
+
const text = msg?.content?.text ?? '';
|
|
156
|
+
// A thread reply anchors the agent's answer to the same thread.
|
|
157
|
+
const threadRootId = msg?.thread_root_id || undefined;
|
|
158
|
+
if (!text) {
|
|
159
|
+
// Attachment-only / empty-text messages never grow text — MVP has no
|
|
160
|
+
// attachment path, so ack-and-drop rather than let them pile up in the
|
|
161
|
+
// pending queue forever.
|
|
162
|
+
return [ackEffect(ctx, sourceType, sourceId)];
|
|
163
|
+
}
|
|
164
|
+
// Progress reporting (best-effort, parall-runtime-indicator-plan.md §3):
|
|
165
|
+
// received lights the pending ring, the active+trigger patch arms the done
|
|
166
|
+
// signal, the input step flips the ring to processing. received MUST settle
|
|
167
|
+
// before this function returns — the ack effect below moves the dispatch
|
|
168
|
+
// row out of 'pending', and MarkReceived only transitions pending rows.
|
|
169
|
+
// It is deliberately NOT gated on the session resolving: it only touches
|
|
170
|
+
// the dispatch row, and a transient session failure must not also cost the
|
|
171
|
+
// pending ring (a later successful turn's idle clears agent-level pendings).
|
|
172
|
+
const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
|
|
173
|
+
const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
|
|
174
|
+
let sessionId = await ensureSession(ctx, chatId);
|
|
175
|
+
if (sessionId) {
|
|
176
|
+
// The guard marker goes down BEFORE any session write: a stale
|
|
177
|
+
// turn_completed for the PREVIOUS turn (outbox retry, late event) checks
|
|
178
|
+
// turnEnvelope to decide whether its idle is still safe — if the active
|
|
179
|
+
// patch below could land first, that event would still see the old
|
|
180
|
+
// envelope id and idle the session out from under this message.
|
|
181
|
+
// (Residual read-then-patch overlap is zero when the host runs a
|
|
182
|
+
// connection's hooks serially, per the C3 contract / C3b confirm item.)
|
|
183
|
+
await ctx.store?.set(turnEnvelopeKey(chatId), envelopeId).catch(() => { });
|
|
184
|
+
const report = (sid) => Promise.all([
|
|
185
|
+
patchSession(ctx, sid, { status: 'active', trigger_message_id: sourceId }),
|
|
186
|
+
createInputStep(ctx, sid, {
|
|
187
|
+
chatId,
|
|
188
|
+
messageId: sourceId,
|
|
189
|
+
senderId: msg?.sender_id ? String(msg.sender_id) : undefined,
|
|
190
|
+
text,
|
|
191
|
+
}),
|
|
192
|
+
]);
|
|
193
|
+
const results = await report(sessionId);
|
|
194
|
+
if (results.includes('stale')) {
|
|
195
|
+
// The cached session was closed under us (e.g. New Session). The
|
|
196
|
+
// get-or-create falls through terminal rows, so one rebuild + retry
|
|
197
|
+
// repairs reporting for this and every later dispatch.
|
|
198
|
+
await invalidateSession(ctx, chatId);
|
|
199
|
+
const fresh = await ensureSession(ctx, chatId);
|
|
200
|
+
if (fresh && fresh !== sessionId) {
|
|
201
|
+
await report(fresh);
|
|
202
|
+
sessionId = fresh;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
await receivedSettled;
|
|
207
|
+
// Per-turn invocation context: parel snapshots this at turn start and
|
|
208
|
+
// consume-gated plugins receive it — sandbox-e2b flattens it into per-exec
|
|
209
|
+
// env, so `parall` CLI calls inside the sandbox land on the right chat.
|
|
210
|
+
const invocationContext = {
|
|
211
|
+
PRLL_CHAT_ID: chatId,
|
|
212
|
+
PRLL_TRIGGER_MESSAGE_ID: sourceId,
|
|
213
|
+
...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
|
|
214
|
+
};
|
|
215
|
+
const effects = [
|
|
216
|
+
{
|
|
217
|
+
type: 'emitEvent',
|
|
218
|
+
event: {
|
|
219
|
+
id: envelopeId,
|
|
220
|
+
orgId,
|
|
221
|
+
connectionId: ctx.connectionId,
|
|
222
|
+
source: 'parall',
|
|
223
|
+
type: 'message',
|
|
224
|
+
// per_subject routing keys the parel session by subject → one parel
|
|
225
|
+
// session per Parall chat (mirrors Parall's chat-scoped conversations).
|
|
226
|
+
subject: chatId,
|
|
227
|
+
data: { text, chatId, messageId: sourceId, threadRootId },
|
|
228
|
+
// chat id + thread anchor ride replyRoute.data so deliver() knows
|
|
229
|
+
// where to post back (same shape channel-slack-socket uses for its
|
|
230
|
+
// channel id).
|
|
231
|
+
replyRoute: {
|
|
232
|
+
kind: 'provider_http',
|
|
233
|
+
connectionId: ctx.connectionId,
|
|
234
|
+
// envelopeId lets deliver() run the same staleness guard as
|
|
235
|
+
// onAgentEvent against the shared turnEnvelope marker.
|
|
236
|
+
data: { chatId, messageId: sourceId, threadRootId, envelopeId },
|
|
237
|
+
},
|
|
238
|
+
context: invocationContext,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
ackEffect(ctx, sourceType, sourceId),
|
|
242
|
+
];
|
|
243
|
+
return effects;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* channel_message dispatch → agent (external IM channel,
|
|
247
|
+
* external-im-channel-design.md). Same pipeline shape as the message path,
|
|
248
|
+
* with the ChannelConversation (chv_) standing in for the chat as the
|
|
249
|
+
* routing subject: fetch the durable ChannelMessage + its conversation,
|
|
250
|
+
* resolve the provider for prompt labeling (cached — the connection→provider
|
|
251
|
+
* mapping is stable), frame the prompt for the EXTERNAL audience, report
|
|
252
|
+
* progress against the conversation-keyed session, and ack by source.
|
|
253
|
+
*
|
|
254
|
+
* Error contract mirrors the message path (and agent-core's
|
|
255
|
+
* fetchAndHandleChannelMessage): 404/403 on the source entities is
|
|
256
|
+
* permanently unusable → ack-and-drop; a transient failure keeps the
|
|
257
|
+
* dispatch pending for the catch-up sweep.
|
|
258
|
+
*/
|
|
259
|
+
async function effectsForChannelDispatch(sourceId, dispatchId, ctx) {
|
|
260
|
+
const apiUrl = parallApiUrl(ctx);
|
|
261
|
+
const orgId = parallOrgId(ctx);
|
|
262
|
+
const authHeaders = { Authorization: `Bearer ${requireAgk(ctx)}` };
|
|
263
|
+
const get = (path, timeoutMs = REQUEST_TIMEOUT_MS) => fetch(`${apiUrl}/api/v1/orgs/${orgId}${path}`, {
|
|
264
|
+
headers: authHeaders,
|
|
265
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
266
|
+
});
|
|
267
|
+
const m = await get(`/channel-messages/${sourceId}`);
|
|
268
|
+
if (!m.ok) {
|
|
269
|
+
if (m.status === 404 || m.status === 403) {
|
|
270
|
+
// Deleted / inaccessible (also the shape a disabled org feature flag
|
|
271
|
+
// takes) — permanently unusable, drop it.
|
|
272
|
+
return [ackEffect(ctx, 'channel_message', sourceId)];
|
|
273
|
+
}
|
|
274
|
+
return []; // transient: keep pending, the catch-up sweep retries
|
|
275
|
+
}
|
|
276
|
+
const msg = (await m.json().catch(() => null));
|
|
277
|
+
const conversationId = msg?.conversation_id ? String(msg.conversation_id) : undefined;
|
|
278
|
+
const text = msg?.text ?? '';
|
|
279
|
+
if (!conversationId) {
|
|
280
|
+
// No conversation → no session key and no reply target: unusable.
|
|
281
|
+
return [ackEffect(ctx, 'channel_message', sourceId)];
|
|
282
|
+
}
|
|
283
|
+
if (!text) {
|
|
284
|
+
// The ingress adapter only persists mechanically parsed text; a row with
|
|
285
|
+
// none will never grow any (same reasoning as attachment-only chat
|
|
286
|
+
// messages) — drop rather than let it pile up in the pending queue.
|
|
287
|
+
return [ackEffect(ctx, 'channel_message', sourceId)];
|
|
288
|
+
}
|
|
289
|
+
const c = await get(`/channel-conversations/${conversationId}`);
|
|
290
|
+
if (!c.ok) {
|
|
291
|
+
if (c.status === 404 || c.status === 403) {
|
|
292
|
+
return [ackEffect(ctx, 'channel_message', sourceId)];
|
|
293
|
+
}
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
const conv = (await c.json().catch(() => null));
|
|
297
|
+
// Provider (= clip alias) for prompt labeling + the reply hint.
|
|
298
|
+
// Best-effort: an unresolved provider degrades the wording, never blocks.
|
|
299
|
+
const connectionId = conv?.connection_id ? String(conv.connection_id) : undefined;
|
|
300
|
+
const provider = connectionId ? await resolveChannelProvider(ctx, connectionId, get) : undefined;
|
|
301
|
+
const senderName = msg?.external_user_name || msg?.external_user_id || 'external user';
|
|
302
|
+
const externalConversationId = conv?.external_conversation_id
|
|
303
|
+
? String(conv.external_conversation_id)
|
|
304
|
+
: undefined;
|
|
305
|
+
const externalMessageId = msg?.external_message_id ? String(msg.external_message_id) : undefined;
|
|
306
|
+
// Progress reporting — identical ordering contract to the message path
|
|
307
|
+
// (see the comments there): received settles before the ack effect is
|
|
308
|
+
// returned; the turnEnvelope marker goes down before any session write.
|
|
309
|
+
const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
|
|
310
|
+
const envelopeId = dispatchId || `channel_message:${sourceId}`;
|
|
311
|
+
let sessionId = await ensureSession(ctx, conversationId);
|
|
312
|
+
if (sessionId) {
|
|
313
|
+
await ctx.store?.set(turnEnvelopeKey(conversationId), envelopeId).catch(() => { });
|
|
314
|
+
const report = (sid) => Promise.all([
|
|
315
|
+
patchSession(ctx, sid, { status: 'active', trigger_message_id: sourceId }),
|
|
316
|
+
createChannelInputStep(ctx, sid, {
|
|
317
|
+
conversationId,
|
|
318
|
+
channelMessageId: sourceId,
|
|
319
|
+
provider,
|
|
320
|
+
externalConversationId,
|
|
321
|
+
senderId: msg?.external_user_id ? String(msg.external_user_id) : 'external',
|
|
322
|
+
senderName,
|
|
323
|
+
text,
|
|
324
|
+
sentAt: msg?.received_at ? String(msg.received_at) : undefined,
|
|
325
|
+
}),
|
|
326
|
+
]);
|
|
327
|
+
const results = await report(sessionId);
|
|
328
|
+
if (results.includes('stale')) {
|
|
329
|
+
await invalidateSession(ctx, conversationId);
|
|
330
|
+
const fresh = await ensureSession(ctx, conversationId);
|
|
331
|
+
if (fresh && fresh !== sessionId) {
|
|
332
|
+
await report(fresh);
|
|
333
|
+
sessionId = fresh;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
|
|
337
|
+
// conversation into its session; last-write-wins on the server). Only on
|
|
338
|
+
// change — the store remembers what was last pushed — and fully
|
|
339
|
+
// fire-and-forget: pure bookkeeping must never add latency to the
|
|
340
|
+
// emit/ack path (a lost attempt is retried on the next message because
|
|
341
|
+
// the store memo only updates on success).
|
|
342
|
+
void recordConversationSession(ctx, conversationId, sessionId).catch(() => { });
|
|
343
|
+
}
|
|
344
|
+
await receivedSettled;
|
|
345
|
+
return [
|
|
346
|
+
{
|
|
347
|
+
type: 'emitEvent',
|
|
348
|
+
event: {
|
|
349
|
+
id: envelopeId,
|
|
350
|
+
orgId,
|
|
351
|
+
connectionId: ctx.connectionId,
|
|
352
|
+
source: 'parall',
|
|
353
|
+
// The host's message intake path turns data.text into the user
|
|
354
|
+
// message — the external-audience framing rides inside it (parel has
|
|
355
|
+
// no separate prompt channel), so 'message' is the right type here.
|
|
356
|
+
type: 'message',
|
|
357
|
+
// per_subject routing keys the parel session by subject → one parel
|
|
358
|
+
// session per external conversation (mirrors chv_ ↔ session 1:1 in
|
|
359
|
+
// the design).
|
|
360
|
+
subject: conversationId,
|
|
361
|
+
data: {
|
|
362
|
+
text: buildChannelPrompt({
|
|
363
|
+
provider,
|
|
364
|
+
conversationType: conv?.conversation_type ? String(conv.conversation_type) : undefined,
|
|
365
|
+
externalConversationId,
|
|
366
|
+
externalMessageId,
|
|
367
|
+
senderName,
|
|
368
|
+
text,
|
|
369
|
+
}),
|
|
370
|
+
conversationId,
|
|
371
|
+
channelMessageId: sourceId,
|
|
372
|
+
provider,
|
|
373
|
+
externalConversationId,
|
|
374
|
+
externalMessageId,
|
|
375
|
+
},
|
|
376
|
+
// No chatId on purpose: the reply goes out through the provider clip
|
|
377
|
+
// (agent-invoked), NOT deliver() — deliver only runs the redundant
|
|
378
|
+
// session idle for channel routes (see delivery.ts).
|
|
379
|
+
replyRoute: {
|
|
380
|
+
kind: 'provider_http',
|
|
381
|
+
connectionId: ctx.connectionId,
|
|
382
|
+
data: { conversationId, envelopeId },
|
|
383
|
+
},
|
|
384
|
+
// No invocation context: PRLL_CHAT_ID must not point CLI defaults at
|
|
385
|
+
// a chat that doesn't exist, and the reply targets (external ids)
|
|
386
|
+
// already ride the prompt verbatim.
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
ackEffect(ctx, 'channel_message', sourceId),
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* connection id → provider alias, cached in the durable store (stable
|
|
394
|
+
* mapping; avoids one connection fetch per inbound message). Cache hits are
|
|
395
|
+
* a store read; the miss-path fetch is cosmetic (prompt labeling only), so
|
|
396
|
+
* it gets a tight timeout — same 3s budget as deliver's front-of-reply
|
|
397
|
+
* session lookup — rather than holding the message's emit/ack behind the
|
|
398
|
+
* full REQUEST_TIMEOUT_MS. Any failure returns undefined — prompt wording
|
|
399
|
+
* degrades to neutral rather than inventing a clip alias.
|
|
400
|
+
*/
|
|
401
|
+
const PROVIDER_LOOKUP_TIMEOUT_MS = 3_000;
|
|
402
|
+
async function resolveChannelProvider(ctx, connectionId, get) {
|
|
403
|
+
const cacheKey = `channelProvider:${connectionId}`;
|
|
404
|
+
const cached = await ctx.store?.get(cacheKey).catch(() => null);
|
|
405
|
+
if (typeof cached === 'string' && cached)
|
|
406
|
+
return cached;
|
|
407
|
+
try {
|
|
408
|
+
const res = await get(`/channel-connections/${connectionId}`, PROVIDER_LOOKUP_TIMEOUT_MS);
|
|
409
|
+
if (!res.ok)
|
|
410
|
+
return undefined;
|
|
411
|
+
const body = (await res.json().catch(() => null));
|
|
412
|
+
const provider = body?.provider ? String(body.provider) : undefined;
|
|
413
|
+
if (provider)
|
|
414
|
+
await ctx.store?.set(cacheKey, provider).catch(() => { });
|
|
415
|
+
return provider;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** Push the chv_ → ase_ pointer when it changed; warn-only on failure. */
|
|
422
|
+
async function recordConversationSession(ctx, conversationId, sessionId) {
|
|
423
|
+
const mappedKey = `convSession:${conversationId}`;
|
|
424
|
+
const mapped = await ctx.store?.get(mappedKey).catch(() => null);
|
|
425
|
+
if (mapped === sessionId)
|
|
426
|
+
return;
|
|
427
|
+
try {
|
|
428
|
+
const res = await fetch(`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/channel-conversations/${conversationId}/session`, {
|
|
429
|
+
method: 'PATCH',
|
|
430
|
+
headers: {
|
|
431
|
+
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
432
|
+
'Content-Type': 'application/json',
|
|
433
|
+
},
|
|
434
|
+
body: JSON.stringify({ agent_session_id: sessionId }),
|
|
435
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
436
|
+
});
|
|
437
|
+
if (!res.ok) {
|
|
438
|
+
console.warn(`[parel-channel] conversation session mapping failed (${res.status})`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
await ctx.store?.set(mappedKey, sessionId).catch(() => { });
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
console.warn('[parel-channel] conversation session mapping failed', err);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Agent execution events (parel E1+E2, plugin-sdk 0.9.0, opt-in via the
|
|
449
|
+
* channel declaration's `observe: [turn, steps]`).
|
|
450
|
+
*
|
|
451
|
+
* E1 (turn lifecycle): turn_completed fires unconditionally on every cleanly
|
|
452
|
+
* finished turn — INCLUDING no-reply turns (hadOutput: false), the reliable
|
|
453
|
+
* "processing ended" signal that used to need a 30min turnwatch timer.
|
|
454
|
+
* turn_failed closes the turn the same way.
|
|
455
|
+
*
|
|
456
|
+
* E2 (step trace): model_reasoning / tool_call / tool_result become parall
|
|
457
|
+
* AgentSteps — the session panel gets the same live execution feed CC agents
|
|
458
|
+
* have (reasoning is aggregated host-side; tool output is a bounded preview).
|
|
459
|
+
*
|
|
460
|
+
* Events are best-effort (display feed, never data-correctness): a missed
|
|
461
|
+
* done event just means the ring clears on the next turn's trigger change; a
|
|
462
|
+
* missed trace step is a gap in the panel, nothing more.
|
|
463
|
+
*/
|
|
464
|
+
export async function handleParallAgentEvent(event, ctx) {
|
|
465
|
+
const chatId = event.subject;
|
|
466
|
+
if (!chatId)
|
|
467
|
+
return [];
|
|
468
|
+
try {
|
|
469
|
+
if (event.type === 'turn_completed' || event.type === 'turn_failed') {
|
|
470
|
+
// Staleness guard: only idle if this event's turn is still the chat's
|
|
471
|
+
// latest emitted envelope — a late/retried completed event for a
|
|
472
|
+
// superseded turn must not clear the newer turn's indicator. Fail-open
|
|
473
|
+
// when the marker is unavailable (a stuck ring is worse than a blink).
|
|
474
|
+
const current = await ctx.store?.get(turnEnvelopeKey(chatId)).catch(() => null);
|
|
475
|
+
if (typeof current === 'string' && current && !event.envelopeIds.includes(current)) {
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
const sessionId = await ensureSession(ctx, chatId);
|
|
479
|
+
if (sessionId) {
|
|
480
|
+
const result = await patchSession(ctx, sessionId, { status: 'idle' });
|
|
481
|
+
// A closed session is already terminal (frontend shows done) — just
|
|
482
|
+
// drop the cache so the next dispatch resolves a fresh one.
|
|
483
|
+
if (result === 'stale')
|
|
484
|
+
await invalidateSession(ctx, chatId);
|
|
485
|
+
}
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
if (event.type === 'model_reasoning' ||
|
|
489
|
+
event.type === 'tool_call' ||
|
|
490
|
+
event.type === 'tool_result') {
|
|
491
|
+
const sessionId = await ensureSession(ctx, chatId);
|
|
492
|
+
if (sessionId) {
|
|
493
|
+
const result = await createTraceStep(ctx, sessionId, chatId, event);
|
|
494
|
+
// Dropped step is fine (display feed); uncache so the NEXT event
|
|
495
|
+
// resolves a live session instead of failing forever.
|
|
496
|
+
if (result === 'stale')
|
|
497
|
+
await invalidateSession(ctx, chatId);
|
|
498
|
+
}
|
|
499
|
+
return [];
|
|
500
|
+
}
|
|
501
|
+
// turn_started (processing is input-step-driven) and execution_paused
|
|
502
|
+
// (E3 — no resolve loop wired yet, HITL deliberately deferred) are
|
|
503
|
+
// ignored; observe only requests [turn, steps].
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
console.error('[parel-channel] agent event handling failed', err);
|
|
507
|
+
}
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
/** Ack a dispatch by source so it leaves the pending queue (idempotent). */
|
|
511
|
+
function ackEffect(ctx, sourceType, sourceId) {
|
|
512
|
+
return {
|
|
513
|
+
type: 'fetch',
|
|
514
|
+
request: {
|
|
515
|
+
url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/ack`,
|
|
516
|
+
method: 'POST',
|
|
517
|
+
headers: {
|
|
518
|
+
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
519
|
+
'Content-Type': 'application/json',
|
|
520
|
+
},
|
|
521
|
+
body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
|
|
522
|
+
},
|
|
523
|
+
idempotencyKey: `ack:${sourceType}:${sourceId}`,
|
|
524
|
+
};
|
|
525
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChannelConnector } from '@parel/plugin-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Parall channel plugin for the parel runtime.
|
|
4
|
+
*
|
|
5
|
+
* Lets a parel agent treat Parall as a `managed_ws` channel — mirroring what
|
|
6
|
+
* channel-slack-socket does for Slack. The parel runtime owns the connection
|
|
7
|
+
* lifecycle + agent routing; this connector just supplies the three hooks:
|
|
8
|
+
*
|
|
9
|
+
* connect() → the Parall gateway WS url (authenticated by the agent's agk_)
|
|
10
|
+
* onMessage() → translate a Parall dispatch frame into a ChannelEnvelope
|
|
11
|
+
* deliver() → post the agent's reply back to the Parall chat via the API
|
|
12
|
+
*
|
|
13
|
+
* This is why Parall needs NO server-side worker/bridge: the integration lives
|
|
14
|
+
* here, runs inside parel, and is service-to-service from parel's side.
|
|
15
|
+
*/
|
|
16
|
+
declare const connector: ChannelConnector;
|
|
17
|
+
export default connector;
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAK1D;;;;;;;;;;;;;GAaG;AACH,QAAA,MAAM,SAAS,EAAE,gBAMhB,CAAC;AAEF,eAAe,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { connectParall } from './connect.js';
|
|
2
|
+
import { buildParallDelivery } from './delivery.js';
|
|
3
|
+
import { catchUpParall, handleParallAgentEvent, handleParallFrame } from './inbound.js';
|
|
4
|
+
/**
|
|
5
|
+
* Parall channel plugin for the parel runtime.
|
|
6
|
+
*
|
|
7
|
+
* Lets a parel agent treat Parall as a `managed_ws` channel — mirroring what
|
|
8
|
+
* channel-slack-socket does for Slack. The parel runtime owns the connection
|
|
9
|
+
* lifecycle + agent routing; this connector just supplies the three hooks:
|
|
10
|
+
*
|
|
11
|
+
* connect() → the Parall gateway WS url (authenticated by the agent's agk_)
|
|
12
|
+
* onMessage() → translate a Parall dispatch frame into a ChannelEnvelope
|
|
13
|
+
* deliver() → post the agent's reply back to the Parall chat via the API
|
|
14
|
+
*
|
|
15
|
+
* This is why Parall needs NO server-side worker/bridge: the integration lives
|
|
16
|
+
* here, runs inside parel, and is service-to-service from parel's side.
|
|
17
|
+
*/
|
|
18
|
+
const connector = {
|
|
19
|
+
connect: connectParall,
|
|
20
|
+
onOpen: catchUpParall,
|
|
21
|
+
onMessage: handleParallFrame,
|
|
22
|
+
onAgentEvent: handleParallAgentEvent,
|
|
23
|
+
deliver: buildParallDelivery,
|
|
24
|
+
};
|
|
25
|
+
export default connector;
|