@parall/agent-core 1.42.0 → 1.43.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/bin/channel-exec.d.ts +4 -0
- package/dist/bin/channel-exec.d.ts.map +1 -0
- package/dist/bin/channel-exec.js +246 -0
- package/dist/channel-capability.d.ts +16 -0
- package/dist/channel-capability.d.ts.map +1 -0
- package/dist/channel-capability.js +155 -0
- package/dist/channel-token.d.ts +19 -0
- package/dist/channel-token.d.ts.map +1 -0
- package/dist/channel-token.js +73 -0
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +21 -16
- package/dist/gateway-base.d.ts +15 -0
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +113 -38
- package/dist/gateway-lane-flow.d.ts +20 -1
- package/dist/gateway-lane-flow.d.ts.map +1 -1
- package/dist/gateway-lane-flow.js +78 -6
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/platform-config.d.ts +15 -0
- package/dist/platform-config.d.ts.map +1 -1
- package/dist/platform-config.js +28 -0
- package/dist/prompt-fragments.d.ts +1 -1
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +29 -7
- package/dist/skills/index.js +1 -1
- package/dist/skills/parall-platform.d.ts +1 -1
- package/dist/skills/parall-platform.d.ts.map +1 -1
- package/dist/skills/parall-platform.js +23 -4
- package/dist/types.d.ts +5 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/bin/channel-exec.ts +262 -0
- package/src/channel-capability.ts +187 -0
- package/src/channel-token.ts +92 -0
- package/src/event-format.ts +21 -16
- package/src/gateway-base.ts +137 -39
- package/src/gateway-lane-flow.ts +92 -4
- package/src/index.ts +3 -0
- package/src/platform-config.ts +44 -0
- package/src/prompt-fragments.ts +29 -7
- package/src/skills/index.ts +1 -1
- package/src/skills/parall-platform.ts +23 -4
- package/src/types.ts +5 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { GatewayLogger } from './dispatch-adapter.js';
|
|
5
|
+
import type { AgentCapability } from './platform-config.js';
|
|
6
|
+
|
|
7
|
+
// Runtime-side materializer for channel capabilities — a pure PLACEMENT
|
|
8
|
+
// layer. The server declares WHAT the agent has (agents.capabilities[]);
|
|
9
|
+
// the credential logic lives in this package's channel-exec entry
|
|
10
|
+
// (bin/channel-exec.ts, reusing channel-token.ts); this module only drops
|
|
11
|
+
// constant POINTER shims onto the capability PATH so the vendor command name
|
|
12
|
+
// (e.g. `lark-cli`, which the official skills invoke directly) routes into
|
|
13
|
+
// that entry. Pointers reference channel-exec by IN-PACKAGE ABSOLUTE PATH —
|
|
14
|
+
// no PATH lookup, no dependence on agent-writable install state; the engine
|
|
15
|
+
// ships atomically with the bridge.
|
|
16
|
+
//
|
|
17
|
+
// Revocation keeps the pointer in place (nothing to remove): the pointer's
|
|
18
|
+
// content is independent of grant state, and revocation semantics are
|
|
19
|
+
// enforced by the platform mint endpoint (403 with an agent-readable
|
|
20
|
+
// message). Deleting would let PATH fall through to a directly-installed
|
|
21
|
+
// real CLI past the mint gate — the failure mode the old deny-stub existed
|
|
22
|
+
// to prevent; a constant pointer prevents it structurally.
|
|
23
|
+
// Design: docs/engineering-design/agent-capability-fragments-design.md §5.1.
|
|
24
|
+
|
|
25
|
+
export const CAPABILITY_FEISHU_CLI = 'feishu-cli';
|
|
26
|
+
|
|
27
|
+
// Marker embedded in every generated pointer. channel-exec skips any PATH
|
|
28
|
+
// candidate whose head carries it, so a pointer (or a stray copy of one) can
|
|
29
|
+
// never be mistaken for the real vendor binary — self-recursion is
|
|
30
|
+
// structurally impossible even if the skip-dir hint is wrong.
|
|
31
|
+
export const CHANNEL_POINTER_MAGIC = 'parall channel capability pointer';
|
|
32
|
+
|
|
33
|
+
// SSOT for the shim directory: bridges prepend this to the child PATH once,
|
|
34
|
+
// unconditionally — the directory is constant, its content tracks grants, so
|
|
35
|
+
// a grant reaches even long-lived subprocesses (the shell re-resolves PATH
|
|
36
|
+
// per command) without any respawn.
|
|
37
|
+
export function capabilityBinDir(stateDir: string): string {
|
|
38
|
+
return path.join(stateDir, 'bin');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Absolute path of the channel-exec entry, resolved across BOTH distribution
|
|
42
|
+
// layouts — mirroring the daemon's resolveBbBrowserDaemonPath:
|
|
43
|
+
// - npm / hosted image: agent-core's dist tree exists on disk →
|
|
44
|
+
// ./bin/channel-exec.js sits under this module's dir.
|
|
45
|
+
// - standalone bundle (@parall/daemon / CDN self-update / desktop): esbuild
|
|
46
|
+
// flattens this module INTO bundle/parall-claude-agent.js, so ./bin/…
|
|
47
|
+
// doesn't exist; the bundle ships parall-channel-exec.js as a sibling flat
|
|
48
|
+
// artifact (scripts/bundle-daemon.mjs), resolved next to this file.
|
|
49
|
+
// Prefer the sibling (bundle) and fall through to the in-package path (dev /
|
|
50
|
+
// npm), like the bb-browser resolver — the two candidates are mutually
|
|
51
|
+
// exclusive so order only affects which stat wins in the impossible case that
|
|
52
|
+
// both exist.
|
|
53
|
+
export function channelExecEntryPath(): string {
|
|
54
|
+
const selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const sibling = path.join(selfDir, 'parall-channel-exec.js');
|
|
56
|
+
if (fs.existsSync(sibling)) return sibling;
|
|
57
|
+
return fileURLToPath(new URL('./bin/channel-exec.js', import.meta.url));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Idempotently reconcile local capability pointers with the delivered
|
|
62
|
+
* capability list. Call at boot (after the first config fetch) and on every
|
|
63
|
+
* config refresh. Never throws — a pointer write failure must not take down
|
|
64
|
+
* a config refresh (the capability simply stays unusable until the next pass).
|
|
65
|
+
*/
|
|
66
|
+
export function materializeChannelCapabilities(
|
|
67
|
+
stateDir: string,
|
|
68
|
+
capabilities: AgentCapability[],
|
|
69
|
+
log?: GatewayLogger,
|
|
70
|
+
): void {
|
|
71
|
+
try {
|
|
72
|
+
reconcileFeishuCli(stateDir, capabilities, log);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
log?.warn(`channel capability materialization failed: ${String(err)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reconcileFeishuCli(
|
|
79
|
+
stateDir: string,
|
|
80
|
+
capabilities: AgentCapability[],
|
|
81
|
+
log?: GatewayLogger,
|
|
82
|
+
): void {
|
|
83
|
+
const binDir = capabilityBinDir(stateDir);
|
|
84
|
+
const posixPath = path.join(binDir, 'lark-cli');
|
|
85
|
+
const granted = capabilities.some((c) => c.key === CAPABILITY_FEISHU_CLI);
|
|
86
|
+
const hadPointer = fs.existsSync(posixPath);
|
|
87
|
+
|
|
88
|
+
// Write/refresh pointers when GRANTED, or when a pointer already exists (a
|
|
89
|
+
// previously-granted, now-revoked agent). The refresh-on-revoke case is
|
|
90
|
+
// load-bearing for bundle self-update: the pointer embeds channel-exec's
|
|
91
|
+
// ABSOLUTE path, which resolves through the daemon's `current` symlink into
|
|
92
|
+
// a versioned dir; after an upgrade prunes the old version, a stale retained
|
|
93
|
+
// pointer would fail MODULE_NOT_FOUND instead of reaching the mint 403.
|
|
94
|
+
// Re-rendering every pass keeps the pointer aimed at the LIVE channel-exec,
|
|
95
|
+
// so a revoked agent still gets the self-explanatory 403. A NEVER-granted
|
|
96
|
+
// agent (no pointer) is left untouched — the operator's own lark-cli install
|
|
97
|
+
// stays clean.
|
|
98
|
+
if (!granted && !hadPointer) return;
|
|
99
|
+
|
|
100
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
101
|
+
const entry = channelExecEntryPath();
|
|
102
|
+
// The node binary is referenced by ABSOLUTE path (process.execPath), not the
|
|
103
|
+
// bare name `node`: packaged installs (desktop / daemon bundle) embed the
|
|
104
|
+
// runtime as `parall-node` with no plain `node` on PATH, so a bare `node`
|
|
105
|
+
// would die before channel-exec ever runs and break bundle parity at the
|
|
106
|
+
// last hop. process.execPath is the very node currently running the bridge —
|
|
107
|
+
// the parall-node in a bundle, the system node under npm.
|
|
108
|
+
const nodeExec = process.execPath;
|
|
109
|
+
writePointerIfChanged(posixPath, renderPosixPointer(nodeExec, entry, binDir, 'feishu'), log);
|
|
110
|
+
// Windows companion: cmd/PowerShell resolve executables via PATHEXT and
|
|
111
|
+
// ignore extensionless shebang files. (The agent's own Bash tool on Windows
|
|
112
|
+
// is git-bash, which uses the sh pointer above — the .cmd is the cmd/
|
|
113
|
+
// PowerShell fallback; its %* follows standard batch semantics, same as any
|
|
114
|
+
// npm-installed .cmd bin.)
|
|
115
|
+
writePointerIfChanged(
|
|
116
|
+
path.join(binDir, 'lark-cli.cmd'),
|
|
117
|
+
renderCmdPointer(nodeExec, entry, binDir, 'feishu'),
|
|
118
|
+
log,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writePointerIfChanged(filePath: string, content: string, log?: GatewayLogger): void {
|
|
123
|
+
let existing: string | null = null;
|
|
124
|
+
try {
|
|
125
|
+
existing = fs.readFileSync(filePath, 'utf8');
|
|
126
|
+
} catch {
|
|
127
|
+
existing = null;
|
|
128
|
+
}
|
|
129
|
+
if (existing !== content) {
|
|
130
|
+
fs.writeFileSync(filePath, content, { mode: 0o755 });
|
|
131
|
+
log?.info(`channel capability: pointer materialized (${path.basename(filePath)})`);
|
|
132
|
+
}
|
|
133
|
+
// Mode is enforced even when content is unchanged (a prior partial write
|
|
134
|
+
// or umask drift must not leave the pointer non-executable).
|
|
135
|
+
fs.chmodSync(filePath, 0o755);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Pointer content is versioned HERE (never delivered by the server — config
|
|
139
|
+
// carries declarations, not code). It embeds the entry's AND the bin dir's
|
|
140
|
+
// absolute paths at write time — no $(dirname)/external commands (a minimal
|
|
141
|
+
// PATH must not break the pointer), and the skip hint cannot drift. A
|
|
142
|
+
// package upgrade that moves paths changes the rendered content, and the
|
|
143
|
+
// boot-time materialize pass rewrites it (self-healing).
|
|
144
|
+
// The target binary is NOT passed on the command line — channel-exec derives
|
|
145
|
+
// it from the channel (feishu → lark-cli), so a caller cannot redirect the
|
|
146
|
+
// minted token to a different program. binDir is still passed as the skip hint.
|
|
147
|
+
export function renderPosixPointer(
|
|
148
|
+
nodeExecPath: string,
|
|
149
|
+
entryJsPath: string,
|
|
150
|
+
binDir: string,
|
|
151
|
+
channel: string,
|
|
152
|
+
): string {
|
|
153
|
+
return [
|
|
154
|
+
'#!/bin/sh',
|
|
155
|
+
`# Generated by @parall/agent-core — ${CHANNEL_POINTER_MAGIC} (do not edit).`,
|
|
156
|
+
'# Credential + exec logic lives in the agent-core package; revocation is',
|
|
157
|
+
'# enforced by the platform mint endpoint, so this pointer stays constant.',
|
|
158
|
+
// Strip Node preload-hijack vars BEFORE launching node: NODE_OPTIONS
|
|
159
|
+
// (e.g. --require=/evil.js) and NODE_PATH would execute caller-supplied code
|
|
160
|
+
// at interpreter startup — BEFORE channel-exec's own env scrub, i.e. before
|
|
161
|
+
// the mint. The pointer is a platform-authored trust-boundary artifact whose
|
|
162
|
+
// whole job is a CONTROLLED launch of the broker (absolute node, magic
|
|
163
|
+
// guard, skip-dir); this closes the same env-hijack class for node startup
|
|
164
|
+
// that the absolute node path closes for PATH, keeping the launch deterministic.
|
|
165
|
+
'unset NODE_OPTIONS NODE_PATH',
|
|
166
|
+
// "$@" preserves argv exactly (this is the agent's main path via git-bash).
|
|
167
|
+
`exec "${nodeExecPath}" "${entryJsPath}" --channel ${channel} --skip-dir "${binDir}" -- "$@"`,
|
|
168
|
+
'',
|
|
169
|
+
].join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function renderCmdPointer(
|
|
173
|
+
nodeExecPath: string,
|
|
174
|
+
entryJsPath: string,
|
|
175
|
+
binDir: string,
|
|
176
|
+
channel: string,
|
|
177
|
+
): string {
|
|
178
|
+
return [
|
|
179
|
+
'@echo off',
|
|
180
|
+
`rem Generated by @parall/agent-core - ${CHANNEL_POINTER_MAGIC} (do not edit).`,
|
|
181
|
+
// Clear Node preload-hijack vars before launching node (see the sh pointer).
|
|
182
|
+
'set "NODE_OPTIONS="',
|
|
183
|
+
'set "NODE_PATH="',
|
|
184
|
+
`"${nodeExecPath}" "${entryJsPath}" --channel ${channel} --skip-dir "${binDir}" -- %*`,
|
|
185
|
+
'',
|
|
186
|
+
].join('\r\n');
|
|
187
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Channel-capability credential client — the ONE place that talks to the
|
|
2
|
+
// platform mint endpoint. Consumed today by the short-lived exec form
|
|
3
|
+
// (bin/channel-exec.ts); the future resident forms (proxy / mcp, hosted by
|
|
4
|
+
// the bridge process) reuse this module so swimlane routing, error
|
|
5
|
+
// presentation, and any future retry/telemetry policy stay single-sourced.
|
|
6
|
+
// Design: docs/engineering-design/agent-capability-fragments-design.md §5.
|
|
7
|
+
|
|
8
|
+
export interface MintedChannelToken {
|
|
9
|
+
channel_type: string;
|
|
10
|
+
token_type: string;
|
|
11
|
+
app_id: string;
|
|
12
|
+
brand: string;
|
|
13
|
+
token: string;
|
|
14
|
+
expires_at: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Thrown for every mint failure; `message` is written for the AGENT to read
|
|
18
|
+
// (and relay to the user when the platform says the capability is revoked).
|
|
19
|
+
export class ChannelTokenError extends Error {}
|
|
20
|
+
|
|
21
|
+
const MINT_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Exchange the agent's channel grant for a short-lived provider token.
|
|
25
|
+
* Deliberately NO local caching at any layer here: the parall round trip is
|
|
26
|
+
* tens of ms (the expensive vendor exchange is absorbed by the server-side
|
|
27
|
+
* Redis cache), and every call re-passing the server gate is what makes
|
|
28
|
+
* revocation and re-credentialing bite on the very next invocation.
|
|
29
|
+
*/
|
|
30
|
+
export async function mintChannelToken(
|
|
31
|
+
channelType: string,
|
|
32
|
+
env: NodeJS.ProcessEnv,
|
|
33
|
+
): Promise<MintedChannelToken> {
|
|
34
|
+
const apiUrl = (env.PRLL_API_URL || '').replace(/\/+$/, '');
|
|
35
|
+
const apiKey = env.PRLL_API_KEY || '';
|
|
36
|
+
const orgId = env.PRLL_ORG_ID || '';
|
|
37
|
+
if (!apiUrl || !apiKey || !orgId) {
|
|
38
|
+
throw new ChannelTokenError(
|
|
39
|
+
'PRLL_API_URL/PRLL_API_KEY/PRLL_ORG_ID missing from the environment',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const headers: Record<string, string> = {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
Authorization: `Bearer ${apiKey}`,
|
|
45
|
+
};
|
|
46
|
+
// Swimlane routing must ride the mint request exactly like every SDK call —
|
|
47
|
+
// a bridge deployed in a PR swimlane mints against its own API/DB.
|
|
48
|
+
const swimlane = (env.PRLL_SWIMLANE_NAME || '').trim();
|
|
49
|
+
if (swimlane) headers['X-Prll-Swimlane'] = swimlane;
|
|
50
|
+
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timer = setTimeout(() => controller.abort(), MINT_TIMEOUT_MS);
|
|
53
|
+
let resp: Response;
|
|
54
|
+
try {
|
|
55
|
+
resp = await fetch(
|
|
56
|
+
`${apiUrl}/api/v1/orgs/${encodeURIComponent(orgId)}/agents/me/channel-token`,
|
|
57
|
+
{
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers,
|
|
60
|
+
body: JSON.stringify({ channel_type: channelType }),
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new ChannelTokenError(
|
|
66
|
+
`could not reach the Parall platform to authenticate (${String(err)}); channel actions are unavailable right now`,
|
|
67
|
+
);
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!resp.ok) {
|
|
73
|
+
let msg = `platform returned HTTP ${resp.status}`;
|
|
74
|
+
try {
|
|
75
|
+
const body = (await resp.json()) as { error?: { message?: string } };
|
|
76
|
+
if (body?.error?.message) msg = body.error.message;
|
|
77
|
+
} catch {
|
|
78
|
+
// non-JSON error body: keep the status-line message
|
|
79
|
+
}
|
|
80
|
+
throw new ChannelTokenError(msg);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Validate every field the caller actually injects into the vendor CLI, not
|
|
84
|
+
// just `token`: a 2xx missing app_id/brand would otherwise launch the real
|
|
85
|
+
// CLI with an incomplete credential env and fail opaquely downstream.
|
|
86
|
+
const minted = (await resp.json()) as Partial<MintedChannelToken>;
|
|
87
|
+
const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
|
|
88
|
+
if (!minted || !nonEmpty(minted.token) || !nonEmpty(minted.app_id) || !nonEmpty(minted.brand)) {
|
|
89
|
+
throw new ChannelTokenError('platform returned an unusable token payload');
|
|
90
|
+
}
|
|
91
|
+
return minted as MintedChannelToken;
|
|
92
|
+
}
|
package/src/event-format.ts
CHANGED
|
@@ -175,22 +175,27 @@ function buildSendMessageHint(event: ParallEvent): string {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
if (event.type === 'channel_message') {
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
178
|
+
// Single-path routing (multi-channel-architecture-design §6): with the
|
|
179
|
+
// `<provider>-cli` capability granted, the vendor CLI on PATH is THE
|
|
180
|
+
// reply path; without it there is no outbound path at all — say so
|
|
181
|
+
// instead of pointing at the retired provider clip.
|
|
182
|
+
// channelCliCapable alone decides: only feishu mints exist today, so a
|
|
183
|
+
// live grant implies Feishu even when the cosmetic provider-label lookup
|
|
184
|
+
// failed (event.channelProvider undefined).
|
|
185
|
+
if (event.channelCliCapable) {
|
|
186
|
+
const convRef = event.channelExternalConversationId
|
|
187
|
+
? `chat_id "${event.channelExternalConversationId}"`
|
|
188
|
+
: 'the conversation id named in this event';
|
|
189
|
+
// Offer the in-thread alternative whenever the inbound message id is
|
|
190
|
+
// known — otherwise the hint nudges every threaded conversation toward
|
|
191
|
+
// a new top-level message.
|
|
192
|
+
const threadAlt = event.channelExternalMessageId
|
|
193
|
+
? ` To reply threaded to this specific message, reference message_id "${event.channelExternalMessageId}".`
|
|
194
|
+
: '';
|
|
195
|
+
return `\n<system-reminder>To reply, use the official Feishu CLI on your PATH: send a message to ${convRef} with \`lark-cli im\` (see \`lark-cli im --help\` for send syntax; auth is provisioned automatically).${threadAlt} lark-cli is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
|
|
196
|
+
}
|
|
197
|
+
const platform = event.channelProvider ?? 'the external platform';
|
|
198
|
+
return `\n<system-reminder>This message arrived from ${platform}, but outbound replies are currently disabled for this org (no channel capability granted). Do NOT attempt to reply on the external platform. If action is needed, surface it inside Parall (\`parall messages send\` / \`parall dm\`). Your plain text output is not delivered anywhere.</system-reminder>`;
|
|
194
199
|
}
|
|
195
200
|
|
|
196
201
|
if (event.type === 'external_trigger' || event.targetId.startsWith('xtr_')) {
|
package/src/gateway-base.ts
CHANGED
|
@@ -148,6 +148,14 @@ export type ParallGatewayOptions = {
|
|
|
148
148
|
* this directory. Absent → legacy received/ack flow (openclaw / hermes).
|
|
149
149
|
*/
|
|
150
150
|
dispatchContextDir?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Live view of the agent's platform-granted capability keys (bridges wire
|
|
153
|
+
* PlatformConfigManager.capabilities().map(c => c.key)). Read at
|
|
154
|
+
* channel-event build time so the reply hint routes to the capability
|
|
155
|
+
* affordance (e.g. feishu-cli → lark-cli) — the single outbound path.
|
|
156
|
+
* Absent/empty → the hint states outbound is disabled.
|
|
157
|
+
*/
|
|
158
|
+
getCapabilityKeys?: () => string[];
|
|
151
159
|
onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
|
|
152
160
|
onSessionReady?: (state: {
|
|
153
161
|
activeSessionId?: string;
|
|
@@ -293,6 +301,9 @@ export class ParallAgentGateway {
|
|
|
293
301
|
// (stable mapping; avoids one connection fetch per inbound message).
|
|
294
302
|
private readonly channelConnectionProviders = new Map<string, string>();
|
|
295
303
|
private readonly dispatchedMessages = new Set<string>();
|
|
304
|
+
// Per-WorkItem failure backoff for typed dispatch consumption — see
|
|
305
|
+
// LaneFlowHost.typedRedriveBackoff in gateway-lane-flow.ts.
|
|
306
|
+
readonly typedRedriveBackoff = new Map<string, { failures: number; until: number }>();
|
|
296
307
|
private readonly forkStates = new Map<string, ActiveForkState>();
|
|
297
308
|
private readonly dispatchState: DispatchState = {
|
|
298
309
|
mainDispatching: false,
|
|
@@ -438,17 +449,28 @@ export class ParallAgentGateway {
|
|
|
438
449
|
(dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId),
|
|
439
450
|
(dispatchEventId) => {
|
|
440
451
|
if (dispatchEventId) {
|
|
441
|
-
this.
|
|
442
|
-
.
|
|
443
|
-
|
|
444
|
-
return;
|
|
452
|
+
return this.ackDispatchEvent(dispatchEventId, () => {
|
|
453
|
+
this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
|
|
454
|
+
});
|
|
445
455
|
}
|
|
446
|
-
this.opts.client
|
|
456
|
+
return this.opts.client
|
|
447
457
|
.ackDispatch(this.opts.config.org_id, {
|
|
448
458
|
source_type: 'task_activity',
|
|
449
459
|
source_id: data.id,
|
|
450
460
|
})
|
|
451
|
-
.
|
|
461
|
+
.then(
|
|
462
|
+
() => true,
|
|
463
|
+
(err) => {
|
|
464
|
+
// Same contract as ackDispatchEvent: a failed ack is a
|
|
465
|
+
// failed consume (arms backoff) and must free the hot-path
|
|
466
|
+
// dedupe so the re-drive isn't rejected by this pod forever.
|
|
467
|
+
this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
|
|
468
|
+
this.opts.log?.warn(
|
|
469
|
+
`dispatch ack failed for task ${data.id}, releasing for re-drive: ${String(err)}`,
|
|
470
|
+
);
|
|
471
|
+
return false;
|
|
472
|
+
},
|
|
473
|
+
);
|
|
452
474
|
},
|
|
453
475
|
);
|
|
454
476
|
} catch (err) {
|
|
@@ -457,7 +479,16 @@ export class ParallAgentGateway {
|
|
|
457
479
|
});
|
|
458
480
|
|
|
459
481
|
ws.on('dispatch.new', async (data: DispatchNewData) => {
|
|
460
|
-
if (data.event_type === '
|
|
482
|
+
if (data.event_type === 'task_assign') {
|
|
483
|
+
if (!data.task_id) return;
|
|
484
|
+
try {
|
|
485
|
+
await this.handleTaskAssignmentRedrive(data);
|
|
486
|
+
} catch (err) {
|
|
487
|
+
this.opts.log?.error(
|
|
488
|
+
`task assignment re-drive failed for ${data.task_id}: ${String(err)}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
} else if (data.event_type === 'task_comment') {
|
|
461
492
|
if (!data.source_id || !data.task_id) return;
|
|
462
493
|
try {
|
|
463
494
|
await this.consumeTypedDispatch(
|
|
@@ -469,9 +500,7 @@ export class ParallAgentGateway {
|
|
|
469
500
|
data.actor_id,
|
|
470
501
|
data.delivery_reason,
|
|
471
502
|
),
|
|
472
|
-
() =>
|
|
473
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
474
|
-
},
|
|
503
|
+
() => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
|
|
475
504
|
);
|
|
476
505
|
} catch (err) {
|
|
477
506
|
this.opts.log?.error(
|
|
@@ -484,9 +513,7 @@ export class ParallAgentGateway {
|
|
|
484
513
|
await this.consumeTypedDispatch(
|
|
485
514
|
{ dispatchEventId: data.id },
|
|
486
515
|
() => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason),
|
|
487
|
-
() =>
|
|
488
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
489
|
-
},
|
|
516
|
+
() => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
|
|
490
517
|
);
|
|
491
518
|
} catch (err) {
|
|
492
519
|
this.opts.log?.error(
|
|
@@ -503,9 +530,7 @@ export class ParallAgentGateway {
|
|
|
503
530
|
allowCreator: true,
|
|
504
531
|
dispatchEventId,
|
|
505
532
|
}),
|
|
506
|
-
() =>
|
|
507
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
508
|
-
},
|
|
533
|
+
() => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
|
|
509
534
|
);
|
|
510
535
|
} catch (err) {
|
|
511
536
|
this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
|
|
@@ -516,9 +541,7 @@ export class ParallAgentGateway {
|
|
|
516
541
|
await this.consumeTypedDispatch(
|
|
517
542
|
{ dispatchEventId: data.id },
|
|
518
543
|
() => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id),
|
|
519
|
-
() =>
|
|
520
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
521
|
-
},
|
|
544
|
+
() => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
|
|
522
545
|
);
|
|
523
546
|
} catch (err) {
|
|
524
547
|
this.opts.log?.error(
|
|
@@ -531,9 +554,7 @@ export class ParallAgentGateway {
|
|
|
531
554
|
await this.consumeTypedDispatch(
|
|
532
555
|
{ dispatchEventId: data.id },
|
|
533
556
|
() => this.fetchAndHandleExternalTriggerRun(data.source_id),
|
|
534
|
-
() =>
|
|
535
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
536
|
-
},
|
|
557
|
+
() => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
|
|
537
558
|
);
|
|
538
559
|
} catch (err) {
|
|
539
560
|
this.opts.log?.error(
|
|
@@ -546,9 +567,7 @@ export class ParallAgentGateway {
|
|
|
546
567
|
await this.consumeTypedDispatch(
|
|
547
568
|
{ dispatchEventId: data.id },
|
|
548
569
|
() => this.fetchAndHandleChannelMessage(data.source_id),
|
|
549
|
-
() =>
|
|
550
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
551
|
-
},
|
|
570
|
+
() => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
|
|
552
571
|
);
|
|
553
572
|
} catch (err) {
|
|
554
573
|
this.opts.log?.error(
|
|
@@ -566,9 +585,7 @@ export class ParallAgentGateway {
|
|
|
566
585
|
data.actor_id,
|
|
567
586
|
data.chat_id ?? null,
|
|
568
587
|
),
|
|
569
|
-
() =>
|
|
570
|
-
this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
|
|
571
|
-
},
|
|
588
|
+
() => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
|
|
572
589
|
);
|
|
573
590
|
} catch (err) {
|
|
574
591
|
this.opts.log?.error(
|
|
@@ -589,14 +606,11 @@ export class ParallAgentGateway {
|
|
|
589
606
|
} catch (err) {
|
|
590
607
|
this.opts.log?.error(`message re-drive failed for ${data.source_id}: ${String(err)}`);
|
|
591
608
|
}
|
|
592
|
-
} else if (data.event_type !== 'message'
|
|
609
|
+
} else if (data.event_type !== 'message') {
|
|
593
610
|
// Truly unknown event_type — log so a newly-added dispatch type
|
|
594
|
-
// not yet wired here surfaces during runtime testing. "message"
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
// (message.new, task.assigned) above and would otherwise spam
|
|
598
|
-
// info-level logs for every inbound chat message / task
|
|
599
|
-
// assignment on a busy agent.
|
|
611
|
+
// not yet wired here surfaces during runtime testing. "message" is
|
|
612
|
+
// deliberately excluded because its first delivery is owned by the
|
|
613
|
+
// message.new handler; recovered task_assign rows are handled above.
|
|
600
614
|
this.opts.log?.info(
|
|
601
615
|
`dispatch.new with unhandled event_type=${String(data.event_type)} (id=${data.id}) — no-op`,
|
|
602
616
|
);
|
|
@@ -686,11 +700,63 @@ export class ParallAgentGateway {
|
|
|
686
700
|
private consumeTypedDispatch(
|
|
687
701
|
ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
|
|
688
702
|
run: (dispatchEventId?: string) => Promise<boolean>,
|
|
689
|
-
ack: (dispatchEventId?: string) => void
|
|
703
|
+
ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
|
|
690
704
|
): Promise<void> {
|
|
691
705
|
return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
|
|
692
706
|
}
|
|
693
707
|
|
|
708
|
+
// Typed completion must wait until the administrative ack has either
|
|
709
|
+
// committed or failed. Errors stay best-effort: a failed ack leaves the row
|
|
710
|
+
// received, so Complete releases and re-drives it safely. The boolean
|
|
711
|
+
// outcome feeds the typed-consume backoff — an ack that failed must count
|
|
712
|
+
// as a failed consume, or an ack outage would clear the backoff entry and
|
|
713
|
+
// let the release re-drive spin at wire speed.
|
|
714
|
+
private ackDispatchEvent(dispatchEventId: string, onFailure?: () => void): Promise<boolean> {
|
|
715
|
+
return this.opts.client.ackDispatchByID(this.opts.config.org_id, dispatchEventId).then(
|
|
716
|
+
() => true,
|
|
717
|
+
(err) => {
|
|
718
|
+
// Complete will return the still-received item to pending and publish
|
|
719
|
+
// an immediate hint. Free its hot-path claim first, otherwise that
|
|
720
|
+
// same-process re-drive is rejected forever by the local dedupe set.
|
|
721
|
+
onFailure?.();
|
|
722
|
+
this.opts.log?.warn(
|
|
723
|
+
`dispatch ack failed for ${dispatchEventId}, releasing for re-drive: ${String(err)}`,
|
|
724
|
+
);
|
|
725
|
+
return false;
|
|
726
|
+
},
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private clearTypedDispatchDedupe(item: DispatchNewData): void {
|
|
731
|
+
switch (item.event_type) {
|
|
732
|
+
case 'task_assign':
|
|
733
|
+
case 'task_update':
|
|
734
|
+
if (item.task_id) {
|
|
735
|
+
const prefix = `${item.task_id}:`;
|
|
736
|
+
for (const key of this.dispatchedTasks) {
|
|
737
|
+
if (key.startsWith(prefix)) this.dispatchedTasks.delete(key);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
break;
|
|
741
|
+
case 'task_comment':
|
|
742
|
+
case 'wiki_comment':
|
|
743
|
+
if (item.source_id) this.dispatchedTasks.delete(`comment:${item.source_id}`);
|
|
744
|
+
break;
|
|
745
|
+
case 'schedule.fire':
|
|
746
|
+
if (item.source_id) this.dispatchedTasks.delete(`schedule_run:${item.source_id}`);
|
|
747
|
+
break;
|
|
748
|
+
case 'external_trigger':
|
|
749
|
+
if (item.source_id) this.dispatchedTasks.delete(`external_trigger_run:${item.source_id}`);
|
|
750
|
+
break;
|
|
751
|
+
case 'channel_message':
|
|
752
|
+
if (item.source_id) this.dispatchedMessages.delete(`channel_message:${item.source_id}`);
|
|
753
|
+
break;
|
|
754
|
+
case 'approval_decided':
|
|
755
|
+
if (item.source_id) this.dispatchedTasks.delete(`approval:${item.source_id}`);
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
694
760
|
private buildDispatchContext(event: ParallEvent, sessionKey: string): DispatchContext {
|
|
695
761
|
const binding = this.sessionBindings.get(sessionKey);
|
|
696
762
|
return {
|
|
@@ -2023,6 +2089,25 @@ export class ParallAgentGateway {
|
|
|
2023
2089
|
});
|
|
2024
2090
|
}
|
|
2025
2091
|
|
|
2092
|
+
// A task assignment's first delivery rides task.assigned, but a typed lane
|
|
2093
|
+
// released without an effect is re-driven as dispatch.new. Reuse the exact
|
|
2094
|
+
// typed claim path used by catch-up so that recovery does not require a
|
|
2095
|
+
// runtime reconnect.
|
|
2096
|
+
private async handleTaskAssignmentRedrive(item: DispatchNewData): Promise<void> {
|
|
2097
|
+
if (!item.task_id) return;
|
|
2098
|
+
await this.consumeTypedDispatch(
|
|
2099
|
+
{ dispatchEventId: item.id },
|
|
2100
|
+
(dispatchEventId) =>
|
|
2101
|
+
this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
|
|
2102
|
+
dispatchEventId,
|
|
2103
|
+
}),
|
|
2104
|
+
(dispatchEventId) =>
|
|
2105
|
+
this.ackDispatchEvent(dispatchEventId ?? item.id, () =>
|
|
2106
|
+
this.clearTypedDispatchDedupe(item),
|
|
2107
|
+
),
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2026
2111
|
private consumeMessageWorkItem(item: {
|
|
2027
2112
|
id: string;
|
|
2028
2113
|
source_id: string;
|
|
@@ -2410,6 +2495,19 @@ export class ParallAgentGateway {
|
|
|
2410
2495
|
}
|
|
2411
2496
|
}
|
|
2412
2497
|
|
|
2498
|
+
// The reply hint routes on the live capability grant: `<provider>-cli`
|
|
2499
|
+
// present → the vendor CLI is on PATH (broker shim) and is THE reply
|
|
2500
|
+
// path; absent → outbound is disabled for this org (flag/connection off)
|
|
2501
|
+
// and the hint must say so instead of pointing at a retired clip. The
|
|
2502
|
+
// provider label lookup above is best-effort/cosmetic — when it fails,
|
|
2503
|
+
// ANY granted `*-cli` capability keeps the hint on the CLI path: a
|
|
2504
|
+
// transient metadata miss must not flip an actively granted agent's
|
|
2505
|
+
// hint to "outbound disabled" and strand a valid external message.
|
|
2506
|
+
const keys = this.opts.getCapabilityKeys?.() ?? [];
|
|
2507
|
+
const cliCapable = provider
|
|
2508
|
+
? keys.includes(`${provider}-cli`)
|
|
2509
|
+
: keys.some((k) => k.endsWith('-cli'));
|
|
2510
|
+
|
|
2413
2511
|
const event: ParallEvent = {
|
|
2414
2512
|
type: 'channel_message',
|
|
2415
2513
|
targetId: conv.id,
|
|
@@ -2424,6 +2522,7 @@ export class ParallAgentGateway {
|
|
|
2424
2522
|
channelConversationType: conv.conversation_type || undefined,
|
|
2425
2523
|
channelExternalConversationId: conv.external_conversation_id,
|
|
2426
2524
|
channelExternalMessageId: msg.external_message_id,
|
|
2525
|
+
channelCliCapable: cliCapable,
|
|
2427
2526
|
ackSourceType: 'channel_message',
|
|
2428
2527
|
ackSourceId: msg.id,
|
|
2429
2528
|
};
|
|
@@ -2596,9 +2695,8 @@ export class ParallAgentGateway {
|
|
|
2596
2695
|
|
|
2597
2696
|
processed++;
|
|
2598
2697
|
try {
|
|
2599
|
-
const ackItem = () =>
|
|
2600
|
-
this.
|
|
2601
|
-
};
|
|
2698
|
+
const ackItem = () =>
|
|
2699
|
+
this.ackDispatchEvent(item.id, () => this.clearTypedDispatchDedupe(item));
|
|
2602
2700
|
if (item.event_type === 'task_assign' && item.task_id) {
|
|
2603
2701
|
try {
|
|
2604
2702
|
await this.consumeTypedDispatch(
|