@parall/parel-channel 1.40.0 → 1.41.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 +9 -1
- package/dist/channel-prompt.d.ts.map +1 -1
- package/dist/channel-prompt.js +17 -0
- package/dist/connect.d.ts +7 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +9 -0
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +38 -10
- package/dist/fork.d.ts +145 -0
- package/dist/fork.d.ts.map +1 -0
- package/dist/fork.js +320 -0
- package/dist/inbound-channel.d.ts +29 -0
- package/dist/inbound-channel.d.ts.map +1 -0
- package/dist/inbound-channel.js +213 -0
- package/dist/inbound.d.ts +14 -0
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +571 -271
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/parel-sdk-compat.d.ts +63 -0
- package/dist/parel-sdk-compat.d.ts.map +1 -0
- package/dist/parel-sdk-compat.js +16 -0
- package/dist/session.d.ts +88 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +225 -11
- package/dist/typed-dispatch.d.ts +76 -0
- package/dist/typed-dispatch.d.ts.map +1 -0
- package/dist/typed-dispatch.js +345 -0
- package/package.json +1 -1
- package/src/channel-prompt.ts +28 -1
- package/src/connect.ts +10 -0
- package/src/delivery.ts +38 -9
- package/src/fork.ts +432 -0
- package/src/inbound-channel.ts +256 -0
- package/src/inbound.ts +634 -303
- package/src/index.ts +7 -1
- package/src/parel-sdk-compat.ts +77 -0
- package/src/session.ts +247 -12
- package/src/typed-dispatch.ts +521 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
2
|
+
import { REQUEST_TIMEOUT_MS } from './session.js';
|
|
3
|
+
/** Dispatch event types this module consumes (everything but the message pair). */
|
|
4
|
+
export const TYPED_EVENT_TYPES = [
|
|
5
|
+
'task_assign',
|
|
6
|
+
'task_update',
|
|
7
|
+
'task_comment',
|
|
8
|
+
'wiki_comment',
|
|
9
|
+
'schedule.fire',
|
|
10
|
+
'external_trigger',
|
|
11
|
+
'approval_decided',
|
|
12
|
+
];
|
|
13
|
+
export function isTypedEventType(eventType) {
|
|
14
|
+
return TYPED_EVENT_TYPES.includes(eventType);
|
|
15
|
+
}
|
|
16
|
+
const STALE = { outcome: 'stale' };
|
|
17
|
+
const PENDING = { outcome: 'pending' };
|
|
18
|
+
function sanitizeMeta(value) {
|
|
19
|
+
return value
|
|
20
|
+
.replace(/[\r\n]+/g, ' ')
|
|
21
|
+
.replace(/[[\]|]/g, ' ')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build the plan for one typed dispatch. Never throws: any unexpected error
|
|
26
|
+
* degrades to `pending` (same posture as the message path — the sweep
|
|
27
|
+
* retries, and a poisoned row eventually surfaces as a stuck pending count).
|
|
28
|
+
*/
|
|
29
|
+
export async function planTypedDispatch(data, ctx) {
|
|
30
|
+
const eventType = String(data.event_type ?? '');
|
|
31
|
+
const sourceId = data.source_id ? String(data.source_id) : '';
|
|
32
|
+
try {
|
|
33
|
+
switch (eventType) {
|
|
34
|
+
case 'task_assign':
|
|
35
|
+
case 'task_update': {
|
|
36
|
+
const taskId = data.task_id ? String(data.task_id) : sourceId;
|
|
37
|
+
if (!taskId)
|
|
38
|
+
return STALE;
|
|
39
|
+
return await planTask(ctx, taskId, eventType, actorId(data));
|
|
40
|
+
}
|
|
41
|
+
case 'task_comment': {
|
|
42
|
+
const taskId = data.task_id ? String(data.task_id) : '';
|
|
43
|
+
if (!sourceId || !taskId)
|
|
44
|
+
return STALE;
|
|
45
|
+
return await planTaskComment(ctx, sourceId, taskId, actorId(data), deliveryReason(data));
|
|
46
|
+
}
|
|
47
|
+
case 'wiki_comment': {
|
|
48
|
+
if (!sourceId)
|
|
49
|
+
return STALE;
|
|
50
|
+
return await planWikiComment(ctx, sourceId, actorId(data), deliveryReason(data));
|
|
51
|
+
}
|
|
52
|
+
case 'schedule.fire': {
|
|
53
|
+
if (!sourceId)
|
|
54
|
+
return STALE;
|
|
55
|
+
return await planScheduleFire(ctx, sourceId);
|
|
56
|
+
}
|
|
57
|
+
case 'external_trigger': {
|
|
58
|
+
if (!sourceId)
|
|
59
|
+
return STALE;
|
|
60
|
+
return await planExternalTrigger(ctx, sourceId);
|
|
61
|
+
}
|
|
62
|
+
case 'approval_decided': {
|
|
63
|
+
if (!sourceId)
|
|
64
|
+
return STALE;
|
|
65
|
+
const chatId = data.chat_id ? String(data.chat_id) : undefined;
|
|
66
|
+
return await planApprovalDecided(ctx, sourceId, chatId, actorId(data));
|
|
67
|
+
}
|
|
68
|
+
default:
|
|
69
|
+
return PENDING; // unknown typed event — future work, never ack-drop
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
console.error(`[parel-channel] typed dispatch ${eventType} failed`, err);
|
|
74
|
+
return PENDING;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function actorId(data) {
|
|
78
|
+
return data.actor_id ? String(data.actor_id) : undefined;
|
|
79
|
+
}
|
|
80
|
+
function deliveryReason(data) {
|
|
81
|
+
return data.delivery_reason ? String(data.delivery_reason) : undefined;
|
|
82
|
+
}
|
|
83
|
+
function orgGet(ctx, path) {
|
|
84
|
+
return fetch(`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}${path}`, {
|
|
85
|
+
headers: { Authorization: `Bearer ${requireAgk(ctx)}` },
|
|
86
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** 404/403 are permanent for a dispatch pointer; anything else is transient. */
|
|
90
|
+
function goneStatus(status) {
|
|
91
|
+
return status === 404 || status === 403;
|
|
92
|
+
}
|
|
93
|
+
async function planTask(ctx, taskId, eventType, actor) {
|
|
94
|
+
const res = await orgGet(ctx, `/tasks/${taskId}`);
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
return goneStatus(res.status) ? STALE : PENDING;
|
|
97
|
+
const task = (await res.json().catch(() => null));
|
|
98
|
+
if (!task?.id)
|
|
99
|
+
return STALE;
|
|
100
|
+
// Same staleness rule as the standard runtimes (gateway handleTaskDispatch):
|
|
101
|
+
// an update only concerns this agent while it is the assignee, or — for
|
|
102
|
+
// updates — the creator watching its own delegation. Anything else is a
|
|
103
|
+
// stale row (reassigned before delivery) — ack it away.
|
|
104
|
+
const me = String(ctx.config.parallAgentId ?? '');
|
|
105
|
+
const isAssignee = task.assignee_id === me;
|
|
106
|
+
const isCreatorUpdate = eventType === 'task_update' && task.creator_id === me;
|
|
107
|
+
if (me && !isAssignee && !isCreatorUpdate)
|
|
108
|
+
return STALE;
|
|
109
|
+
// Metadata lines pass through sanitizeMeta — a task title with newlines or
|
|
110
|
+
// brackets must not be able to forge [Event: …] header lines (the body
|
|
111
|
+
// below is user content by definition and stays verbatim).
|
|
112
|
+
const label = task.identifier
|
|
113
|
+
? `${sanitizeMeta(task.identifier)} (prll://${task.id})`
|
|
114
|
+
: `prll://${task.id}`;
|
|
115
|
+
// Standard runtimes collapse task_update onto the SAME task.assigned event
|
|
116
|
+
// body (agent-core handleTaskDispatch → handleTaskAssignment) — managed
|
|
117
|
+
// agents must read the identical contract, and the shared task skill only
|
|
118
|
+
// teaches [Event: task.assigned].
|
|
119
|
+
const lines = [`[Event: task.assigned]`, `[Task: ${label}]`];
|
|
120
|
+
const by = actor ?? task.creator_id;
|
|
121
|
+
if (by) {
|
|
122
|
+
lines.push(`[Assigned by: prll://${by}]`);
|
|
123
|
+
}
|
|
124
|
+
lines.push(`Title: ${sanitizeMeta(task.title ?? '')}`);
|
|
125
|
+
lines.push(`Status: ${sanitizeMeta(task.status ?? '')}`, `Priority: ${sanitizeMeta(task.priority ?? '')}`);
|
|
126
|
+
if (task.project_id)
|
|
127
|
+
lines.push(`Project: prll://${task.project_id}`);
|
|
128
|
+
if (task.parent_id)
|
|
129
|
+
lines.push(`Parent: prll://${task.parent_id}`);
|
|
130
|
+
if (task.description)
|
|
131
|
+
lines.push('', task.description);
|
|
132
|
+
lines.push('', taskActionHint(task.id));
|
|
133
|
+
return {
|
|
134
|
+
outcome: 'emit',
|
|
135
|
+
subject: task.id,
|
|
136
|
+
body: lines.join('\n'),
|
|
137
|
+
stepTarget: { type: 'task', id: task.id },
|
|
138
|
+
triggerType: eventType,
|
|
139
|
+
summary: `${eventType === 'task_assign' ? 'Task assigned' : 'Task updated'}: ${task.title ?? task.id}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* schedule.fire / external_trigger turns have NO delivery target — the
|
|
144
|
+
* managed-agent base behavior ("your reply auto-delivers") does not apply,
|
|
145
|
+
* so without this line a normal completion silently disappears.
|
|
146
|
+
*/
|
|
147
|
+
function cliOnlyOutputHint(kind) {
|
|
148
|
+
return `<system-reminder>This ${kind} event has NO conversation attached — your plain text output is NOT delivered anywhere. To surface results, act through your sandbox CLI (e.g. \`parall messages send <chatId> --text "…"\` to a relevant chat, \`parall tasks update …\`, or the target referenced above).</system-reminder>`;
|
|
149
|
+
}
|
|
150
|
+
function taskActionHint(taskId) {
|
|
151
|
+
return `<system-reminder>Act on this task with your sandbox CLI: \`parall tasks get ${taskId}\`, \`parall tasks update ${taskId} --status …\`, \`parall tasks comments add ${taskId} --body "…"\`. Your plain text output is NOT delivered anywhere for task events.</system-reminder>`;
|
|
152
|
+
}
|
|
153
|
+
async function fetchComment(ctx, commentId) {
|
|
154
|
+
const res = await orgGet(ctx, `/comments/${commentId}`);
|
|
155
|
+
if (!res.ok)
|
|
156
|
+
return goneStatus(res.status) ? STALE : PENDING;
|
|
157
|
+
const comment = (await res.json().catch(() => null));
|
|
158
|
+
if (!comment?.id)
|
|
159
|
+
return STALE;
|
|
160
|
+
// no_reply-hinted comments are informational — same drop the gateway does.
|
|
161
|
+
if (comment.hints?.no_reply)
|
|
162
|
+
return STALE;
|
|
163
|
+
return { comment };
|
|
164
|
+
}
|
|
165
|
+
async function planTaskComment(ctx, commentId, taskId, actor, reason) {
|
|
166
|
+
const fetched = await fetchComment(ctx, commentId);
|
|
167
|
+
if (!('comment' in fetched))
|
|
168
|
+
return fetched;
|
|
169
|
+
const comment = fetched.comment;
|
|
170
|
+
// Task context is optional garnish (the gateway treats it the same way).
|
|
171
|
+
let task = null;
|
|
172
|
+
try {
|
|
173
|
+
const res = await orgGet(ctx, `/tasks/${taskId}`);
|
|
174
|
+
if (res.ok)
|
|
175
|
+
task = (await res.json().catch(() => null));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* optional */
|
|
179
|
+
}
|
|
180
|
+
const lines = [`[Event: task.comment.created]`];
|
|
181
|
+
const label = task?.identifier
|
|
182
|
+
? `${sanitizeMeta(task.identifier)} (prll://${taskId})`
|
|
183
|
+
: `prll://${taskId}`;
|
|
184
|
+
lines.push(`[Task: ${label}]`);
|
|
185
|
+
if (task?.title)
|
|
186
|
+
lines.push(`Title: ${sanitizeMeta(task.title)}`);
|
|
187
|
+
if (task?.status || task?.priority) {
|
|
188
|
+
// Task context parity with agent-core's task_comment body.
|
|
189
|
+
lines.push(`Status: ${sanitizeMeta(task.status ?? '')}`, `Priority: ${sanitizeMeta(task.priority ?? '')}`);
|
|
190
|
+
}
|
|
191
|
+
if (reason)
|
|
192
|
+
lines.push(`[Delivery: ${sanitizeMeta(reason)}]`);
|
|
193
|
+
const author = comment.author?.display_name ?? actor ?? 'unknown';
|
|
194
|
+
lines.push(`[From: ${sanitizeMeta(author)} (prll://${comment.author_id ?? ''})]`);
|
|
195
|
+
lines.push(`[Comment ID: prll://${comment.id}]`);
|
|
196
|
+
lines.push('', comment.body ?? '');
|
|
197
|
+
lines.push('', taskActionHint(taskId));
|
|
198
|
+
return {
|
|
199
|
+
outcome: 'emit',
|
|
200
|
+
subject: taskId,
|
|
201
|
+
body: lines.join('\n'),
|
|
202
|
+
stepTarget: { type: 'task', id: taskId },
|
|
203
|
+
triggerType: 'task_comment',
|
|
204
|
+
summary: `Task comment by ${author}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
async function planWikiComment(ctx, commentId, actor, reason) {
|
|
208
|
+
const fetched = await fetchComment(ctx, commentId);
|
|
209
|
+
if (!('comment' in fetched))
|
|
210
|
+
return fetched;
|
|
211
|
+
const comment = fetched.comment;
|
|
212
|
+
// Routing key = scheme-stripped target_uri (same as agent-core's
|
|
213
|
+
// parseWikiCommentTarget): distinct pages / anchors / changesets group
|
|
214
|
+
// into distinct subjects instead of colliding on one wiki id.
|
|
215
|
+
const targetUri = comment.target_uri ?? '';
|
|
216
|
+
const routingKey = targetUri.replace(/^prll:\/\//, '') || `comment:${comment.id}`;
|
|
217
|
+
const isChangeset = routingKey.startsWith('wcs_');
|
|
218
|
+
const lines = [`[Event: wiki.comment.created]`];
|
|
219
|
+
lines.push(`[${isChangeset ? 'Wiki Changeset' : 'Wiki'}: prll://${routingKey}]`);
|
|
220
|
+
if (reason)
|
|
221
|
+
lines.push(`[Delivery: ${sanitizeMeta(reason)}]`);
|
|
222
|
+
const author = comment.author?.display_name ?? actor ?? 'unknown';
|
|
223
|
+
lines.push(`[From: ${sanitizeMeta(author)} (prll://${comment.author_id ?? ''})]`);
|
|
224
|
+
lines.push(`[Comment ID: prll://${comment.id}]`);
|
|
225
|
+
lines.push('', comment.body ?? '');
|
|
226
|
+
lines.push('', `<system-reminder>Reply with your sandbox CLI: \`parall comments add --target prll://${routingKey} --body "…"\` (read context first with \`parall comments get ${comment.id}\`). Your plain text output is NOT delivered anywhere for wiki events.</system-reminder>`);
|
|
227
|
+
return {
|
|
228
|
+
outcome: 'emit',
|
|
229
|
+
subject: routingKey,
|
|
230
|
+
body: lines.join('\n'),
|
|
231
|
+
stepTarget: { type: 'wiki', id: routingKey },
|
|
232
|
+
triggerType: 'wiki_comment',
|
|
233
|
+
summary: `Wiki comment by ${author}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async function planScheduleFire(ctx, runId) {
|
|
237
|
+
const res = await orgGet(ctx, `/schedule_runs/${runId}`);
|
|
238
|
+
if (!res.ok) {
|
|
239
|
+
if (res.status === 404) {
|
|
240
|
+
// Contract: warn + ack + drop (a post-fire PATCH can revoke access to
|
|
241
|
+
// a delivered snapshot — expected, but keep it findable in triage).
|
|
242
|
+
console.warn(`[parel-channel] schedule run ${runId} not accessible (404), dropping`);
|
|
243
|
+
return STALE;
|
|
244
|
+
}
|
|
245
|
+
return goneStatus(res.status) ? STALE : PENDING;
|
|
246
|
+
}
|
|
247
|
+
const run = (await res.json().catch(() => null));
|
|
248
|
+
if (!run?.id || !run.schedule_id)
|
|
249
|
+
return STALE;
|
|
250
|
+
const lines = [`[Event: schedule.fired]`];
|
|
251
|
+
lines.push(`[Schedule: prll://${run.schedule_id}]`);
|
|
252
|
+
lines.push(`[Run: prll://${run.id}]`);
|
|
253
|
+
if (run.scheduled_fire_at)
|
|
254
|
+
lines.push(`[Scheduled at: ${sanitizeMeta(run.scheduled_fire_at)}]`);
|
|
255
|
+
if (run.fired_attached_uri)
|
|
256
|
+
lines.push(`[Attached: ${sanitizeMeta(run.fired_attached_uri)}]`);
|
|
257
|
+
lines.push('', run.fired_description ?? '');
|
|
258
|
+
lines.push('', cliOnlyOutputHint('schedule'));
|
|
259
|
+
return {
|
|
260
|
+
outcome: 'emit',
|
|
261
|
+
subject: run.schedule_id,
|
|
262
|
+
body: lines.join('\n'),
|
|
263
|
+
stepTarget: { type: 'schedule', id: run.schedule_id },
|
|
264
|
+
triggerType: 'schedule_fire',
|
|
265
|
+
summary: `Schedule fired: ${(run.fired_description ?? run.id).slice(0, 120)}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async function planExternalTrigger(ctx, runId) {
|
|
269
|
+
const res = await orgGet(ctx, `/external-trigger-runs/${runId}`);
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
if (res.status === 404) {
|
|
272
|
+
console.warn(`[parel-channel] external trigger run ${runId} not accessible (404), dropping`);
|
|
273
|
+
return STALE;
|
|
274
|
+
}
|
|
275
|
+
return goneStatus(res.status) ? STALE : PENDING;
|
|
276
|
+
}
|
|
277
|
+
const run = (await res.json().catch(() => null));
|
|
278
|
+
if (!run?.id || !run.trigger_id)
|
|
279
|
+
return STALE;
|
|
280
|
+
const lines = [`[Event: external.trigger]`];
|
|
281
|
+
const triggerLabel = run.trigger_name
|
|
282
|
+
? `${sanitizeMeta(run.trigger_name)} (prll://${run.trigger_id})`
|
|
283
|
+
: `prll://${run.trigger_id}`;
|
|
284
|
+
lines.push(`[Trigger: ${triggerLabel}]`);
|
|
285
|
+
lines.push(`[Run: prll://${run.id}]`);
|
|
286
|
+
if (run.connection_id) {
|
|
287
|
+
const label = run.connection_display_name
|
|
288
|
+
? `${sanitizeMeta(run.connection_display_name)} (prll://${run.connection_id})`
|
|
289
|
+
: `prll://${run.connection_id}`;
|
|
290
|
+
lines.push(`[Connection: ${label}]`);
|
|
291
|
+
}
|
|
292
|
+
if (run.ingress_event_id)
|
|
293
|
+
lines.push(`[Ingress: prll://${run.ingress_event_id}]`);
|
|
294
|
+
const attached = run.trigger_snapshot?.attached_to_uri;
|
|
295
|
+
if (typeof attached === 'string' && attached)
|
|
296
|
+
lines.push(`[Attached: ${sanitizeMeta(attached)}]`);
|
|
297
|
+
if (run.connection_source_type) {
|
|
298
|
+
lines.push(`[Source: ${sanitizeMeta(run.connection_source_type)}]`);
|
|
299
|
+
}
|
|
300
|
+
if (run.ingress_event_type) {
|
|
301
|
+
lines.push(`[External event: ${sanitizeMeta(run.ingress_event_type)}]`);
|
|
302
|
+
}
|
|
303
|
+
lines.push('', run.agent_input_body ?? '');
|
|
304
|
+
lines.push('', cliOnlyOutputHint('trigger'));
|
|
305
|
+
return {
|
|
306
|
+
outcome: 'emit',
|
|
307
|
+
subject: run.trigger_id,
|
|
308
|
+
body: lines.join('\n'),
|
|
309
|
+
stepTarget: { type: 'external_trigger', id: run.trigger_id },
|
|
310
|
+
triggerType: 'external_trigger',
|
|
311
|
+
summary: `External trigger: ${run.trigger_name ?? run.trigger_id}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
async function planApprovalDecided(ctx, approvalId, chatId, actor) {
|
|
315
|
+
// Approvals are NOT org-scoped in the API surface (ENDPOINTS.APPROVAL).
|
|
316
|
+
const res = await fetch(`${parallApiUrl(ctx)}/api/v1/approvals/${approvalId}`, {
|
|
317
|
+
headers: { Authorization: `Bearer ${requireAgk(ctx)}` },
|
|
318
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok)
|
|
321
|
+
return goneStatus(res.status) ? STALE : PENDING;
|
|
322
|
+
const approval = (await res.json().catch(() => null));
|
|
323
|
+
if (!approval?.id)
|
|
324
|
+
return STALE;
|
|
325
|
+
const statusLabel = approval.status === 'approved' ? 'Approved' : 'Rejected';
|
|
326
|
+
const execInfo = approval.execution_status ? ` | execution: ${approval.execution_status}` : '';
|
|
327
|
+
const resolvedChat = chatId ?? approval.chat_id;
|
|
328
|
+
const lines = [`[Event: approval.decided]`];
|
|
329
|
+
lines.push(`[Approval: prll://${approval.id}]`);
|
|
330
|
+
if (resolvedChat)
|
|
331
|
+
lines.push(`[Chat: prll://${resolvedChat}]`);
|
|
332
|
+
lines.push(`[Decided by: ${sanitizeMeta(actor ?? approval.decided_by ?? 'system')}]`);
|
|
333
|
+
lines.push('', `${statusLabel}: ${approval.title ?? ''}${execInfo}`);
|
|
334
|
+
return {
|
|
335
|
+
outcome: 'emit',
|
|
336
|
+
// The decision belongs to the chat conversation it happened in — group
|
|
337
|
+
// and correlate there, and let the turn-end reply deliver to that chat.
|
|
338
|
+
subject: resolvedChat ?? approval.id,
|
|
339
|
+
body: lines.join('\n'),
|
|
340
|
+
stepTarget: resolvedChat ? { type: 'chat', id: resolvedChat } : null,
|
|
341
|
+
triggerType: 'approval_decided',
|
|
342
|
+
summary: `${statusLabel}: ${approval.title ?? approval.id}`,
|
|
343
|
+
chatId: resolvedChat,
|
|
344
|
+
};
|
|
345
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parall/parel-channel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/channel-prompt.ts
CHANGED
|
@@ -33,6 +33,13 @@ function sanitizeMeta(value: string): string {
|
|
|
33
33
|
.trim();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export interface PromptAttachment {
|
|
37
|
+
id: string;
|
|
38
|
+
fileName: string;
|
|
39
|
+
fileSize: number;
|
|
40
|
+
mimeType: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
export interface ChatPromptArgs {
|
|
37
44
|
chatId: string;
|
|
38
45
|
/** Display name of the chat; undefined when unresolved (best-effort). */
|
|
@@ -40,8 +47,27 @@ export interface ChatPromptArgs {
|
|
|
40
47
|
/** Sender user id (usr_); display-name enrichment is a follow-up. */
|
|
41
48
|
senderId?: string;
|
|
42
49
|
threadRootId?: string;
|
|
43
|
-
/** The inbound message text (raw, un-framed). */
|
|
50
|
+
/** The inbound message text (raw, un-framed); may be empty (attachment-only). */
|
|
44
51
|
text: string;
|
|
52
|
+
/** Message attachments — rendered as reference lines (agent fetches via CLI). */
|
|
53
|
+
attachments?: PromptAttachment[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One reference line per attachment, byte-identical to agent-core's
|
|
58
|
+
* event-format rendering: the agent learns the attachment EXISTS and pulls
|
|
59
|
+
* the bytes itself through its sandbox CLI (`parall files download prll://…`)
|
|
60
|
+
* — same discover-then-fetch contract the standard runtimes use.
|
|
61
|
+
*/
|
|
62
|
+
function attachmentLines(attachments: PromptAttachment[] | undefined): string[] {
|
|
63
|
+
if (!attachments?.length) return [];
|
|
64
|
+
return attachments.map((att) => {
|
|
65
|
+
const sizeStr =
|
|
66
|
+
att.fileSize >= 1048576
|
|
67
|
+
? `${(att.fileSize / 1048576).toFixed(1)}MB`
|
|
68
|
+
: `${Math.round(att.fileSize / 1024)}KB`;
|
|
69
|
+
return `[Attachment: prll://${att.id} | ${sanitizeMeta(att.mimeType)} | ${sizeStr} | ${sanitizeMeta(att.fileName)}]`;
|
|
70
|
+
});
|
|
45
71
|
}
|
|
46
72
|
|
|
47
73
|
/**
|
|
@@ -57,6 +83,7 @@ export function buildChatPrompt(args: ChatPromptArgs): string {
|
|
|
57
83
|
const lines = [`[Chat: ${chatLabel}]`];
|
|
58
84
|
if (args.senderId) lines.push(`[From: ${sanitizeMeta(args.senderId)}]`);
|
|
59
85
|
if (args.threadRootId) lines.push(`[Thread: ${sanitizeMeta(args.threadRootId)}]`);
|
|
86
|
+
lines.push(...attachmentLines(args.attachments));
|
|
60
87
|
lines.push('', args.text);
|
|
61
88
|
return lines.join('\n');
|
|
62
89
|
}
|
package/src/connect.ts
CHANGED
|
@@ -54,6 +54,16 @@ export function parallAgentId(ctx: ConnectorContext): string {
|
|
|
54
54
|
return String(ctx.config.parallAgentId ?? '');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Whether this deployment provisions a sandbox (shell + `parall` CLI) for the
|
|
59
|
+
* agent. Server writes `parallSandbox: "off"` into the connection config only
|
|
60
|
+
* for conversational-only deployments (no platform E2B key) — an absent field
|
|
61
|
+
* means sandbox-enabled (every config generated before the field existed was).
|
|
62
|
+
*/
|
|
63
|
+
export function sandboxEnabled(ctx: ConnectorContext): boolean {
|
|
64
|
+
return String(ctx.config.parallSandbox ?? '') !== 'off';
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
export function requireAgk(ctx: ConnectorContext): string {
|
|
58
68
|
const agk = ctx.secrets.parallApiKey;
|
|
59
69
|
if (!agk) {
|
package/src/delivery.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ChannelDelivery, ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
|
|
2
2
|
import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
|
|
3
|
+
import { childRefKnown } from './fork.js';
|
|
3
4
|
import {
|
|
5
|
+
ensureChildSession,
|
|
4
6
|
ensureSession,
|
|
7
|
+
invalidateChildSession,
|
|
5
8
|
invalidateSession,
|
|
6
9
|
patchSession,
|
|
7
10
|
REQUEST_TIMEOUT_MS,
|
|
@@ -42,15 +45,18 @@ export async function buildParallDelivery(
|
|
|
42
45
|
threadRootId?: string;
|
|
43
46
|
envelopeId?: string;
|
|
44
47
|
conversationId?: string;
|
|
48
|
+
childRef?: string;
|
|
45
49
|
};
|
|
46
50
|
const chatId = route.chatId;
|
|
47
51
|
if (!chatId) {
|
|
48
|
-
//
|
|
49
|
-
// the provider clip, agent-invoked
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
52
|
+
// No delivery target: external IM turns (channel_message — the reply
|
|
53
|
+
// already went out through the provider clip, agent-invoked) and typed
|
|
54
|
+
// dispatch turns (task/schedule/trigger/wiki — the agent acts via its
|
|
55
|
+
// sandbox CLI). The turn-end text is deliberately NOT delivered anywhere
|
|
56
|
+
// (same contract as agent-core: plain output is not the reply). Only run
|
|
57
|
+
// the redundant idle so a binding without `observe: [turn]` still closes
|
|
58
|
+
// the session.
|
|
59
|
+
if (route.envelopeId) {
|
|
54
60
|
const sessionId = await ensureSession(ctx, { timeoutMs: 3_000 });
|
|
55
61
|
if (sessionId) {
|
|
56
62
|
await idleUnlessSuperseded(ctx, route.envelopeId, sessionId);
|
|
@@ -66,7 +72,24 @@ export async function buildParallDelivery(
|
|
|
66
72
|
// Short timeout: this sits in front of the user-visible reply POST. Cache
|
|
67
73
|
// hit (every dispatch after the agent's first) is a store read; on a slow
|
|
68
74
|
// miss we degrade to a reply without the session link rather than delay it.
|
|
69
|
-
|
|
75
|
+
// A fork-owned envelope (replyRoute carries childRef) links to the CHILD's
|
|
76
|
+
// ase_ — its session panel owns this turn, not main's. Same short timeout
|
|
77
|
+
// as the main path: this sits in front of the user-visible reply POST, so
|
|
78
|
+
// a slow resolve degrades to a reply without the session link, never a
|
|
79
|
+
// 10s-delayed message.
|
|
80
|
+
const childLive = route.childRef ? await childRefKnown(ctx, route.childRef) : false;
|
|
81
|
+
if (route.childRef && !childLive) {
|
|
82
|
+
// Terminal stale-child delivery: the fork was purged (New Session) or
|
|
83
|
+
// given up on (black hole → the opening already re-planned onto main).
|
|
84
|
+
// Posting would land pre-reset output in the fresh conversation line, or
|
|
85
|
+
// duplicate a reply main already produced — drop it entirely (no POST,
|
|
86
|
+
// no retry effect).
|
|
87
|
+
console.warn('[parel-channel] dropping reply from stale fork', route.childRef);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const sessionId = route.childRef
|
|
91
|
+
? await ensureChildSession(ctx, route.childRef, { timeoutMs: 3_000 })
|
|
92
|
+
: await ensureSession(ctx, { timeoutMs: 3_000 });
|
|
70
93
|
|
|
71
94
|
const request = {
|
|
72
95
|
url: `${apiUrl}/api/v1/orgs/${orgId}/chats/${chatId}/messages`,
|
|
@@ -118,9 +141,15 @@ export async function buildParallDelivery(
|
|
|
118
141
|
// resolves this connector (npm ^1 fallback path; the artifact path freezes
|
|
119
142
|
// connector+config atomically) would otherwise never idle. Both signals
|
|
120
143
|
// run the same turnEnvelope staleness guard and idle is idempotent, so the
|
|
121
|
-
// overlap is harmless.
|
|
144
|
+
// overlap is harmless. Child turns skip the guard entirely — the marker is
|
|
145
|
+
// main's; a child's own turn ending always idles the child ase_.
|
|
122
146
|
if (sessionId) {
|
|
123
|
-
|
|
147
|
+
if (route.childRef) {
|
|
148
|
+
const result = await patchSession(ctx, sessionId, { status: 'idle' });
|
|
149
|
+
if (result === 'stale') await invalidateChildSession(ctx, route.childRef);
|
|
150
|
+
} else {
|
|
151
|
+
await idleUnlessSuperseded(ctx, route.envelopeId, sessionId);
|
|
152
|
+
}
|
|
124
153
|
}
|
|
125
154
|
return [];
|
|
126
155
|
}
|