@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/src/session.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { ConnectorContext } from '@parel/plugin-sdk';
|
|
2
|
+
import { parallAgentId, parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort progress reporting: dispatch received, AgentSession lifecycle,
|
|
6
|
+
* and input steps — the signals parall's message-level activity indicator and
|
|
7
|
+
* session panel are built on (see parall-runtime-indicator-plan.md §3).
|
|
8
|
+
*
|
|
9
|
+
* Every function catches its own failures and returns a degraded value: the
|
|
10
|
+
* message pipeline (emit / reply) must never block on indicator reporting.
|
|
11
|
+
*
|
|
12
|
+
* Session identity is deterministic — (runtime_type='parel',
|
|
13
|
+
* runtime_session_id=subject), where the subject is the envelope routing key:
|
|
14
|
+
* a chat id (cht_) for Parall chats, or a channel conversation id (chv_) for
|
|
15
|
+
* external IM conversations. per_subject routing already keys one parel
|
|
16
|
+
* session per subject, so the parall-side session mirrors it 1:1 without the
|
|
17
|
+
* connector ever knowing parel's internal session id.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
/** Reporting needs the agent id in the endpoint path; old configs lack it. */
|
|
23
|
+
export function reportingEnabled(ctx: ConnectorContext): boolean {
|
|
24
|
+
return parallAgentId(ctx) !== '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The subject's most recently emitted envelope id — the staleness guard for
|
|
29
|
+
* turn-level done signals: a turn_completed/failed whose envelopeIds don't
|
|
30
|
+
* include the current value belongs to a superseded turn and must not idle
|
|
31
|
+
* the session out from under the newer one.
|
|
32
|
+
*/
|
|
33
|
+
export const turnEnvelopeKey = (subject: string) => `turnEnvelope:${subject}`;
|
|
34
|
+
const sessionKey = (subject: string) => `session:${subject}`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 'stale' means the session id no longer accepts writes (closed/superseded —
|
|
38
|
+
* e.g. the New Session control ended it): callers invalidate the cache and
|
|
39
|
+
* may retry once against a fresh get-or-create, which falls through terminal
|
|
40
|
+
* rows and creates a new session server-side.
|
|
41
|
+
*/
|
|
42
|
+
export type ReportResult = 'ok' | 'stale' | 'failed';
|
|
43
|
+
|
|
44
|
+
function resultForStatus(status: number): ReportResult {
|
|
45
|
+
return status === 404 || status === 409 || status === 410 ? 'stale' : 'failed';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Drop the cached session id so the next ensureSession re-resolves it. */
|
|
49
|
+
export async function invalidateSession(ctx: ConnectorContext, subject: string): Promise<void> {
|
|
50
|
+
await ctx.store?.delete(sessionKey(subject)).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function jsonHeaders(ctx: ConnectorContext): Record<string, string> {
|
|
54
|
+
return {
|
|
55
|
+
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get-or-create the subject's parall AgentSession (ase_), cached in the
|
|
62
|
+
* durable per-connection store. Returns null when reporting is disabled or
|
|
63
|
+
* the call fails — callers skip their report and move on. Callers on a
|
|
64
|
+
* latency-sensitive path (deliver blocks the user-visible reply on this) pass
|
|
65
|
+
* a shorter timeoutMs; the cache-hit fast path is a store read either way.
|
|
66
|
+
*/
|
|
67
|
+
export async function ensureSession(
|
|
68
|
+
ctx: ConnectorContext,
|
|
69
|
+
subject: string,
|
|
70
|
+
opts: { timeoutMs?: number } = {},
|
|
71
|
+
): Promise<string | null> {
|
|
72
|
+
if (!reportingEnabled(ctx)) return null;
|
|
73
|
+
const cached = await ctx.store?.get(sessionKey(subject)).catch(() => null);
|
|
74
|
+
if (typeof cached === 'string' && cached) return cached;
|
|
75
|
+
try {
|
|
76
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions`;
|
|
77
|
+
const res = await fetch(url, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: jsonHeaders(ctx),
|
|
80
|
+
body: JSON.stringify({ runtime_type: 'parel', runtime_session_id: subject }),
|
|
81
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
console.error(`[parel-channel] session get-or-create failed (${res.status})`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const body = (await res.json().catch(() => null)) as { id?: string } | null;
|
|
88
|
+
const id = body?.id;
|
|
89
|
+
if (!id) return null;
|
|
90
|
+
await ctx.store?.set(sessionKey(subject), id).catch(() => {});
|
|
91
|
+
return id;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error('[parel-channel] session get-or-create failed', err);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** PATCH the session (status / trigger_message_id). Never throws. */
|
|
99
|
+
export async function patchSession(
|
|
100
|
+
ctx: ConnectorContext,
|
|
101
|
+
sessionId: string,
|
|
102
|
+
body: { status: 'active' | 'idle'; trigger_message_id?: string },
|
|
103
|
+
): Promise<ReportResult> {
|
|
104
|
+
try {
|
|
105
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}`;
|
|
106
|
+
const res = await fetch(url, {
|
|
107
|
+
method: 'PATCH',
|
|
108
|
+
headers: jsonHeaders(ctx),
|
|
109
|
+
body: JSON.stringify(body),
|
|
110
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
111
|
+
});
|
|
112
|
+
if (res.ok) return 'ok';
|
|
113
|
+
console.error(`[parel-channel] session patch failed (${res.status})`);
|
|
114
|
+
return resultForStatus(res.status);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error('[parel-channel] session patch failed', err);
|
|
117
|
+
return 'failed';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The input step is what flips the frontend indicator from pending to
|
|
123
|
+
* processing — target_type/target_id route the agent_step.new broadcast to
|
|
124
|
+
* messages:{chatId}, and content.trigger_ref.message_id is the exact match
|
|
125
|
+
* key (both mandatory; the server only publishes when a target is set).
|
|
126
|
+
*/
|
|
127
|
+
export async function createInputStep(
|
|
128
|
+
ctx: ConnectorContext,
|
|
129
|
+
sessionId: string,
|
|
130
|
+
args: { chatId: string; messageId: string; senderId?: string; text: string },
|
|
131
|
+
): Promise<ReportResult> {
|
|
132
|
+
try {
|
|
133
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
134
|
+
const res = await fetch(url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: jsonHeaders(ctx),
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
step_type: 'input',
|
|
139
|
+
target_type: 'chat',
|
|
140
|
+
target_id: args.chatId,
|
|
141
|
+
// Session-scoped dedup: an ack failure leaves the dispatch replayable
|
|
142
|
+
// by the reconnect catch-up sweep — without this key every replay
|
|
143
|
+
// would insert and broadcast another input step for the same message.
|
|
144
|
+
idempotency_key: `input:${args.messageId}`,
|
|
145
|
+
content: {
|
|
146
|
+
trigger_type: 'mention',
|
|
147
|
+
trigger_ref: { message_id: args.messageId },
|
|
148
|
+
...(args.senderId ? { sender_id: args.senderId } : {}),
|
|
149
|
+
summary: args.text.slice(0, 200),
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
153
|
+
});
|
|
154
|
+
if (res.ok) return 'ok';
|
|
155
|
+
console.error(`[parel-channel] input step failed (${res.status})`);
|
|
156
|
+
return resultForStatus(res.status);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error('[parel-channel] input step failed', err);
|
|
159
|
+
return 'failed';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Input step for a channel_message dispatch (external IM). Shaped exactly
|
|
165
|
+
* like agent-core's channel input step so the session panel renders both
|
|
166
|
+
* runtimes identically: target = the ChannelConversation (chv_), trigger_ref
|
|
167
|
+
* carries the channel identifiers, and the sender is the external user
|
|
168
|
+
* (a display name, not a Parall usr_).
|
|
169
|
+
*/
|
|
170
|
+
export async function createChannelInputStep(
|
|
171
|
+
ctx: ConnectorContext,
|
|
172
|
+
sessionId: string,
|
|
173
|
+
args: {
|
|
174
|
+
conversationId: string;
|
|
175
|
+
channelMessageId: string;
|
|
176
|
+
provider?: string;
|
|
177
|
+
externalConversationId?: string;
|
|
178
|
+
senderId: string;
|
|
179
|
+
senderName: string;
|
|
180
|
+
text: string;
|
|
181
|
+
sentAt?: string;
|
|
182
|
+
},
|
|
183
|
+
): Promise<ReportResult> {
|
|
184
|
+
try {
|
|
185
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
186
|
+
const res = await fetch(url, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: jsonHeaders(ctx),
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
step_type: 'input',
|
|
191
|
+
target_type: 'channel_conversation',
|
|
192
|
+
target_id: args.conversationId,
|
|
193
|
+
// Session-scoped dedup, same rationale as the chat input step: a
|
|
194
|
+
// failed ack leaves the dispatch replayable by the catch-up sweep.
|
|
195
|
+
idempotency_key: `input:${args.channelMessageId}`,
|
|
196
|
+
content: {
|
|
197
|
+
trigger_type: 'channel_message',
|
|
198
|
+
trigger_ref: {
|
|
199
|
+
conversation_id: args.conversationId,
|
|
200
|
+
channel_message_id: args.channelMessageId,
|
|
201
|
+
...(args.provider ? { provider: args.provider } : {}),
|
|
202
|
+
...(args.externalConversationId
|
|
203
|
+
? { external_conversation_id: args.externalConversationId }
|
|
204
|
+
: {}),
|
|
205
|
+
},
|
|
206
|
+
sender_id: args.senderId,
|
|
207
|
+
sender_name: args.senderName,
|
|
208
|
+
summary: args.text.slice(0, 200),
|
|
209
|
+
...(args.sentAt ? { sent_at: args.sentAt } : {}),
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
213
|
+
});
|
|
214
|
+
if (res.ok) return 'ok';
|
|
215
|
+
console.error(`[parel-channel] channel input step failed (${res.status})`);
|
|
216
|
+
return resultForStatus(res.status);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error('[parel-channel] channel input step failed', err);
|
|
219
|
+
return 'failed';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Subject-scoped: one connector serves many per-subject sessions, and
|
|
224
|
+
// provider call ids are only unique within a turn — never let two subjects'
|
|
225
|
+
// concurrent calls overwrite each other's parked name.
|
|
226
|
+
const toolNameKey = (subject: string, callId: string) => `toolName:${subject}:${callId}`;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* E2 step-trace events → parall AgentSteps, shaped exactly like agent-core's
|
|
230
|
+
* createRuntimeStep so the session panel renders them identically to CC's.
|
|
231
|
+
* tool_call/tool_result carry a session-scoped idempotency key (events are
|
|
232
|
+
* at-most-once today, but a callId key is free insurance); reasoning has no
|
|
233
|
+
* natural key and parel already aggregates it host-side. The E2 tool_result
|
|
234
|
+
* event has no tool name, so tool_call parks it in the store by callId.
|
|
235
|
+
*/
|
|
236
|
+
export async function createTraceStep(
|
|
237
|
+
ctx: ConnectorContext,
|
|
238
|
+
sessionId: string,
|
|
239
|
+
subject: string,
|
|
240
|
+
event:
|
|
241
|
+
| { type: 'model_reasoning'; text: string }
|
|
242
|
+
| { type: 'tool_call'; callId: string; name: string; input: unknown }
|
|
243
|
+
| {
|
|
244
|
+
type: 'tool_result';
|
|
245
|
+
callId: string;
|
|
246
|
+
status: 'ok' | 'error';
|
|
247
|
+
durationMs?: number;
|
|
248
|
+
outputPreview?: string;
|
|
249
|
+
},
|
|
250
|
+
): Promise<ReportResult> {
|
|
251
|
+
let body: Record<string, unknown>;
|
|
252
|
+
if (event.type === 'model_reasoning') {
|
|
253
|
+
body = { step_type: 'thinking', content: { text: event.text } };
|
|
254
|
+
} else if (event.type === 'tool_call') {
|
|
255
|
+
await ctx.store?.set(toolNameKey(subject, event.callId), event.name).catch(() => {});
|
|
256
|
+
body = {
|
|
257
|
+
step_type: 'tool_call',
|
|
258
|
+
runtime_key: event.callId,
|
|
259
|
+
idempotency_key: `tc:${event.callId}`,
|
|
260
|
+
content: {
|
|
261
|
+
call_id: event.callId,
|
|
262
|
+
tool_name: event.name,
|
|
263
|
+
tool_input: event.input,
|
|
264
|
+
status: 'running',
|
|
265
|
+
started_at: new Date(ctx.now()).toISOString(),
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
} else {
|
|
269
|
+
const parked = await ctx.store?.get(toolNameKey(subject, event.callId)).catch(() => null);
|
|
270
|
+
await ctx.store?.delete(toolNameKey(subject, event.callId)).catch(() => {});
|
|
271
|
+
body = {
|
|
272
|
+
step_type: 'tool_result',
|
|
273
|
+
idempotency_key: `tr:${event.callId}`,
|
|
274
|
+
content: {
|
|
275
|
+
call_id: event.callId,
|
|
276
|
+
// The server REQUIRES tool_name on tool_result — a missed tool_call
|
|
277
|
+
// event (best-effort feed) must degrade to a labeled unknown, not a
|
|
278
|
+
// 400-dropped step.
|
|
279
|
+
tool_name: typeof parked === 'string' && parked ? parked : 'unknown',
|
|
280
|
+
status: event.status === 'error' ? 'error' : 'success',
|
|
281
|
+
output: event.outputPreview ?? '',
|
|
282
|
+
duration_ms: event.durationMs ?? 0,
|
|
283
|
+
collapsible: true,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
289
|
+
// The subject prefix tells chat turns from external IM turns — channel
|
|
290
|
+
// steps must not target 'chat' or the server would broadcast them onto
|
|
291
|
+
// a messages:{chv_} channel no client subscribes to (agent-core's
|
|
292
|
+
// resolveStepTarget draws the same line).
|
|
293
|
+
const targetType = subject.startsWith('chv_') ? 'channel_conversation' : 'chat';
|
|
294
|
+
const res = await fetch(url, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: jsonHeaders(ctx),
|
|
297
|
+
body: JSON.stringify({ ...body, target_type: targetType, target_id: subject }),
|
|
298
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
299
|
+
});
|
|
300
|
+
if (res.ok) return 'ok';
|
|
301
|
+
console.error(`[parel-channel] trace step failed (${res.status})`);
|
|
302
|
+
return resultForStatus(res.status);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error('[parel-channel] trace step failed', err);
|
|
305
|
+
return 'failed';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Lights the pending ring (server broadcasts dispatch.received). Must COMPLETE
|
|
311
|
+
* before the ack effect is returned: MarkReceived only transitions rows still
|
|
312
|
+
* in status='pending', and ack moves the same row to 'acked' — losing the race
|
|
313
|
+
* means the ring never appears.
|
|
314
|
+
*/
|
|
315
|
+
export async function markDispatchReceived(
|
|
316
|
+
ctx: ConnectorContext,
|
|
317
|
+
sourceType: string,
|
|
318
|
+
sourceId: string,
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
if (!reportingEnabled(ctx)) return;
|
|
321
|
+
try {
|
|
322
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/received`;
|
|
323
|
+
const res = await fetch(url, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
headers: jsonHeaders(ctx),
|
|
326
|
+
body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
|
|
327
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
328
|
+
});
|
|
329
|
+
if (!res.ok) console.error(`[parel-channel] mark received failed (${res.status})`);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
console.error('[parel-channel] mark received failed', err);
|
|
332
|
+
}
|
|
333
|
+
}
|