@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/delivery.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ChannelDelivery, ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
|
|
2
|
+
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
3
|
+
import {
|
|
4
|
+
ensureSession,
|
|
5
|
+
invalidateSession,
|
|
6
|
+
patchSession,
|
|
7
|
+
REQUEST_TIMEOUT_MS,
|
|
8
|
+
turnEnvelopeKey,
|
|
9
|
+
} from './session.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Post the agent's reply back to the Parall chat. The chat id rides the
|
|
13
|
+
* replyRoute.data set in inbound.ts.
|
|
14
|
+
*
|
|
15
|
+
* Contract (confirmed against parel #104): provider_http reply routes are
|
|
16
|
+
* auto-delivered at turn end via this hook; `payload` is the plain reply text
|
|
17
|
+
* string and `replyRoute` is the one captured from the originating envelope.
|
|
18
|
+
*
|
|
19
|
+
* The reply POST is awaited (not returned as a host fetch effect) so the
|
|
20
|
+
* session link resolution and failure handling stay in one place; a
|
|
21
|
+
* retryable failure falls back to the host effect (at-least-once, idempotent
|
|
22
|
+
* by delivery id + message idempotency_key). The turn's PRIMARY done signal
|
|
23
|
+
* lives in onAgentEvent (turn_completed); the idle here is a deliberate
|
|
24
|
+
* redundant close for bindings without `observe: [turn]` — see the inline
|
|
25
|
+
* comment at the bottom.
|
|
26
|
+
*
|
|
27
|
+
* External IM turns (channel_message dispatches) route here too but deliver
|
|
28
|
+
* NOTHING: their replyRoute carries a conversationId and no chatId, and the
|
|
29
|
+
* agent replies through the provider clip itself — this hook only runs the
|
|
30
|
+
* same redundant session idle for them.
|
|
31
|
+
*/
|
|
32
|
+
export async function buildParallDelivery(
|
|
33
|
+
delivery: ChannelDelivery,
|
|
34
|
+
ctx: ConnectorContext,
|
|
35
|
+
): Promise<ConnectorEffect[]> {
|
|
36
|
+
const apiUrl = parallApiUrl(ctx);
|
|
37
|
+
const agk = requireAgk(ctx);
|
|
38
|
+
const orgId = parallOrgId(ctx);
|
|
39
|
+
const route = (delivery.replyRoute?.data ?? {}) as {
|
|
40
|
+
chatId?: string;
|
|
41
|
+
messageId?: string;
|
|
42
|
+
threadRootId?: string;
|
|
43
|
+
envelopeId?: string;
|
|
44
|
+
conversationId?: string;
|
|
45
|
+
};
|
|
46
|
+
const chatId = route.chatId;
|
|
47
|
+
if (!chatId) {
|
|
48
|
+
// External IM turn (channel_message): the reply already went out through
|
|
49
|
+
// the provider clip, agent-invoked — the turn-end text is deliberately
|
|
50
|
+
// NOT delivered anywhere (same contract as agent-core: plain output is
|
|
51
|
+
// not the reply). Only run the redundant idle so a binding without
|
|
52
|
+
// `observe: [turn]` still closes the session.
|
|
53
|
+
if (route.conversationId) {
|
|
54
|
+
const sessionId = await ensureSession(ctx, route.conversationId, { timeoutMs: 3_000 });
|
|
55
|
+
if (sessionId) {
|
|
56
|
+
await idleUnlessSuperseded(ctx, route.conversationId, route.envelopeId, sessionId);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const payload = delivery.payload as { text?: string } | string | undefined;
|
|
63
|
+
const text = typeof payload === 'string' ? payload : (payload?.text ?? '');
|
|
64
|
+
if (!text) return [];
|
|
65
|
+
|
|
66
|
+
// Short timeout: this sits in front of the user-visible reply POST. Cache
|
|
67
|
+
// hit (every message after a chat's first) is a store read; on a slow miss
|
|
68
|
+
// we degrade to a reply without the session link rather than delay it.
|
|
69
|
+
const sessionId = await ensureSession(ctx, chatId, { timeoutMs: 3_000 });
|
|
70
|
+
|
|
71
|
+
const request = {
|
|
72
|
+
url: `${apiUrl}/api/v1/orgs/${orgId}/chats/${chatId}/messages`,
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${agk}`,
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
},
|
|
78
|
+
// message_type must be explicit: the server persists it verbatim, and
|
|
79
|
+
// a blank type skips text normalization (prll:// refs, mentions) and
|
|
80
|
+
// renders as an unknown-type fallback on clients. agent_session_id links
|
|
81
|
+
// the reply bubble to its session panel (server validates ownership).
|
|
82
|
+
// idempotency_key dedupes the ambiguous-failure case where the direct
|
|
83
|
+
// POST committed but its response was lost and the host retry re-sends —
|
|
84
|
+
// same body on both paths, so the server collapses them to one message.
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
message_type: 'text',
|
|
87
|
+
content: { text },
|
|
88
|
+
idempotency_key: `parel-reply:${delivery.id}`,
|
|
89
|
+
// A reply to a thread message stays in that thread.
|
|
90
|
+
...(route.threadRootId ? { thread_root_id: route.threadRootId } : {}),
|
|
91
|
+
...(sessionId ? { agent_session_id: sessionId } : {}),
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(request.url, {
|
|
97
|
+
method: request.method,
|
|
98
|
+
headers: request.headers,
|
|
99
|
+
body: request.body,
|
|
100
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
// 4xx (bar 429) is permanent — retrying an unprocessable reply burns
|
|
104
|
+
// requests forever. Drop it (logged); turn_completed still closes the
|
|
105
|
+
// turn's indicator.
|
|
106
|
+
const permanent = res.status >= 400 && res.status < 500 && res.status !== 429;
|
|
107
|
+
if (!permanent) throw new Error(`reply post failed (${res.status})`);
|
|
108
|
+
console.error(`[parel-channel] reply rejected (${res.status}) — dropping`);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error('[parel-channel] direct reply failed; falling back to host retry', err);
|
|
112
|
+
return [{ type: 'fetch', request, idempotencyKey: `reply:${delivery.id}` }];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Redundant done signal, kept deliberately: turn_completed (onAgentEvent)
|
|
116
|
+
// is the primary close for every turn — including no-reply turns this hook
|
|
117
|
+
// never sees — but a binding deployed without `observe: [turn]` that still
|
|
118
|
+
// resolves this connector (npm ^1 fallback path; the artifact path freezes
|
|
119
|
+
// connector+config atomically) would otherwise never idle. Both signals
|
|
120
|
+
// run the same turnEnvelope staleness guard and idle is idempotent, so the
|
|
121
|
+
// overlap is harmless.
|
|
122
|
+
if (sessionId) {
|
|
123
|
+
await idleUnlessSuperseded(ctx, chatId, route.envelopeId, sessionId);
|
|
124
|
+
}
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Idle the subject's session unless a newer envelope owns the turn (the same
|
|
130
|
+
* turnEnvelope staleness guard onAgentEvent runs). Fail-open when the marker
|
|
131
|
+
* is unavailable — a stuck ring is worse than a blink.
|
|
132
|
+
*/
|
|
133
|
+
async function idleUnlessSuperseded(
|
|
134
|
+
ctx: ConnectorContext,
|
|
135
|
+
subject: string,
|
|
136
|
+
envelopeId: string | undefined,
|
|
137
|
+
sessionId: string,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const current = await ctx.store?.get(turnEnvelopeKey(subject)).catch(() => null);
|
|
140
|
+
if (!(typeof current === 'string' && current) || !envelopeId || current === envelopeId) {
|
|
141
|
+
const result = await patchSession(ctx, sessionId, { status: 'idle' });
|
|
142
|
+
if (result === 'stale') await invalidateSession(ctx, subject);
|
|
143
|
+
}
|
|
144
|
+
}
|