@parall/agent-core 1.30.0 → 1.32.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/bridge-workspace.js +12 -12
- package/dist/dispatch-adapter.d.ts +15 -8
- package/dist/dispatch-adapter.d.ts.map +1 -1
- package/dist/event-format.d.ts +1 -1
- package/dist/event-format.d.ts.map +1 -1
- package/dist/event-format.js +68 -25
- package/dist/gateway-base.d.ts +15 -13
- package/dist/gateway-base.d.ts.map +1 -1
- package/dist/gateway-base.js +662 -312
- package/dist/index.d.ts +15 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -11
- package/dist/internal/attachment-input.d.ts +3 -3
- package/dist/internal/attachment-input.d.ts.map +1 -1
- package/dist/internal/attachment-input.js +61 -58
- package/dist/logger.d.ts +1 -1
- package/dist/platform-config.d.ts +28 -2
- package/dist/platform-config.d.ts.map +1 -1
- package/dist/platform-config.js +42 -11
- package/dist/prompt-fragments.d.ts +1 -1
- package/dist/prompt-fragments.d.ts.map +1 -1
- package/dist/prompt-fragments.js +28 -10
- package/dist/provider-config.d.ts +20 -0
- package/dist/provider-config.d.ts.map +1 -0
- package/dist/provider-config.js +41 -0
- package/dist/routing.d.ts +5 -5
- package/dist/routing.js +6 -6
- package/dist/session-state.d.ts +16 -0
- package/dist/session-state.d.ts.map +1 -1
- package/dist/session-state.js +45 -0
- package/dist/skills/index.d.ts +5 -4
- package/dist/skills/index.d.ts.map +1 -1
- package/dist/skills/index.js +28 -21
- package/dist/skills/parall-clips.d.ts +2 -0
- package/dist/skills/parall-clips.d.ts.map +1 -0
- package/dist/skills/parall-clips.js +44 -0
- package/dist/telemetry.d.ts +27 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +205 -0
- package/dist/types.d.ts +18 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -2
- package/src/bridge-workspace.ts +12 -12
- package/src/dispatch-adapter.ts +31 -8
- package/src/event-format.ts +80 -30
- package/src/gateway-base.ts +998 -442
- package/src/index.ts +23 -12
- package/src/internal/attachment-input.ts +127 -100
- package/src/logger.ts +1 -1
- package/src/platform-config.ts +61 -16
- package/src/prompt-fragments.ts +28 -10
- package/src/provider-config.ts +51 -0
- package/src/routing.ts +11 -11
- package/src/session-state.ts +62 -0
- package/src/skills/index.ts +34 -23
- package/src/skills/parall-clips.ts +44 -0
- package/src/telemetry.ts +252 -0
- package/src/types.ts +18 -2
package/src/prompt-fragments.ts
CHANGED
|
@@ -27,29 +27,32 @@ be direct, and care about the outcome of the work — not just the request in fr
|
|
|
27
27
|
of you.`;
|
|
28
28
|
|
|
29
29
|
function sanitizeProfileField(value: string): string {
|
|
30
|
-
return value
|
|
30
|
+
return value
|
|
31
|
+
.replace(/[\r\n]+/g, ' ')
|
|
32
|
+
.replace(/`/g, "'")
|
|
33
|
+
.trim();
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
function sanitizeProfileBlock(value: string): string {
|
|
34
|
-
return value.replace(/\r\n?/g,
|
|
37
|
+
return value.replace(/\r\n?/g, '\n').trim();
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function buildIdentity(agent?: AgentIdentity): string {
|
|
38
41
|
if (!agent) return PRLL_IDENTITY_BASE;
|
|
39
42
|
const name = sanitizeProfileField(agent.displayName);
|
|
40
|
-
const lines = [PRLL_IDENTITY_BASE,
|
|
43
|
+
const lines = [PRLL_IDENTITY_BASE, '', '### Your Parall Identity', ''];
|
|
41
44
|
lines.push(`You are **${name}** (\`prll://${agent.userId}\`).`);
|
|
42
45
|
if (agent.description) {
|
|
43
46
|
const description = sanitizeProfileBlock(agent.description);
|
|
44
47
|
if (description) {
|
|
45
|
-
lines.push(
|
|
48
|
+
lines.push('', '### Your Agent Profile', '', description);
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
lines.push(
|
|
49
|
-
|
|
52
|
+
'',
|
|
50
53
|
`When you see \`${agent.userId}\` or \`prll://${agent.userId}\` in messages, mentions, or events — that's you.`,
|
|
51
54
|
);
|
|
52
|
-
return lines.join(
|
|
55
|
+
return lines.join('\n');
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
/** @deprecated Use buildIdentity() instead. Kept for backward compat during migration. */
|
|
@@ -143,6 +146,18 @@ resolves and renders the entity title automatically.
|
|
|
143
146
|
|
|
144
147
|
prll://tsk_xxx/description#Implementation heading within task description
|
|
145
148
|
|
|
149
|
+
### Unread context
|
|
150
|
+
|
|
151
|
+
When dispatched to a chat, you may see \`[Unread: N messages | since: prll://msg_xxx]\`.
|
|
152
|
+
This shows messages since your last interaction — your read cursor advances after each
|
|
153
|
+
dispatch, so context you skip now won't appear as unread next time. Use
|
|
154
|
+
\`parall messages list <chat> --limit 20\` to fetch recent context. For large unread
|
|
155
|
+
counts (50+), fetch only recent messages rather than everything.
|
|
156
|
+
|
|
157
|
+
Thread dispatches may show \`[Thread: prll://msg_root | N replies | M unread | since: prll://msg_r]\`.
|
|
158
|
+
Same semantics — use \`parall messages list <chat> --thread-root-id <thread_root> --limit 20\` to
|
|
159
|
+
catch up on the thread.
|
|
160
|
+
|
|
146
161
|
### Reading context on demand
|
|
147
162
|
|
|
148
163
|
An event only carries the single triggering message. If you're mentioned in a
|
|
@@ -206,7 +221,7 @@ export type LocalAttachmentResult = {
|
|
|
206
221
|
};
|
|
207
222
|
|
|
208
223
|
export function renderLocalAttachmentSection(section: LocalAttachmentResult): string {
|
|
209
|
-
if (section.images.length === 0 && section.notes.length === 0) return
|
|
224
|
+
if (section.images.length === 0 && section.notes.length === 0) return '';
|
|
210
225
|
|
|
211
226
|
// Each image renders as a metadata header line plus the absolute path on
|
|
212
227
|
// its own line. The path stands alone (no quoting / escaping) so file-
|
|
@@ -215,7 +230,7 @@ export function renderLocalAttachmentSection(section: LocalAttachmentResult): st
|
|
|
215
230
|
// the bridge operator. A pathological workspace dir with embedded newlines
|
|
216
231
|
// would split across lines; that's exotic enough not to warrant scrubbing
|
|
217
232
|
// every consumer's path through escape rules.
|
|
218
|
-
const lines = [
|
|
233
|
+
const lines = ['[Local attachment files]'];
|
|
219
234
|
for (const image of section.images) {
|
|
220
235
|
lines.push(
|
|
221
236
|
`- prll://${image.attachmentId} (${sanitizePromptMeta(image.mimeType)}, ${formatBytes(image.fileSize)}, ${sanitizePromptMeta(image.fileName)})`,
|
|
@@ -223,11 +238,14 @@ export function renderLocalAttachmentSection(section: LocalAttachmentResult): st
|
|
|
223
238
|
);
|
|
224
239
|
}
|
|
225
240
|
lines.push(...section.notes);
|
|
226
|
-
return lines.join(
|
|
241
|
+
return lines.join('\n');
|
|
227
242
|
}
|
|
228
243
|
|
|
229
244
|
function sanitizePromptMeta(value: string): string {
|
|
230
|
-
return value
|
|
245
|
+
return value
|
|
246
|
+
.replace(/[\r\n]+/g, ' ')
|
|
247
|
+
.replace(/[()]/g, ' ')
|
|
248
|
+
.trim();
|
|
231
249
|
}
|
|
232
250
|
|
|
233
251
|
export function formatBytes(bytes: number): string {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface ProviderConfig {
|
|
2
|
+
llm_source?: string;
|
|
3
|
+
openai_api_key?: string;
|
|
4
|
+
openai_base_url?: string;
|
|
5
|
+
anthropic_auth_token?: string;
|
|
6
|
+
anthropic_base_url?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function llmSource(pc?: ProviderConfig): string {
|
|
10
|
+
return effectiveLLMSourceExplicit(pc) || 'parall';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns the explicitly-configured LLM source — the llm_source value when
|
|
15
|
+
* set, or "custom" when BYO provider credentials are present — or "" when the
|
|
16
|
+
* provider_config carries no source signal at all (e.g. the empty `{}` default
|
|
17
|
+
* that self-hosted agents always have). The daemon supervisor uses the empty
|
|
18
|
+
* case to defer to the machine-level llm_source instead of letting `{}` shadow
|
|
19
|
+
* it as "parall". Mirrors the Go `ProviderConfig.EffectiveLLMSourceExplicit`.
|
|
20
|
+
*/
|
|
21
|
+
export function effectiveLLMSourceExplicit(pc?: ProviderConfig): string {
|
|
22
|
+
if (pc?.llm_source) return pc.llm_source;
|
|
23
|
+
if (
|
|
24
|
+
pc?.openai_api_key ||
|
|
25
|
+
pc?.openai_base_url ||
|
|
26
|
+
pc?.anthropic_auth_token ||
|
|
27
|
+
pc?.anthropic_base_url
|
|
28
|
+
) {
|
|
29
|
+
return 'custom';
|
|
30
|
+
}
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearAllProviderCreds(env: NodeJS.ProcessEnv): void {
|
|
35
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
36
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
37
|
+
delete env.ANTHROPIC_API_KEY;
|
|
38
|
+
delete env.OPENAI_API_KEY;
|
|
39
|
+
delete env.OPENAI_BASE_URL;
|
|
40
|
+
delete env.PRLL_CLAUDE_ALLOW_API_KEY;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseProviderConfig(env: NodeJS.ProcessEnv): ProviderConfig | undefined {
|
|
44
|
+
const raw = env.PRLL_PROVIDER_CONFIG?.trim();
|
|
45
|
+
if (!raw) return undefined;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw) as ProviderConfig;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new Error(`Invalid PRLL_PROVIDER_CONFIG JSON: ${String(err)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/routing.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { DispatchState, ParallEvent } from
|
|
1
|
+
import type { DispatchState, ParallEvent } from './types.js';
|
|
2
2
|
|
|
3
3
|
/** Where an inbound event should be routed. */
|
|
4
4
|
export type TriggerDisposition =
|
|
5
|
-
| { action:
|
|
6
|
-
| { action:
|
|
7
|
-
| { action:
|
|
8
|
-
| { action:
|
|
5
|
+
| { action: 'main' }
|
|
6
|
+
| { action: 'buffer-main' }
|
|
7
|
+
| { action: 'buffer-fork'; forkKey: string }
|
|
8
|
+
| { action: 'new-fork' };
|
|
9
9
|
|
|
10
10
|
/** Pluggable strategy for routing triggers when main session is busy. */
|
|
11
11
|
export type RoutingStrategy = (event: ParallEvent, state: DispatchState) => TriggerDisposition;
|
|
@@ -21,17 +21,17 @@ const MAX_CONCURRENT_FORKS = 20;
|
|
|
21
21
|
*/
|
|
22
22
|
export const defaultRoutingStrategy: RoutingStrategy = (event, state) => {
|
|
23
23
|
if (state.mainCurrentTargetId === event.targetId) {
|
|
24
|
-
return { action:
|
|
24
|
+
return { action: 'buffer-main' };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const existingForkKey = state.activeForks.get(event.targetId);
|
|
28
|
-
if (existingForkKey) return { action:
|
|
28
|
+
if (existingForkKey) return { action: 'buffer-fork', forkKey: existingForkKey };
|
|
29
29
|
|
|
30
30
|
if (state.activeForks.size >= MAX_CONCURRENT_FORKS) {
|
|
31
|
-
return { action:
|
|
31
|
+
return { action: 'buffer-main' };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
return { action:
|
|
34
|
+
return { action: 'new-fork' };
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
/** Route an inbound event based on current dispatch state. */
|
|
@@ -41,7 +41,7 @@ export function routeTrigger(
|
|
|
41
41
|
strategy: RoutingStrategy = defaultRoutingStrategy,
|
|
42
42
|
): TriggerDisposition {
|
|
43
43
|
const existingForkKey = state.activeForks.get(event.targetId);
|
|
44
|
-
if (existingForkKey) return { action:
|
|
45
|
-
if (!state.mainDispatching) return { action:
|
|
44
|
+
if (existingForkKey) return { action: 'buffer-fork', forkKey: existingForkKey };
|
|
45
|
+
if (!state.mainDispatching) return { action: 'main' };
|
|
46
46
|
return strategy(event, state);
|
|
47
47
|
}
|
package/src/session-state.ts
CHANGED
|
@@ -67,3 +67,65 @@ export function getDispatchNoReply(sessionKey: string): boolean {
|
|
|
67
67
|
export function clearDispatchNoReply(sessionKey: string) {
|
|
68
68
|
dispatchNoReplyMap.delete(normalizeSessionKey(sessionKey));
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Per-dispatch metrics — populated by runtime adapters, reported by gateway
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export type DispatchMetrics = {
|
|
76
|
+
deliver_text_chunks: number;
|
|
77
|
+
deliver_text_chars: number;
|
|
78
|
+
message_send_attempts: number;
|
|
79
|
+
message_send_successes: number;
|
|
80
|
+
no_reply_called: boolean;
|
|
81
|
+
tool_call_count: number;
|
|
82
|
+
started_at: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const dispatchMetricsMap = new Map<string, DispatchMetrics>();
|
|
86
|
+
|
|
87
|
+
export function resetDispatchMetrics(sessionKey: string): void {
|
|
88
|
+
dispatchMetricsMap.set(normalizeSessionKey(sessionKey), {
|
|
89
|
+
deliver_text_chunks: 0,
|
|
90
|
+
deliver_text_chars: 0,
|
|
91
|
+
message_send_attempts: 0,
|
|
92
|
+
message_send_successes: 0,
|
|
93
|
+
no_reply_called: false,
|
|
94
|
+
tool_call_count: 0,
|
|
95
|
+
started_at: Date.now(),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getDispatchMetrics(sessionKey: string): DispatchMetrics | undefined {
|
|
100
|
+
return dispatchMetricsMap.get(normalizeSessionKey(sessionKey));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function clearDispatchMetrics(sessionKey: string): void {
|
|
104
|
+
dispatchMetricsMap.delete(normalizeSessionKey(sessionKey));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function recordDeliverText(sessionKey: string, charCount: number): void {
|
|
108
|
+
const m = dispatchMetricsMap.get(normalizeSessionKey(sessionKey));
|
|
109
|
+
if (!m) return;
|
|
110
|
+
m.deliver_text_chunks++;
|
|
111
|
+
m.deliver_text_chars += charCount;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function recordMessageSend(sessionKey: string, success: boolean): void {
|
|
115
|
+
const m = dispatchMetricsMap.get(normalizeSessionKey(sessionKey));
|
|
116
|
+
if (!m) return;
|
|
117
|
+
m.message_send_attempts++;
|
|
118
|
+
if (success) m.message_send_successes++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function recordNoReply(sessionKey: string): void {
|
|
122
|
+
const m = dispatchMetricsMap.get(normalizeSessionKey(sessionKey));
|
|
123
|
+
if (!m) return;
|
|
124
|
+
m.no_reply_called = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function recordToolCall(sessionKey: string): void {
|
|
128
|
+
const m = dispatchMetricsMap.get(normalizeSessionKey(sessionKey));
|
|
129
|
+
if (!m) return;
|
|
130
|
+
m.tool_call_count++;
|
|
131
|
+
}
|
package/src/skills/index.ts
CHANGED
|
@@ -1,52 +1,63 @@
|
|
|
1
|
-
import * as fs from
|
|
2
|
-
import * as path from
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
3
|
|
|
4
|
-
export { PARALL_PLATFORM_SKILL } from
|
|
5
|
-
export { PARALL_TASKS_SKILL } from
|
|
6
|
-
export { PARALL_WIKI_SKILL } from
|
|
7
|
-
export { PARALL_SCHEDULES_SKILL } from
|
|
4
|
+
export { PARALL_PLATFORM_SKILL } from './parall-platform.js';
|
|
5
|
+
export { PARALL_TASKS_SKILL } from './parall-tasks.js';
|
|
6
|
+
export { PARALL_WIKI_SKILL } from './parall-wiki.js';
|
|
7
|
+
export { PARALL_SCHEDULES_SKILL } from './parall-schedules.js';
|
|
8
|
+
export { PARALL_CLIPS_SKILL } from './parall-clips.js';
|
|
8
9
|
|
|
9
|
-
import { PARALL_PLATFORM_SKILL } from
|
|
10
|
-
import { PARALL_TASKS_SKILL } from
|
|
11
|
-
import { PARALL_WIKI_SKILL } from
|
|
12
|
-
import { PARALL_SCHEDULES_SKILL } from
|
|
10
|
+
import { PARALL_PLATFORM_SKILL } from './parall-platform.js';
|
|
11
|
+
import { PARALL_TASKS_SKILL } from './parall-tasks.js';
|
|
12
|
+
import { PARALL_WIKI_SKILL } from './parall-wiki.js';
|
|
13
|
+
import { PARALL_SCHEDULES_SKILL } from './parall-schedules.js';
|
|
14
|
+
import { PARALL_CLIPS_SKILL } from './parall-clips.js';
|
|
13
15
|
|
|
14
16
|
export type SkillMeta = { name: string; description: string; content: string };
|
|
15
17
|
|
|
16
18
|
export const SKILLS: SkillMeta[] = [
|
|
17
19
|
{
|
|
18
|
-
name:
|
|
19
|
-
description:
|
|
20
|
+
name: 'parall-platform',
|
|
21
|
+
description:
|
|
22
|
+
"Parall platform queries and lightweight agent provisioning: list org members, agents, chats, read message history, check identity, or create another agent. Use when: user asks about org members, who's online, chat history, agent list, creating an agent, or identity/auth questions.",
|
|
20
23
|
content: PARALL_PLATFORM_SKILL,
|
|
21
24
|
},
|
|
22
25
|
{
|
|
23
|
-
name:
|
|
24
|
-
description:
|
|
26
|
+
name: 'parall-tasks',
|
|
27
|
+
description:
|
|
28
|
+
'Parall task operations: create, update, comment on, and query tasks and projects. Use when: user asks to create a task, update task status, add comments, list tasks, or manage projects.',
|
|
25
29
|
content: PARALL_TASKS_SKILL,
|
|
26
30
|
},
|
|
27
31
|
{
|
|
28
|
-
name:
|
|
29
|
-
description:
|
|
32
|
+
name: 'parall-wiki',
|
|
33
|
+
description:
|
|
34
|
+
'Parall wiki operations: read, search, edit, and propose changes to organization knowledge bases. Use when: user asks to read/write docs, edit wiki pages, search knowledge base, propose changes, or review changesets.',
|
|
30
35
|
content: PARALL_WIKI_SKILL,
|
|
31
36
|
},
|
|
32
37
|
{
|
|
33
|
-
name:
|
|
34
|
-
description:
|
|
38
|
+
name: 'parall-schedules',
|
|
39
|
+
description:
|
|
40
|
+
'Parall schedule operations: create / pause / resume / cancel recurring or one-shot time triggers; respond to schedule fire events. Use when: user asks to set up a recurring reminder, schedule a delayed prompt, run cron-like work, or when the agent receives an `[Event: schedule.fired]` dispatch.',
|
|
35
41
|
content: PARALL_SCHEDULES_SKILL,
|
|
36
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: 'parall-clips',
|
|
45
|
+
description:
|
|
46
|
+
'Parall clip operations: list installed clips, invoke clip commands, inspect clip details. Use when: the task requires external capabilities (GitHub, web search, etc.), user asks about available tools/clips, or you need to call a clip command.',
|
|
47
|
+
content: PARALL_CLIPS_SKILL,
|
|
48
|
+
},
|
|
37
49
|
];
|
|
38
50
|
|
|
39
51
|
/** Write plain skill markdown files to a target directory (CC/Codex). */
|
|
40
52
|
export function writeSkillFiles(targetDir: string): void {
|
|
41
53
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
42
54
|
for (const skill of SKILLS) {
|
|
43
|
-
fs.writeFileSync(path.join(targetDir, `${skill.name}.md`), skill.content,
|
|
55
|
+
fs.writeFileSync(path.join(targetDir, `${skill.name}.md`), skill.content, 'utf8');
|
|
44
56
|
}
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
|
|
48
59
|
export function buildSkillReferences(workspaceDir: string): string {
|
|
49
|
-
const dir = path.join(workspaceDir,
|
|
50
|
-
const lines = SKILLS.map((s) => `- ${s.description.split(
|
|
51
|
-
return `## Platform Skills (read on demand)\n\n${lines.join(
|
|
60
|
+
const dir = path.join(workspaceDir, '.parall', 'skills');
|
|
61
|
+
const lines = SKILLS.map((s) => `- ${s.description.split(':')[0]}: \`${dir}/${s.name}.md\``);
|
|
62
|
+
return `## Platform Skills (read on demand)\n\n${lines.join('\n')}\n`;
|
|
52
63
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const PARALL_CLIPS_SKILL = `# Parall Clips
|
|
2
|
+
|
|
3
|
+
Clips are capability extensions — packaged toolkits that give you extra commands (e.g. GitHub operations, web search, code analysis). Clips installed in the org are available for any agent to invoke via the CLI.
|
|
4
|
+
|
|
5
|
+
## Discovering available clips
|
|
6
|
+
|
|
7
|
+
\`\`\`bash
|
|
8
|
+
# List all clips installed in the org
|
|
9
|
+
parall clip list
|
|
10
|
+
|
|
11
|
+
# Show detailed info about a clip (manifest, commands, version)
|
|
12
|
+
parall clip info <alias>
|
|
13
|
+
\`\`\`
|
|
14
|
+
|
|
15
|
+
## Invoking a clip command
|
|
16
|
+
|
|
17
|
+
\`\`\`bash
|
|
18
|
+
# Invoke a command on a clip by alias
|
|
19
|
+
parall clip invoke <alias> <command> [input]
|
|
20
|
+
|
|
21
|
+
# input is optional — when provided, it can be a JSON string or plain text
|
|
22
|
+
parall clip invoke github-tools list-repos '{"org": "acme"}'
|
|
23
|
+
parall clip invoke web-search search "latest Node.js LTS version"
|
|
24
|
+
|
|
25
|
+
# Custom timeout (default 30s)
|
|
26
|
+
parall clip invoke github-tools create-issue '{"title": "Bug report"}' --timeout 60000
|
|
27
|
+
\`\`\`
|
|
28
|
+
|
|
29
|
+
## How clips work
|
|
30
|
+
|
|
31
|
+
1. An org admin installs a clip from the Pinix registry or creates a custom one
|
|
32
|
+
2. \`parall clip list\` shows every clip installed in the org
|
|
33
|
+
3. You can only **invoke** clips that an admin has **bound to you** — invoking an unbound clip returns a "not bound" error. Ask an admin to bind the clip if you need it.
|
|
34
|
+
4. Each clip exposes one or more named commands with typed input/output
|
|
35
|
+
|
|
36
|
+
## When to use clips
|
|
37
|
+
|
|
38
|
+
- Check \`parall clip list\` when a task requires capabilities beyond your built-in tools (e.g. GitHub API, external services, specialized analysis)
|
|
39
|
+
- Use \`parall clip info <alias>\` to discover available commands and their expected input format
|
|
40
|
+
- If \`parall clip invoke\` reports the clip isn't bound to you, that clip exists in the org but hasn't been granted to you — ask an admin to bind it
|
|
41
|
+
- Clip invocations return JSON output on success or an error message on failure
|
|
42
|
+
|
|
43
|
+
CLI command results are JSON on stdout.
|
|
44
|
+
`;
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import {
|
|
3
|
+
trace,
|
|
4
|
+
metrics,
|
|
5
|
+
type Span,
|
|
6
|
+
type Tracer,
|
|
7
|
+
type Counter,
|
|
8
|
+
type Histogram,
|
|
9
|
+
SpanStatusCode,
|
|
10
|
+
} from '@opentelemetry/api';
|
|
11
|
+
import { type Logger, SeverityNumber } from '@opentelemetry/api-logs';
|
|
12
|
+
import type { GatewayLogger } from './dispatch-adapter.js';
|
|
13
|
+
import type { DispatchMetrics } from './session-state.js';
|
|
14
|
+
import type { ParallEvent } from './types.js';
|
|
15
|
+
|
|
16
|
+
let initialized = false;
|
|
17
|
+
let shutdownFn: (() => Promise<void>) | null = null;
|
|
18
|
+
|
|
19
|
+
let tracer: Tracer | null = null;
|
|
20
|
+
let dispatchCounter: Counter | null = null;
|
|
21
|
+
let dispatchDuration: Histogram | null = null;
|
|
22
|
+
let missingReplyCounter: Counter | null = null;
|
|
23
|
+
let otelLogger: Logger | null = null;
|
|
24
|
+
|
|
25
|
+
function resolveTargetType(targetId: string): string {
|
|
26
|
+
if (targetId.startsWith('cht_')) return 'chat';
|
|
27
|
+
if (targetId.startsWith('tsk_')) return 'task';
|
|
28
|
+
if (targetId.startsWith('sch_')) return 'schedule';
|
|
29
|
+
return 'unknown';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TelemetryHandle {
|
|
33
|
+
shutdown: () => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Initialize agent telemetry. All agents export OTLP to the Parall
|
|
38
|
+
* telemetry-service (`PRLL_API_URL/otel`), authenticated with `PRLL_API_KEY`.
|
|
39
|
+
* The service canonicalizes identity from the token and proxies to SigNoz.
|
|
40
|
+
*
|
|
41
|
+
* Resource attributes include machine/agent/org identity from env.
|
|
42
|
+
* Returns a no-op handle when `PRLL_API_URL` is absent (local dev).
|
|
43
|
+
*/
|
|
44
|
+
export async function initAgentTelemetry(
|
|
45
|
+
serviceName: string,
|
|
46
|
+
runtimeType: string,
|
|
47
|
+
): Promise<TelemetryHandle> {
|
|
48
|
+
const noopHandle: TelemetryHandle = { shutdown: async () => {} };
|
|
49
|
+
|
|
50
|
+
const apiUrl = process.env.PRLL_API_URL;
|
|
51
|
+
const apiKey = process.env.PRLL_API_KEY;
|
|
52
|
+
if (!apiUrl || !apiKey) {
|
|
53
|
+
return noopHandle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const otelEndpoint = apiUrl.replace(/\/$/, '') + '/otel';
|
|
58
|
+
|
|
59
|
+
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-proto');
|
|
60
|
+
const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-proto');
|
|
61
|
+
const { OTLPLogExporter } = await import('@opentelemetry/exporter-logs-otlp-proto');
|
|
62
|
+
const { NodeTracerProvider, BatchSpanProcessor } = await import(
|
|
63
|
+
'@opentelemetry/sdk-trace-node'
|
|
64
|
+
);
|
|
65
|
+
const { MeterProvider, PeriodicExportingMetricReader } = await import(
|
|
66
|
+
'@opentelemetry/sdk-metrics'
|
|
67
|
+
);
|
|
68
|
+
const { LoggerProvider, BatchLogRecordProcessor } = await import('@opentelemetry/sdk-logs');
|
|
69
|
+
const { Resource } = await import('@opentelemetry/resources');
|
|
70
|
+
|
|
71
|
+
const resource = new Resource({
|
|
72
|
+
'service.name': serviceName,
|
|
73
|
+
'service.version': process.env.npm_package_version || 'unknown',
|
|
74
|
+
'deployment.environment.name':
|
|
75
|
+
process.env.PRLL_SERVER_ENV || process.env.NODE_ENV || 'development',
|
|
76
|
+
'parall.runtime_type': runtimeType,
|
|
77
|
+
'parall.agent_id': process.env.PRLL_AGENT_ID || '',
|
|
78
|
+
'parall.machine_id': process.env.PRLL_MACHINE_ID || '',
|
|
79
|
+
'parall.org_id': process.env.PRLL_ORG_ID || '',
|
|
80
|
+
'parall.daemon_mode': process.env.PRLL_DAEMON_MODE === '1',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const authHeaders = { Authorization: `Bearer ${apiKey}` };
|
|
84
|
+
|
|
85
|
+
const traceExporter = new OTLPTraceExporter({
|
|
86
|
+
url: `${otelEndpoint}/v1/traces`,
|
|
87
|
+
headers: authHeaders,
|
|
88
|
+
});
|
|
89
|
+
const tracerProvider = new NodeTracerProvider({ resource });
|
|
90
|
+
tracerProvider.addSpanProcessor(new BatchSpanProcessor(traceExporter));
|
|
91
|
+
tracerProvider.register();
|
|
92
|
+
|
|
93
|
+
const metricExporter = new OTLPMetricExporter({
|
|
94
|
+
url: `${otelEndpoint}/v1/metrics`,
|
|
95
|
+
headers: authHeaders,
|
|
96
|
+
});
|
|
97
|
+
const metricReader = new PeriodicExportingMetricReader({
|
|
98
|
+
exporter: metricExporter,
|
|
99
|
+
exportIntervalMillis: 15_000,
|
|
100
|
+
});
|
|
101
|
+
const meterProvider = new MeterProvider({ resource, readers: [metricReader] });
|
|
102
|
+
metrics.setGlobalMeterProvider(meterProvider);
|
|
103
|
+
|
|
104
|
+
const logExporter = new OTLPLogExporter({
|
|
105
|
+
url: `${otelEndpoint}/v1/logs`,
|
|
106
|
+
headers: authHeaders,
|
|
107
|
+
});
|
|
108
|
+
const loggerProvider = new LoggerProvider({ resource });
|
|
109
|
+
loggerProvider.addLogRecordProcessor(new BatchLogRecordProcessor(logExporter));
|
|
110
|
+
|
|
111
|
+
const meter = metrics.getMeter('parall.agent');
|
|
112
|
+
tracer = trace.getTracer('parall.agent');
|
|
113
|
+
otelLogger = loggerProvider.getLogger('parall.agent');
|
|
114
|
+
dispatchCounter = meter.createCounter('parall.dispatch.count', {
|
|
115
|
+
description: 'Number of dispatch cycles completed',
|
|
116
|
+
});
|
|
117
|
+
dispatchDuration = meter.createHistogram('parall.dispatch.duration', {
|
|
118
|
+
description: 'Dispatch cycle duration in milliseconds',
|
|
119
|
+
unit: 'ms',
|
|
120
|
+
});
|
|
121
|
+
missingReplyCounter = meter.createCounter('parall.dispatch.missing_reply', {
|
|
122
|
+
description: 'Dispatches where agent produced text but sent no reply message',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
initialized = true;
|
|
126
|
+
shutdownFn = async () => {
|
|
127
|
+
await tracerProvider.forceFlush();
|
|
128
|
+
await meterProvider.forceFlush();
|
|
129
|
+
await loggerProvider.forceFlush();
|
|
130
|
+
await tracerProvider.shutdown();
|
|
131
|
+
await meterProvider.shutdown();
|
|
132
|
+
await loggerProvider.shutdown();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
shutdown: async () => {
|
|
137
|
+
if (shutdownFn) await shutdownFn();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
return noopHandle;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function startDispatchSpan(
|
|
146
|
+
event: ParallEvent,
|
|
147
|
+
runtimeType: string,
|
|
148
|
+
sessionKey: string,
|
|
149
|
+
): Span | null {
|
|
150
|
+
if (!initialized || !tracer) return null;
|
|
151
|
+
return tracer.startSpan('parall.dispatch', {
|
|
152
|
+
attributes: {
|
|
153
|
+
'dispatch.target_type': resolveTargetType(event.targetId),
|
|
154
|
+
'dispatch.event_type': event.type,
|
|
155
|
+
'dispatch.runtime_type': runtimeType,
|
|
156
|
+
'dispatch.session_key': sessionKey,
|
|
157
|
+
'dispatch.message_id': event.messageId,
|
|
158
|
+
'dispatch.target_id': event.targetId,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function endDispatchSpan(
|
|
164
|
+
span: Span | null,
|
|
165
|
+
metricsSnapshot: DispatchMetrics | undefined,
|
|
166
|
+
error?: unknown,
|
|
167
|
+
): void {
|
|
168
|
+
if (!span) return;
|
|
169
|
+
if (metricsSnapshot) {
|
|
170
|
+
span.setAttributes({
|
|
171
|
+
'dispatch.deliver_text_chunks': metricsSnapshot.deliver_text_chunks,
|
|
172
|
+
'dispatch.deliver_text_chars': metricsSnapshot.deliver_text_chars,
|
|
173
|
+
'dispatch.message_send_attempts': metricsSnapshot.message_send_attempts,
|
|
174
|
+
'dispatch.message_send_successes': metricsSnapshot.message_send_successes,
|
|
175
|
+
'dispatch.no_reply_called': metricsSnapshot.no_reply_called,
|
|
176
|
+
'dispatch.tool_call_count': metricsSnapshot.tool_call_count,
|
|
177
|
+
'dispatch.duration_ms': Date.now() - metricsSnapshot.started_at,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (error) {
|
|
181
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
|
|
182
|
+
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
183
|
+
}
|
|
184
|
+
span.end();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function recordDispatchMetric(
|
|
188
|
+
event: ParallEvent,
|
|
189
|
+
runtimeType: string,
|
|
190
|
+
durationMs: number,
|
|
191
|
+
): void {
|
|
192
|
+
if (!initialized) return;
|
|
193
|
+
const attrs = {
|
|
194
|
+
target_type: resolveTargetType(event.targetId),
|
|
195
|
+
event_type: event.type,
|
|
196
|
+
runtime_type: runtimeType,
|
|
197
|
+
};
|
|
198
|
+
dispatchCounter?.add(1, attrs);
|
|
199
|
+
dispatchDuration?.record(durationMs, attrs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function recordMissingReply(runtimeType: string): void {
|
|
203
|
+
if (!initialized) return;
|
|
204
|
+
missingReplyCounter?.add(1, { runtime_type: runtimeType });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sessionKeyStorage = new AsyncLocalStorage<string>();
|
|
208
|
+
|
|
209
|
+
export function runWithSessionKey<T>(sessionKey: string, fn: () => T): T {
|
|
210
|
+
return sessionKeyStorage.run(sessionKey, fn);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Create a GatewayLogger that forwards all levels to OTLP logs.
|
|
215
|
+
* Two layers: "agent" (runtime) and "daemon".
|
|
216
|
+
*/
|
|
217
|
+
export function createOtelLogger(layer: 'agent' | 'daemon', prefix: string): GatewayLogger {
|
|
218
|
+
const ts = () => new Date().toISOString();
|
|
219
|
+
const emit = (severity: 'INFO' | 'WARN' | 'ERROR', msg: string) => {
|
|
220
|
+
if (!otelLogger) return;
|
|
221
|
+
const severityNumber =
|
|
222
|
+
severity === 'ERROR'
|
|
223
|
+
? SeverityNumber.ERROR
|
|
224
|
+
: severity === 'WARN'
|
|
225
|
+
? SeverityNumber.WARN
|
|
226
|
+
: SeverityNumber.INFO;
|
|
227
|
+
const attrs: Record<string, string> = { 'log.layer': layer, 'log.prefix': prefix };
|
|
228
|
+
const sk = sessionKeyStorage.getStore();
|
|
229
|
+
if (sk) attrs['session.key'] = sk;
|
|
230
|
+
otelLogger.emit({
|
|
231
|
+
severityNumber,
|
|
232
|
+
severityText: severity,
|
|
233
|
+
body: msg,
|
|
234
|
+
attributes: attrs,
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
info: (msg: string) => {
|
|
239
|
+
console.log(`${ts()} [${prefix}] ${msg}`);
|
|
240
|
+
emit('INFO', msg);
|
|
241
|
+
},
|
|
242
|
+
warn: (msg: string) => {
|
|
243
|
+
console.warn(`${ts()} [${prefix}] ${msg}`);
|
|
244
|
+
emit('WARN', msg);
|
|
245
|
+
},
|
|
246
|
+
error: (msg: string) => {
|
|
247
|
+
console.error(`${ts()} [${prefix}] ${msg}`);
|
|
248
|
+
emit('ERROR', msg);
|
|
249
|
+
},
|
|
250
|
+
child: (sub: string) => createOtelLogger(layer, `${prefix}:${sub}`),
|
|
251
|
+
};
|
|
252
|
+
}
|