@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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ConnectorContext } from '@parel/plugin-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort progress reporting: dispatch received, AgentSession lifecycle,
|
|
4
|
+
* and input steps — the signals parall's message-level activity indicator and
|
|
5
|
+
* session panel are built on (see parall-runtime-indicator-plan.md §3).
|
|
6
|
+
*
|
|
7
|
+
* Every function catches its own failures and returns a degraded value: the
|
|
8
|
+
* message pipeline (emit / reply) must never block on indicator reporting.
|
|
9
|
+
*
|
|
10
|
+
* Session identity is deterministic — (runtime_type='parel',
|
|
11
|
+
* runtime_session_id=subject), where the subject is the envelope routing key:
|
|
12
|
+
* a chat id (cht_) for Parall chats, or a channel conversation id (chv_) for
|
|
13
|
+
* external IM conversations. per_subject routing already keys one parel
|
|
14
|
+
* session per subject, so the parall-side session mirrors it 1:1 without the
|
|
15
|
+
* connector ever knowing parel's internal session id.
|
|
16
|
+
*/
|
|
17
|
+
export declare const REQUEST_TIMEOUT_MS = 10000;
|
|
18
|
+
/** Reporting needs the agent id in the endpoint path; old configs lack it. */
|
|
19
|
+
export declare function reportingEnabled(ctx: ConnectorContext): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The subject's most recently emitted envelope id — the staleness guard for
|
|
22
|
+
* turn-level done signals: a turn_completed/failed whose envelopeIds don't
|
|
23
|
+
* include the current value belongs to a superseded turn and must not idle
|
|
24
|
+
* the session out from under the newer one.
|
|
25
|
+
*/
|
|
26
|
+
export declare const turnEnvelopeKey: (subject: string) => string;
|
|
27
|
+
/**
|
|
28
|
+
* 'stale' means the session id no longer accepts writes (closed/superseded —
|
|
29
|
+
* e.g. the New Session control ended it): callers invalidate the cache and
|
|
30
|
+
* may retry once against a fresh get-or-create, which falls through terminal
|
|
31
|
+
* rows and creates a new session server-side.
|
|
32
|
+
*/
|
|
33
|
+
export type ReportResult = 'ok' | 'stale' | 'failed';
|
|
34
|
+
/** Drop the cached session id so the next ensureSession re-resolves it. */
|
|
35
|
+
export declare function invalidateSession(ctx: ConnectorContext, subject: string): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Get-or-create the subject's parall AgentSession (ase_), cached in the
|
|
38
|
+
* durable per-connection store. Returns null when reporting is disabled or
|
|
39
|
+
* the call fails — callers skip their report and move on. Callers on a
|
|
40
|
+
* latency-sensitive path (deliver blocks the user-visible reply on this) pass
|
|
41
|
+
* a shorter timeoutMs; the cache-hit fast path is a store read either way.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ensureSession(ctx: ConnectorContext, subject: string, opts?: {
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}): Promise<string | null>;
|
|
46
|
+
/** PATCH the session (status / trigger_message_id). Never throws. */
|
|
47
|
+
export declare function patchSession(ctx: ConnectorContext, sessionId: string, body: {
|
|
48
|
+
status: 'active' | 'idle';
|
|
49
|
+
trigger_message_id?: string;
|
|
50
|
+
}): Promise<ReportResult>;
|
|
51
|
+
/**
|
|
52
|
+
* The input step is what flips the frontend indicator from pending to
|
|
53
|
+
* processing — target_type/target_id route the agent_step.new broadcast to
|
|
54
|
+
* messages:{chatId}, and content.trigger_ref.message_id is the exact match
|
|
55
|
+
* key (both mandatory; the server only publishes when a target is set).
|
|
56
|
+
*/
|
|
57
|
+
export declare function createInputStep(ctx: ConnectorContext, sessionId: string, args: {
|
|
58
|
+
chatId: string;
|
|
59
|
+
messageId: string;
|
|
60
|
+
senderId?: string;
|
|
61
|
+
text: string;
|
|
62
|
+
}): Promise<ReportResult>;
|
|
63
|
+
/**
|
|
64
|
+
* Input step for a channel_message dispatch (external IM). Shaped exactly
|
|
65
|
+
* like agent-core's channel input step so the session panel renders both
|
|
66
|
+
* runtimes identically: target = the ChannelConversation (chv_), trigger_ref
|
|
67
|
+
* carries the channel identifiers, and the sender is the external user
|
|
68
|
+
* (a display name, not a Parall usr_).
|
|
69
|
+
*/
|
|
70
|
+
export declare function createChannelInputStep(ctx: ConnectorContext, sessionId: string, args: {
|
|
71
|
+
conversationId: string;
|
|
72
|
+
channelMessageId: string;
|
|
73
|
+
provider?: string;
|
|
74
|
+
externalConversationId?: string;
|
|
75
|
+
senderId: string;
|
|
76
|
+
senderName: string;
|
|
77
|
+
text: string;
|
|
78
|
+
sentAt?: string;
|
|
79
|
+
}): Promise<ReportResult>;
|
|
80
|
+
/**
|
|
81
|
+
* E2 step-trace events → parall AgentSteps, shaped exactly like agent-core's
|
|
82
|
+
* createRuntimeStep so the session panel renders them identically to CC's.
|
|
83
|
+
* tool_call/tool_result carry a session-scoped idempotency key (events are
|
|
84
|
+
* at-most-once today, but a callId key is free insurance); reasoning has no
|
|
85
|
+
* natural key and parel already aggregates it host-side. The E2 tool_result
|
|
86
|
+
* event has no tool name, so tool_call parks it in the store by callId.
|
|
87
|
+
*/
|
|
88
|
+
export declare function createTraceStep(ctx: ConnectorContext, sessionId: string, subject: string, event: {
|
|
89
|
+
type: 'model_reasoning';
|
|
90
|
+
text: string;
|
|
91
|
+
} | {
|
|
92
|
+
type: 'tool_call';
|
|
93
|
+
callId: string;
|
|
94
|
+
name: string;
|
|
95
|
+
input: unknown;
|
|
96
|
+
} | {
|
|
97
|
+
type: 'tool_result';
|
|
98
|
+
callId: string;
|
|
99
|
+
status: 'ok' | 'error';
|
|
100
|
+
durationMs?: number;
|
|
101
|
+
outputPreview?: string;
|
|
102
|
+
}): Promise<ReportResult>;
|
|
103
|
+
/**
|
|
104
|
+
* Lights the pending ring (server broadcasts dispatch.received). Must COMPLETE
|
|
105
|
+
* before the ack effect is returned: MarkReceived only transitions rows still
|
|
106
|
+
* in status='pending', and ack moves the same row to 'acked' — losing the race
|
|
107
|
+
* means the ring never appears.
|
|
108
|
+
*/
|
|
109
|
+
export declare function markDispatchReceived(ctx: ConnectorContext, sourceType: string, sourceId: string): Promise<void>;
|
|
110
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAG1D;;;;;;;;;;;;;;GAcG;AAEH,eAAO,MAAM,kBAAkB,QAAS,CAAC;AAEzC,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAE/D;AAED;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,WAA8B,CAAC;AAG9E;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAMrD,2EAA2E;AAC3E,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7F;AASD;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAChC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAyBxB;AAED,qEAAqE;AACrE,wBAAsB,YAAY,CAChC,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;IAAE,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IAAC,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/D,OAAO,CAAC,YAAY,CAAC,CAgBvB;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC3E,OAAO,CAAC,YAAY,CAAC,CA8BvB;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;IACJ,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GACA,OAAO,CAAC,YAAY,CAAC,CAsCvB;AAOD;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,gBAAgB,EACrB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,KAAK,EACD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACnE;IACE,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACJ,OAAO,CAAC,YAAY,CAAC,CAyDvB;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,gBAAgB,EACrB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAcf"}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { parallAgentId, parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort progress reporting: dispatch received, AgentSession lifecycle,
|
|
4
|
+
* and input steps — the signals parall's message-level activity indicator and
|
|
5
|
+
* session panel are built on (see parall-runtime-indicator-plan.md §3).
|
|
6
|
+
*
|
|
7
|
+
* Every function catches its own failures and returns a degraded value: the
|
|
8
|
+
* message pipeline (emit / reply) must never block on indicator reporting.
|
|
9
|
+
*
|
|
10
|
+
* Session identity is deterministic — (runtime_type='parel',
|
|
11
|
+
* runtime_session_id=subject), where the subject is the envelope routing key:
|
|
12
|
+
* a chat id (cht_) for Parall chats, or a channel conversation id (chv_) for
|
|
13
|
+
* external IM conversations. per_subject routing already keys one parel
|
|
14
|
+
* session per subject, so the parall-side session mirrors it 1:1 without the
|
|
15
|
+
* connector ever knowing parel's internal session id.
|
|
16
|
+
*/
|
|
17
|
+
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
18
|
+
/** Reporting needs the agent id in the endpoint path; old configs lack it. */
|
|
19
|
+
export function reportingEnabled(ctx) {
|
|
20
|
+
return parallAgentId(ctx) !== '';
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The subject's most recently emitted envelope id — the staleness guard for
|
|
24
|
+
* turn-level done signals: a turn_completed/failed whose envelopeIds don't
|
|
25
|
+
* include the current value belongs to a superseded turn and must not idle
|
|
26
|
+
* the session out from under the newer one.
|
|
27
|
+
*/
|
|
28
|
+
export const turnEnvelopeKey = (subject) => `turnEnvelope:${subject}`;
|
|
29
|
+
const sessionKey = (subject) => `session:${subject}`;
|
|
30
|
+
function resultForStatus(status) {
|
|
31
|
+
return status === 404 || status === 409 || status === 410 ? 'stale' : 'failed';
|
|
32
|
+
}
|
|
33
|
+
/** Drop the cached session id so the next ensureSession re-resolves it. */
|
|
34
|
+
export async function invalidateSession(ctx, subject) {
|
|
35
|
+
await ctx.store?.delete(sessionKey(subject)).catch(() => { });
|
|
36
|
+
}
|
|
37
|
+
function jsonHeaders(ctx) {
|
|
38
|
+
return {
|
|
39
|
+
Authorization: `Bearer ${requireAgk(ctx)}`,
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get-or-create the subject's parall AgentSession (ase_), cached in the
|
|
45
|
+
* durable per-connection store. Returns null when reporting is disabled or
|
|
46
|
+
* the call fails — callers skip their report and move on. Callers on a
|
|
47
|
+
* latency-sensitive path (deliver blocks the user-visible reply on this) pass
|
|
48
|
+
* a shorter timeoutMs; the cache-hit fast path is a store read either way.
|
|
49
|
+
*/
|
|
50
|
+
export async function ensureSession(ctx, subject, opts = {}) {
|
|
51
|
+
if (!reportingEnabled(ctx))
|
|
52
|
+
return null;
|
|
53
|
+
const cached = await ctx.store?.get(sessionKey(subject)).catch(() => null);
|
|
54
|
+
if (typeof cached === 'string' && cached)
|
|
55
|
+
return cached;
|
|
56
|
+
try {
|
|
57
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions`;
|
|
58
|
+
const res = await fetch(url, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: jsonHeaders(ctx),
|
|
61
|
+
body: JSON.stringify({ runtime_type: 'parel', runtime_session_id: subject }),
|
|
62
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
console.error(`[parel-channel] session get-or-create failed (${res.status})`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const body = (await res.json().catch(() => null));
|
|
69
|
+
const id = body?.id;
|
|
70
|
+
if (!id)
|
|
71
|
+
return null;
|
|
72
|
+
await ctx.store?.set(sessionKey(subject), id).catch(() => { });
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error('[parel-channel] session get-or-create failed', err);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** PATCH the session (status / trigger_message_id). Never throws. */
|
|
81
|
+
export async function patchSession(ctx, sessionId, body) {
|
|
82
|
+
try {
|
|
83
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}`;
|
|
84
|
+
const res = await fetch(url, {
|
|
85
|
+
method: 'PATCH',
|
|
86
|
+
headers: jsonHeaders(ctx),
|
|
87
|
+
body: JSON.stringify(body),
|
|
88
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
89
|
+
});
|
|
90
|
+
if (res.ok)
|
|
91
|
+
return 'ok';
|
|
92
|
+
console.error(`[parel-channel] session patch failed (${res.status})`);
|
|
93
|
+
return resultForStatus(res.status);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error('[parel-channel] session patch failed', err);
|
|
97
|
+
return 'failed';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The input step is what flips the frontend indicator from pending to
|
|
102
|
+
* processing — target_type/target_id route the agent_step.new broadcast to
|
|
103
|
+
* messages:{chatId}, and content.trigger_ref.message_id is the exact match
|
|
104
|
+
* key (both mandatory; the server only publishes when a target is set).
|
|
105
|
+
*/
|
|
106
|
+
export async function createInputStep(ctx, sessionId, args) {
|
|
107
|
+
try {
|
|
108
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
109
|
+
const res = await fetch(url, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: jsonHeaders(ctx),
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
step_type: 'input',
|
|
114
|
+
target_type: 'chat',
|
|
115
|
+
target_id: args.chatId,
|
|
116
|
+
// Session-scoped dedup: an ack failure leaves the dispatch replayable
|
|
117
|
+
// by the reconnect catch-up sweep — without this key every replay
|
|
118
|
+
// would insert and broadcast another input step for the same message.
|
|
119
|
+
idempotency_key: `input:${args.messageId}`,
|
|
120
|
+
content: {
|
|
121
|
+
trigger_type: 'mention',
|
|
122
|
+
trigger_ref: { message_id: args.messageId },
|
|
123
|
+
...(args.senderId ? { sender_id: args.senderId } : {}),
|
|
124
|
+
summary: args.text.slice(0, 200),
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
128
|
+
});
|
|
129
|
+
if (res.ok)
|
|
130
|
+
return 'ok';
|
|
131
|
+
console.error(`[parel-channel] input step failed (${res.status})`);
|
|
132
|
+
return resultForStatus(res.status);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error('[parel-channel] input step failed', err);
|
|
136
|
+
return 'failed';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Input step for a channel_message dispatch (external IM). Shaped exactly
|
|
141
|
+
* like agent-core's channel input step so the session panel renders both
|
|
142
|
+
* runtimes identically: target = the ChannelConversation (chv_), trigger_ref
|
|
143
|
+
* carries the channel identifiers, and the sender is the external user
|
|
144
|
+
* (a display name, not a Parall usr_).
|
|
145
|
+
*/
|
|
146
|
+
export async function createChannelInputStep(ctx, sessionId, args) {
|
|
147
|
+
try {
|
|
148
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
149
|
+
const res = await fetch(url, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: jsonHeaders(ctx),
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
step_type: 'input',
|
|
154
|
+
target_type: 'channel_conversation',
|
|
155
|
+
target_id: args.conversationId,
|
|
156
|
+
// Session-scoped dedup, same rationale as the chat input step: a
|
|
157
|
+
// failed ack leaves the dispatch replayable by the catch-up sweep.
|
|
158
|
+
idempotency_key: `input:${args.channelMessageId}`,
|
|
159
|
+
content: {
|
|
160
|
+
trigger_type: 'channel_message',
|
|
161
|
+
trigger_ref: {
|
|
162
|
+
conversation_id: args.conversationId,
|
|
163
|
+
channel_message_id: args.channelMessageId,
|
|
164
|
+
...(args.provider ? { provider: args.provider } : {}),
|
|
165
|
+
...(args.externalConversationId
|
|
166
|
+
? { external_conversation_id: args.externalConversationId }
|
|
167
|
+
: {}),
|
|
168
|
+
},
|
|
169
|
+
sender_id: args.senderId,
|
|
170
|
+
sender_name: args.senderName,
|
|
171
|
+
summary: args.text.slice(0, 200),
|
|
172
|
+
...(args.sentAt ? { sent_at: args.sentAt } : {}),
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
176
|
+
});
|
|
177
|
+
if (res.ok)
|
|
178
|
+
return 'ok';
|
|
179
|
+
console.error(`[parel-channel] channel input step failed (${res.status})`);
|
|
180
|
+
return resultForStatus(res.status);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
console.error('[parel-channel] channel input step failed', err);
|
|
184
|
+
return 'failed';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Subject-scoped: one connector serves many per-subject sessions, and
|
|
188
|
+
// provider call ids are only unique within a turn — never let two subjects'
|
|
189
|
+
// concurrent calls overwrite each other's parked name.
|
|
190
|
+
const toolNameKey = (subject, callId) => `toolName:${subject}:${callId}`;
|
|
191
|
+
/**
|
|
192
|
+
* E2 step-trace events → parall AgentSteps, shaped exactly like agent-core's
|
|
193
|
+
* createRuntimeStep so the session panel renders them identically to CC's.
|
|
194
|
+
* tool_call/tool_result carry a session-scoped idempotency key (events are
|
|
195
|
+
* at-most-once today, but a callId key is free insurance); reasoning has no
|
|
196
|
+
* natural key and parel already aggregates it host-side. The E2 tool_result
|
|
197
|
+
* event has no tool name, so tool_call parks it in the store by callId.
|
|
198
|
+
*/
|
|
199
|
+
export async function createTraceStep(ctx, sessionId, subject, event) {
|
|
200
|
+
let body;
|
|
201
|
+
if (event.type === 'model_reasoning') {
|
|
202
|
+
body = { step_type: 'thinking', content: { text: event.text } };
|
|
203
|
+
}
|
|
204
|
+
else if (event.type === 'tool_call') {
|
|
205
|
+
await ctx.store?.set(toolNameKey(subject, event.callId), event.name).catch(() => { });
|
|
206
|
+
body = {
|
|
207
|
+
step_type: 'tool_call',
|
|
208
|
+
runtime_key: event.callId,
|
|
209
|
+
idempotency_key: `tc:${event.callId}`,
|
|
210
|
+
content: {
|
|
211
|
+
call_id: event.callId,
|
|
212
|
+
tool_name: event.name,
|
|
213
|
+
tool_input: event.input,
|
|
214
|
+
status: 'running',
|
|
215
|
+
started_at: new Date(ctx.now()).toISOString(),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const parked = await ctx.store?.get(toolNameKey(subject, event.callId)).catch(() => null);
|
|
221
|
+
await ctx.store?.delete(toolNameKey(subject, event.callId)).catch(() => { });
|
|
222
|
+
body = {
|
|
223
|
+
step_type: 'tool_result',
|
|
224
|
+
idempotency_key: `tr:${event.callId}`,
|
|
225
|
+
content: {
|
|
226
|
+
call_id: event.callId,
|
|
227
|
+
// The server REQUIRES tool_name on tool_result — a missed tool_call
|
|
228
|
+
// event (best-effort feed) must degrade to a labeled unknown, not a
|
|
229
|
+
// 400-dropped step.
|
|
230
|
+
tool_name: typeof parked === 'string' && parked ? parked : 'unknown',
|
|
231
|
+
status: event.status === 'error' ? 'error' : 'success',
|
|
232
|
+
output: event.outputPreview ?? '',
|
|
233
|
+
duration_ms: event.durationMs ?? 0,
|
|
234
|
+
collapsible: true,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
|
|
240
|
+
// The subject prefix tells chat turns from external IM turns — channel
|
|
241
|
+
// steps must not target 'chat' or the server would broadcast them onto
|
|
242
|
+
// a messages:{chv_} channel no client subscribes to (agent-core's
|
|
243
|
+
// resolveStepTarget draws the same line).
|
|
244
|
+
const targetType = subject.startsWith('chv_') ? 'channel_conversation' : 'chat';
|
|
245
|
+
const res = await fetch(url, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: jsonHeaders(ctx),
|
|
248
|
+
body: JSON.stringify({ ...body, target_type: targetType, target_id: subject }),
|
|
249
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
250
|
+
});
|
|
251
|
+
if (res.ok)
|
|
252
|
+
return 'ok';
|
|
253
|
+
console.error(`[parel-channel] trace step failed (${res.status})`);
|
|
254
|
+
return resultForStatus(res.status);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
console.error('[parel-channel] trace step failed', err);
|
|
258
|
+
return 'failed';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Lights the pending ring (server broadcasts dispatch.received). Must COMPLETE
|
|
263
|
+
* before the ack effect is returned: MarkReceived only transitions rows still
|
|
264
|
+
* in status='pending', and ack moves the same row to 'acked' — losing the race
|
|
265
|
+
* means the ring never appears.
|
|
266
|
+
*/
|
|
267
|
+
export async function markDispatchReceived(ctx, sourceType, sourceId) {
|
|
268
|
+
if (!reportingEnabled(ctx))
|
|
269
|
+
return;
|
|
270
|
+
try {
|
|
271
|
+
const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/received`;
|
|
272
|
+
const res = await fetch(url, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: jsonHeaders(ctx),
|
|
275
|
+
body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
|
|
276
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
277
|
+
});
|
|
278
|
+
if (!res.ok)
|
|
279
|
+
console.error(`[parel-channel] mark received failed (${res.status})`);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
console.error('[parel-channel] mark received failed', err);
|
|
283
|
+
}
|
|
284
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@parall/parel-channel",
|
|
3
|
+
"version": "1.37.0",
|
|
4
|
+
"description": "Parall channel plugin for the parel runtime — lets a parel agent treat Parall as a managed_ws channel (receive dispatches, reply via the Parall API)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/parall-hq/parall-mono",
|
|
9
|
+
"directory": "ts/parel-channel"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src",
|
|
23
|
+
"parel.plugin.json"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@parel/plugin-sdk": "^0.9.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"typescript": "^5.7.0",
|
|
31
|
+
"vitest": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -b",
|
|
35
|
+
"test": "vitest run"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@parall/parel-channel",
|
|
3
|
+
"type": "channel",
|
|
4
|
+
"channel": {
|
|
5
|
+
"connectionTypes": ["managed_ws"],
|
|
6
|
+
"sources": ["parall"]
|
|
7
|
+
},
|
|
8
|
+
"config": {
|
|
9
|
+
"parallApiUrl": "Parall API base URL (e.g. https://api.parall.com).",
|
|
10
|
+
"parallOrgId": "Parall org id this agent belongs to."
|
|
11
|
+
},
|
|
12
|
+
"requires": {
|
|
13
|
+
"secrets": {
|
|
14
|
+
"parallApiKey": {
|
|
15
|
+
"description": "This agent's Parall API key (agk_). Used to open the gateway WS and to post replies.",
|
|
16
|
+
"required": true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"execution": {
|
|
21
|
+
"snapshot": {
|
|
22
|
+
"store": "reset",
|
|
23
|
+
"sideEffects": "reference"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt framing for external IM channel messages (channel_message
|
|
3
|
+
* dispatches). Mirrors agent-core's event-format.ts channel branch, adapted
|
|
4
|
+
* to the parel runtime's tool surface:
|
|
5
|
+
*
|
|
6
|
+
* - The audience is OUTSIDE Parall — no prll:// refs, no internal cards or
|
|
7
|
+
* attachment links in the reply (dead links there).
|
|
8
|
+
* - The reply goes out through the provider clip's `send_message` command.
|
|
9
|
+
* A parel agent invokes clips from its sandbox via the parall CLI, so the
|
|
10
|
+
* hint spells out the concrete `parall clip invoke …` command (agent-core
|
|
11
|
+
* leaves the invocation mechanism to the bridge's own clip tooling).
|
|
12
|
+
* - Provider metadata is best-effort (the connection lookup can fail) —
|
|
13
|
+
* never instruct the agent to invoke a made-up clip alias.
|
|
14
|
+
* - When the inbound external message id is known, offer the in-thread reply
|
|
15
|
+
* form ({"message_id": …}) so threaded conversations aren't nudged toward
|
|
16
|
+
* new top-level messages.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function sanitizeMeta(value: string): string {
|
|
20
|
+
return value
|
|
21
|
+
.replace(/[\r\n]+/g, ' ')
|
|
22
|
+
.replace(/[[\]|]/g, ' ')
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ChannelPromptArgs {
|
|
27
|
+
/** Provider name (= clip alias, e.g. "feishu"); undefined when unresolved. */
|
|
28
|
+
provider?: string;
|
|
29
|
+
/** Conversation shape as recorded on the ChannelConversation (dm/group). */
|
|
30
|
+
conversationType?: string;
|
|
31
|
+
externalConversationId?: string;
|
|
32
|
+
externalMessageId?: string;
|
|
33
|
+
senderName: string;
|
|
34
|
+
/** The inbound message text (raw, un-framed). */
|
|
35
|
+
text: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildChannelPrompt(args: ChannelPromptArgs): string {
|
|
39
|
+
const providerLabel = sanitizeMeta(args.provider ?? 'external IM');
|
|
40
|
+
const convLabel = args.externalConversationId
|
|
41
|
+
? `${sanitizeMeta(args.externalConversationId)} (${sanitizeMeta(args.conversationType ?? 'conversation')})`
|
|
42
|
+
: sanitizeMeta(args.conversationType ?? 'conversation');
|
|
43
|
+
const lines = [
|
|
44
|
+
'[Event: channel.message]',
|
|
45
|
+
`[Channel: ${providerLabel} | conversation: ${convLabel}]`,
|
|
46
|
+
`[From: ${sanitizeMeta(args.senderName)} (external user, not a Parall member)]`,
|
|
47
|
+
];
|
|
48
|
+
if (args.externalMessageId) {
|
|
49
|
+
lines.push(`[External message ID: ${sanitizeMeta(args.externalMessageId)}]`);
|
|
50
|
+
}
|
|
51
|
+
lines.push(
|
|
52
|
+
`[Audience: this conversation lives on ${providerLabel}, OUTSIDE Parall. Readers cannot open prll:// links, Parall cards, or internal attachments — never include them in replies. Write plain conversational text.]`,
|
|
53
|
+
);
|
|
54
|
+
lines.push('', args.text);
|
|
55
|
+
return lines.join('\n') + buildChannelReplyHint(args);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildChannelReplyHint(args: ChannelPromptArgs): string {
|
|
59
|
+
const target = JSON.stringify({
|
|
60
|
+
chat_id: args.externalConversationId || '<conversation id>',
|
|
61
|
+
text: '...',
|
|
62
|
+
});
|
|
63
|
+
const threadAlt = args.externalMessageId
|
|
64
|
+
? ` To reply in-thread to this specific message, use '${JSON.stringify({ message_id: args.externalMessageId, text: '...' })}' instead.`
|
|
65
|
+
: '';
|
|
66
|
+
if (args.provider) {
|
|
67
|
+
const alias = args.provider;
|
|
68
|
+
return `\n<system-reminder>To reply, invoke the \`${alias}\` clip's \`send_message\` command from your sandbox: \`parall clip invoke ${alias} send_message '${target}'\` — your plain text output is NOT delivered to the external conversation.${threadAlt} The same clip's \`call\` command reaches the wider ${alias} API when needed.</system-reminder>`;
|
|
69
|
+
}
|
|
70
|
+
return `\n<system-reminder>To reply, invoke your channel provider clip's \`send_message\` command from your sandbox: \`parall clip invoke <alias> send_message '${target}'\` (find the alias with \`parall clip list\`) — your plain text output is NOT delivered to the external conversation.${threadAlt} The same clip's \`call\` command reaches the wider external platform API when needed.</system-reminder>`;
|
|
71
|
+
}
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ConnectorContext, ConnectRequest } from '@parel/plugin-sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Open the Parall gateway WebSocket as this agent. Auth is the agent's agk_: we
|
|
5
|
+
* fetch a short-lived WS ticket and hand parel the ws:// url to connect to. The
|
|
6
|
+
* parel runtime owns the socket lifecycle from there.
|
|
7
|
+
*
|
|
8
|
+
* Contract (confirmed against parel #102/#103): one long-lived managed_ws
|
|
9
|
+
* connection per declaration; the host rewrites ws(s):// to http(s):// for the
|
|
10
|
+
* Workers fetch dial, owns keepalive/liveness, and re-invokes connect() on
|
|
11
|
+
* every (re)connect — so the short-lived ticket is minted fresh each time.
|
|
12
|
+
*/
|
|
13
|
+
export async function connectParall(ctx: ConnectorContext): Promise<ConnectRequest> {
|
|
14
|
+
const apiUrl = parallApiUrl(ctx);
|
|
15
|
+
const agk = requireAgk(ctx);
|
|
16
|
+
// connect() runs on every (re)connect — fail fast rather than hang the host's
|
|
17
|
+
// reconnect loop on a wedged ticket endpoint.
|
|
18
|
+
const res = await fetch(`${apiUrl}/api/v1/ws/ticket`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { Authorization: `Bearer ${agk}` },
|
|
21
|
+
signal: AbortSignal.timeout(10_000),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
throw new Error(`parel-channel: ws ticket failed (${res.status})`);
|
|
25
|
+
}
|
|
26
|
+
const body = (await res.json().catch(() => null)) as { ticket?: string; ws_url?: string } | null;
|
|
27
|
+
const ticket = body?.ticket;
|
|
28
|
+
const wsUrl = body?.ws_url;
|
|
29
|
+
if (!ticket) {
|
|
30
|
+
throw new Error('parel-channel: ws ticket response missing ticket');
|
|
31
|
+
}
|
|
32
|
+
// Prefer the server-advertised gateway URL — the API and WS hosts differ in
|
|
33
|
+
// some deployments (local dev ports, split domains). Fall back to deriving
|
|
34
|
+
// from the API origin (prod-style single domain routing /ws).
|
|
35
|
+
const base = wsUrl || `${apiUrl.replace(/^http/, 'ws')}/ws`;
|
|
36
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
37
|
+
return { url: `${base}${sep}ticket=${encodeURIComponent(ticket)}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parallApiUrl(ctx: ConnectorContext): string {
|
|
41
|
+
return String(ctx.config.parallApiUrl ?? '').replace(/\/+$/, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parallOrgId(ctx: ConnectorContext): string {
|
|
45
|
+
return String(ctx.config.parallOrgId ?? '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The parall agent's user id (usr_), needed for the path-scoped session/step
|
|
50
|
+
* reporting endpoints. Empty on configs deployed before progress reporting
|
|
51
|
+
* shipped — reporting degrades to off until the agent is redeployed.
|
|
52
|
+
*/
|
|
53
|
+
export function parallAgentId(ctx: ConnectorContext): string {
|
|
54
|
+
return String(ctx.config.parallAgentId ?? '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function requireAgk(ctx: ConnectorContext): string {
|
|
58
|
+
const agk = ctx.secrets.parallApiKey;
|
|
59
|
+
if (!agk) {
|
|
60
|
+
throw new Error('parel-channel: parallApiKey secret is required');
|
|
61
|
+
}
|
|
62
|
+
return agk;
|
|
63
|
+
}
|