deepseek-agent-acp 0.1.1 → 0.2.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/index.js CHANGED
@@ -15,6 +15,7 @@ import readline from 'node:readline';
15
15
  import { format } from 'node:util';
16
16
  import { StreamAccumulator, parseSSEStream } from './sse-parser.js';
17
17
  import { McpClient } from './mcp-client.js';
18
+ import { initTelemetry, registerSecret, parseNostrContext } from './telemetry.js';
18
19
  // ============================================================================
19
20
  // 1. STDIO HYGIENE
20
21
  // Redirect all console output to process.stderr so process.stdout is 100%
@@ -80,6 +81,11 @@ const DEEPSEEK_API_KEY = loadSecretKey();
80
81
  if (!DEEPSEEK_API_KEY) {
81
82
  console.warn('DEEPSEEK_API_KEY is not set. Ensure secrets are provided in environment or secrets file.');
82
83
  }
84
+ registerSecret(DEEPSEEK_API_KEY);
85
+ // OpenTelemetry & Arize Phoenix Telemetry Service
86
+ const telemetryService = initTelemetry({
87
+ secrets: DEEPSEEK_API_KEY ? [DEEPSEEK_API_KEY] : []
88
+ });
83
89
  // Configuration
84
90
  const BASE_URL = (process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com').replace(/\/+$/, '');
85
91
  const MODEL = process.env.DEEPSEEK_MODEL ?? 'deepseek-chat';
@@ -285,6 +291,8 @@ async function handleSessionPrompt(id, params) {
285
291
  session.abortController = new AbortController();
286
292
  session.activePromptId = id ?? null;
287
293
  session.messages.push({ role: 'user', content: promptText });
294
+ const nostrMeta = parseNostrContext(promptText);
295
+ const turnHandle = telemetryService?.startTurnSpan(sessionId, promptText, MODEL, nostrMeta);
288
296
  const maxIterations = getIterationLimit(MODEL);
289
297
  let iteration = 0;
290
298
  let lastToolError = null;
@@ -298,6 +306,7 @@ async function handleSessionPrompt(id, params) {
298
306
  }
299
307
  iteration++;
300
308
  console.info(`Turn iteration ${iteration.toString()}/${maxIterations.toString()} for session ${sessionId}`);
309
+ const llmSpan = turnHandle?.startLlmSpan(MODEL, iteration);
301
310
  // Prepare messages for DeepSeek API: preserve reasoning_content (CoT Invariant)
302
311
  const messagesPayload = session.messages.map((m) => {
303
312
  const msg = {
@@ -335,18 +344,21 @@ async function handleSessionPrompt(id, params) {
335
344
  messages: messagesPayload,
336
345
  tools: tools && tools.length > 0 ? tools : undefined,
337
346
  temperature: TEMPERATURE,
338
- stream: true
347
+ stream: true,
348
+ stream_options: { include_usage: true }
339
349
  }),
340
350
  signal: combinedSignal
341
351
  });
342
352
  if (!response.ok) {
343
353
  const errorText = await response.text();
354
+ llmSpan?.end(undefined, `DeepSeek API error (HTTP ${response.status.toString()}): ${errorText}`);
344
355
  throw new Error(`DeepSeek API error (HTTP ${response.status.toString()}): ${errorText}`);
345
356
  }
346
357
  // Stream accumulator for 3-phase events
347
358
  let accumulator = null;
348
359
  for await (const event of parseSSEStream(response.body, requestTimeoutMs)) {
349
360
  if (session.abortController?.signal?.aborted || !session.abortController) {
361
+ llmSpan?.end(undefined, 'Stream aborted by client cancellation.');
350
362
  throw new Error('Stream aborted by client cancellation.');
351
363
  }
352
364
  if (event.type === 'delta') {
@@ -376,6 +388,10 @@ async function handleSessionPrompt(id, params) {
376
388
  if (!accumulator) {
377
389
  accumulator = new StreamAccumulator();
378
390
  }
391
+ if (accumulator.usage) {
392
+ llmSpan?.recordUsage(accumulator.usage);
393
+ turnHandle?.recordUsage(accumulator.usage);
394
+ }
379
395
  // Build assistant message
380
396
  const assistantMsg = {
381
397
  role: 'assistant',
@@ -387,6 +403,7 @@ async function handleSessionPrompt(id, params) {
387
403
  // Truncated Stream / Synthetic Tool Error
388
404
  if (accumulator.isTruncated() && accumulator.toolCalls.length > 0) {
389
405
  console.warn(`[SAFE STREAM] finish_reason === 'length' on tool call. Injecting synthetic tool error.`);
406
+ llmSpan?.end(undefined, 'Tool execution truncated due to token limits.');
390
407
  assistantMsg.tool_calls = accumulator.toolCalls.map((tc) => ({
391
408
  id: tc.id,
392
409
  type: 'function',
@@ -418,9 +435,12 @@ async function handleSessionPrompt(id, params) {
418
435
  const validToolCalls = accumulator.getValidToolCalls();
419
436
  // No tool calls means the model produced its final response text
420
437
  if (validToolCalls.length === 0) {
438
+ llmSpan?.end(accumulator.content || undefined);
421
439
  session.messages.push(assistantMsg);
422
440
  session.commit();
423
441
  turnCompleted = true;
442
+ turnHandle?.end('OK', accumulator.content || '');
443
+ void telemetryService?.backgroundFlush();
424
444
  sendResponse(id, {
425
445
  sessionId,
426
446
  message: {
@@ -432,7 +452,8 @@ async function handleSessionPrompt(id, params) {
432
452
  sendNotification('session/turnComplete', { sessionId });
433
453
  return;
434
454
  }
435
- // Tool calls were produced
455
+ // Tool calls were produced: close current iteration's LLM span before executing tools
456
+ llmSpan?.end(accumulator.content || undefined);
436
457
  assistantMsg.tool_calls = validToolCalls;
437
458
  session.messages.push(assistantMsg);
438
459
  let loopAborted = false;
@@ -447,6 +468,8 @@ async function handleSessionPrompt(id, params) {
447
468
  const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
448
469
  console.warn(`JSON parse error on tool arguments for ${tc.function.name}: ${msg}`);
449
470
  const errorMsg = `Tool execution truncated due to token limits: malformed JSON arguments (${msg})`;
471
+ turnHandle?.startToolSpan(tc.id, tc.function.name, tc.function.arguments);
472
+ turnHandle?.endToolSpan(tc.id, JSON.stringify({ error: errorMsg }), true, tc.function.name);
450
473
  session.messages.push({
451
474
  role: 'tool',
452
475
  tool_call_id: tc.id,
@@ -476,6 +499,7 @@ async function handleSessionPrompt(id, params) {
476
499
  }
477
500
  const argsPreview = JSON.stringify(parsedArgs).slice(0, 150);
478
501
  console.info(`Executing tool via MCP: ${tc.function.name} args: ${argsPreview}`);
502
+ turnHandle?.startToolSpan(tc.id, tc.function.name, parsedArgs);
479
503
  const toolResult = await mcpClient.callTool(tc.function.name, parsedArgs);
480
504
  console.info(`Tool ${tc.function.name} output: ${toolResult.slice(0, 150).replace(/\n/g, ' ')}`);
481
505
  session.messages.push({
@@ -495,6 +519,7 @@ async function handleSessionPrompt(id, params) {
495
519
  catch {
496
520
  // Ignored
497
521
  }
522
+ turnHandle?.endToolSpan(tc.id, toolResult, Boolean(errorString), tc.function.name);
498
523
  if (errorString) {
499
524
  if (lastToolError === errorString) {
500
525
  consecutiveErrorCount++;
@@ -545,6 +570,8 @@ async function handleSessionPrompt(id, params) {
545
570
  const finalContent = iteration >= maxIterations
546
571
  ? (session.messages[session.messages.length - 1]?.content ?? 'Turn reached maximum iteration limit.')
547
572
  : 'Turn execution stopped because repetitive tool errors occurred.';
573
+ turnHandle?.end('OK', finalContent);
574
+ void telemetryService?.backgroundFlush();
548
575
  sendResponse(id, {
549
576
  sessionId,
550
577
  message: {
@@ -561,6 +588,15 @@ async function handleSessionPrompt(id, params) {
561
588
  console.error(`Turn execution error in session ${sessionId}: ${msg}`);
562
589
  // Rollback session state on error
563
590
  session.rollback();
591
+ const isCancelled = session.abortController?.signal?.aborted ||
592
+ (err instanceof Error && (err.name === 'AbortError' || err.message.includes('cancellation')));
593
+ if (isCancelled) {
594
+ turnHandle?.end('CANCELLED');
595
+ }
596
+ else {
597
+ turnHandle?.end('ERROR', undefined, 'turn_failed', msg);
598
+ }
599
+ void telemetryService?.backgroundFlush();
564
600
  sendNotification('session/update', {
565
601
  sessionId,
566
602
  delta: {
@@ -604,29 +640,36 @@ rl.on('line', (line) => {
604
640
  console.error(`Invalid JSON-RPC frame: ${msg}`);
605
641
  }
606
642
  });
607
- const shutdown = (signal) => {
643
+ const shutdown = async (signal) => {
608
644
  if (signal) {
609
645
  console.info(`Received ${signal}, closing...`);
610
646
  }
611
647
  else {
612
648
  console.info('Stdin closed. Shutting down adapter...');
613
649
  }
614
- mcpClient
615
- .close()
616
- .catch((err) => {
617
- console.error('Error during shutdown:', err);
618
- })
619
- .finally(() => {
620
- process.exit(0);
621
- });
650
+ try {
651
+ await mcpClient.close();
652
+ }
653
+ catch (err) {
654
+ console.error('Error during MCP client close:', err);
655
+ }
656
+ try {
657
+ if (telemetryService) {
658
+ await telemetryService.shutdown();
659
+ }
660
+ }
661
+ catch (err) {
662
+ console.error('Error during telemetry shutdown:', err);
663
+ }
664
+ process.exit(0);
622
665
  };
623
666
  rl.on('close', () => {
624
- shutdown();
667
+ void shutdown();
625
668
  });
626
669
  // Handle graceful termination signals
627
670
  process.on('SIGTERM', () => {
628
- shutdown('SIGTERM');
671
+ void shutdown('SIGTERM');
629
672
  });
630
673
  process.on('SIGINT', () => {
631
- shutdown('SIGINT');
674
+ void shutdown('SIGINT');
632
675
  });
@@ -5,7 +5,7 @@
5
5
  * 2. content (response text tokens)
6
6
  * 3. tool_calls (function name and argument chunks)
7
7
  */
8
- import type { SSEEvent, SSEChunkData, ProcessedChunkResult, ToolCall, StreamYieldEvent } from './types.js';
8
+ import type { SSEEvent, SSEChunkData, ProcessedChunkResult, ToolCall, StreamYieldEvent, DeepSeekUsage } from './types.js';
9
9
  export declare class SSEParser {
10
10
  private buffer;
11
11
  /**
@@ -20,6 +20,7 @@ export declare class StreamAccumulator {
20
20
  content: string;
21
21
  toolCalls: ToolCall[];
22
22
  finishReason: string | null;
23
+ usage: DeepSeekUsage | null;
23
24
  /**
24
25
  * Process a parsed SSE chunk data object.
25
26
  */
@@ -52,10 +52,14 @@ export class StreamAccumulator {
52
52
  content = '';
53
53
  toolCalls = [];
54
54
  finishReason = null;
55
+ usage = null;
55
56
  /**
56
57
  * Process a parsed SSE chunk data object.
57
58
  */
58
59
  processChunk(chunkData) {
60
+ if (chunkData?.usage) {
61
+ this.usage = chunkData.usage;
62
+ }
59
63
  if (!chunkData?.choices || !Array.isArray(chunkData.choices) || chunkData.choices.length === 0) {
60
64
  return {
61
65
  reasoningDelta: null,
@@ -0,0 +1,73 @@
1
+ /**
2
+ * telemetry.ts — OpenTelemetry and Arize Phoenix tracing for deepseek-agent-acp.
3
+ * Implements OpenInference GenAI semantic conventions:
4
+ * - AGENT root span (agent_turn) representing complete ACP turn.
5
+ * - LLM child spans (llm:<model>) per iteration tracking streaming deltas & token usage.
6
+ * - TOOL child spans (tool:<name>) for MCP tool execution with duration, output bytes, and status.
7
+ * - Special extraction of `nostr.posted_event_id` for buzz-trace topological thread linking.
8
+ * - Two-tier secret redaction (32KB pre-truncation, exact-match, bounded regexes) and fail-open design.
9
+ * - Strict stdio hygiene: zero writes to stdout (all diagnostics redirected to stderr).
10
+ */
11
+ import type { DeepSeekUsage } from './types.js';
12
+ export declare function _resetProcessSecretsCache(): void;
13
+ /**
14
+ * Register a secret string for exact-match redaction (e.g. DEEPSEEK_API_KEY before env deletion).
15
+ */
16
+ export declare function registerSecret(secret: string | undefined | null): void;
17
+ /**
18
+ * Truncate a payload to maximum character length.
19
+ */
20
+ export declare function truncatePayload(text: string, maxLen?: number): string;
21
+ /**
22
+ * Redact sensitive secrets from text or object payloads.
23
+ * Pre-truncates to 32KB to protect against regex catastrophic backtracking.
24
+ */
25
+ export declare function redactSecrets(input: unknown): string;
26
+ /**
27
+ * Computes byte length of output safely without throwing TypeError
28
+ * when output is an Array, Object, Buffer, or String.
29
+ */
30
+ export declare function computeOutputBytes(output: unknown): number;
31
+ export interface NostrMetadata {
32
+ threadRootId?: string;
33
+ eventId?: string;
34
+ channelId?: string;
35
+ channelName?: string;
36
+ replyToId?: string;
37
+ senderName?: string;
38
+ senderPubkey?: string;
39
+ }
40
+ /**
41
+ * Parse Nostr context envelope injected by buzz-acp into prompt text.
42
+ * Isolates the active envelope using lastIndexOf('Event ID:') to prevent
43
+ * capturing cancelled or steered predecessor messages.
44
+ */
45
+ export declare function parseNostrContext(rawText: string): NostrMetadata;
46
+ /**
47
+ * Extract the posted Buzz event ID from a reply tool result.
48
+ * buzz-thread reply tools return `posted (N characters) · event_id <64 hex>`.
49
+ */
50
+ export declare function extractPostedEventId(output: unknown): string | undefined;
51
+ export interface LlmSpanHandle {
52
+ recordUsage(usage: DeepSeekUsage): void;
53
+ end(outputValue?: string, errorMessage?: string): void;
54
+ }
55
+ export interface TurnSpanHandle {
56
+ startLlmSpan(modelName: string, iteration?: number): LlmSpanHandle;
57
+ startToolSpan(toolCallId: string, toolName: string, toolParams: unknown): void;
58
+ endToolSpan(toolCallId: string, output?: unknown, isError?: boolean, toolName?: string): void;
59
+ recordUsage(usage: DeepSeekUsage): void;
60
+ end(status: 'OK' | 'ERROR' | 'CANCELLED', outputOrError?: string, failureKind?: string, errorMessage?: string): void;
61
+ }
62
+ export interface TelemetryService {
63
+ startTurnSpan(sessionId: string, promptText: string, modelName?: string, nostrMeta?: NostrMetadata): TurnSpanHandle;
64
+ backgroundFlush(timeoutMs?: number): Promise<void>;
65
+ shutdown(): Promise<void>;
66
+ }
67
+ export declare function backgroundFlush(provider: {
68
+ forceFlush(): Promise<void>;
69
+ }, timeoutMs?: number): Promise<void>;
70
+ export declare function getPackageVersion(): string;
71
+ export declare function initTelemetry(options?: {
72
+ secrets?: string[];
73
+ }): TelemetryService | null;
@@ -0,0 +1,549 @@
1
+ /**
2
+ * telemetry.ts — OpenTelemetry and Arize Phoenix tracing for deepseek-agent-acp.
3
+ * Implements OpenInference GenAI semantic conventions:
4
+ * - AGENT root span (agent_turn) representing complete ACP turn.
5
+ * - LLM child spans (llm:<model>) per iteration tracking streaming deltas & token usage.
6
+ * - TOOL child spans (tool:<name>) for MCP tool execution with duration, output bytes, and status.
7
+ * - Special extraction of `nostr.posted_event_id` for buzz-trace topological thread linking.
8
+ * - Two-tier secret redaction (32KB pre-truncation, exact-match, bounded regexes) and fail-open design.
9
+ * - Strict stdio hygiene: zero writes to stdout (all diagnostics redirected to stderr).
10
+ */
11
+ import { trace, context, SpanStatusCode } from '@opentelemetry/api';
12
+ import { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
13
+ import { resourceFromAttributes } from '@opentelemetry/resources';
14
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
15
+ import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
16
+ import { readFileSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { dirname, join } from 'node:path';
19
+ // ============================================================================
20
+ // 1. SECRET HYGIENE & REDACTION
21
+ // ============================================================================
22
+ const PREFIXED_PATTERNS = [
23
+ /nsec1[0-9a-z]{58}/gi,
24
+ /sk-[a-zA-Z0-9_\-.]{20,64}/g,
25
+ /sk-ant-[a-zA-Z0-9_.-]+/g,
26
+ /\bgl(pat|rt|dt|ptt|cbt|oas)-[0-9a-zA-Z_-]+/g,
27
+ /\bgh[pousr]_[0-9a-zA-Z]{36}\b/g,
28
+ /ya29\.[0-9a-zA-Z_-]+/g,
29
+ /1\/\/[0-9a-zA-Z_-]+/g,
30
+ /\beyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+/g
31
+ ];
32
+ const explicitSecrets = new Set();
33
+ let cachedProcessSecrets = null;
34
+ export function _resetProcessSecretsCache() {
35
+ cachedProcessSecrets = null;
36
+ explicitSecrets.clear();
37
+ }
38
+ /**
39
+ * Register a secret string for exact-match redaction (e.g. DEEPSEEK_API_KEY before env deletion).
40
+ */
41
+ export function registerSecret(secret) {
42
+ if (!secret || typeof secret !== 'string' || secret.length < 8) {
43
+ return;
44
+ }
45
+ explicitSecrets.add(secret);
46
+ try {
47
+ const b64 = Buffer.from(secret).toString('base64');
48
+ if (b64.length >= 8) {
49
+ explicitSecrets.add(b64);
50
+ }
51
+ }
52
+ catch {
53
+ // Ignore base64 conversion errors
54
+ }
55
+ cachedProcessSecrets = null;
56
+ }
57
+ function getProcessSecrets() {
58
+ if (cachedProcessSecrets !== null) {
59
+ return cachedProcessSecrets;
60
+ }
61
+ const secrets = new Set(explicitSecrets);
62
+ const sensitiveKeys = [
63
+ 'DEEPSEEK_API_KEY',
64
+ 'BUZZ_PRIVATE_KEY',
65
+ 'GITLAB_TOKEN',
66
+ 'PHOENIX_API_KEY',
67
+ 'AGY_OAUTH_TOKEN_B64',
68
+ 'CLAUDE_CODE_OAUTH_TOKEN',
69
+ 'ANTHROPIC_API_KEY'
70
+ ];
71
+ for (const key of sensitiveKeys) {
72
+ const val = process.env[key];
73
+ if (val && typeof val === 'string' && val.length >= 8) {
74
+ secrets.add(val);
75
+ try {
76
+ const b64 = Buffer.from(val).toString('base64');
77
+ if (b64.length >= 8) {
78
+ secrets.add(b64);
79
+ }
80
+ }
81
+ catch {
82
+ // Ignore base64 conversion errors
83
+ }
84
+ }
85
+ }
86
+ // Deduplicate and sort descending by length to replace longer strings first
87
+ cachedProcessSecrets = Array.from(secrets).sort((a, b) => b.length - a.length);
88
+ return cachedProcessSecrets;
89
+ }
90
+ /**
91
+ * Truncate a payload to maximum character length.
92
+ */
93
+ export function truncatePayload(text, maxLen = 8192) {
94
+ if (!text) {
95
+ return '';
96
+ }
97
+ if (text.length <= maxLen) {
98
+ return text;
99
+ }
100
+ return `${text.slice(0, maxLen)}... [TRUNCATED]`;
101
+ }
102
+ /**
103
+ * Redact sensitive secrets from text or object payloads.
104
+ * Pre-truncates to 32KB to protect against regex catastrophic backtracking.
105
+ */
106
+ export function redactSecrets(input) {
107
+ if (input === null || input === undefined) {
108
+ return '';
109
+ }
110
+ let text = typeof input === 'string' ? input : JSON.stringify(input);
111
+ // Pre-truncate to 32KB to avoid ReDoS on massive tool payloads
112
+ if (text.length > 32768) {
113
+ text = `${text.slice(0, 32768)}... [PRE_TRUNCATED]`;
114
+ }
115
+ // 1. Exact match against known process secrets
116
+ const secrets = getProcessSecrets();
117
+ for (const secret of secrets) {
118
+ if (text.includes(secret)) {
119
+ text = text.split(secret).join('[REDACTED]');
120
+ }
121
+ }
122
+ // 2. Prefixed patterns (Nostr, DeepSeek, Anthropic, GitLab, GitHub, Google OAuth, JWT)
123
+ for (const pattern of PREFIXED_PATTERNS) {
124
+ text = text.replace(pattern, '[REDACTED]');
125
+ }
126
+ return text;
127
+ }
128
+ /**
129
+ * Computes byte length of output safely without throwing TypeError
130
+ * when output is an Array, Object, Buffer, or String.
131
+ */
132
+ export function computeOutputBytes(output) {
133
+ if (output === null || output === undefined) {
134
+ return 0;
135
+ }
136
+ if (typeof output === 'string') {
137
+ return Buffer.byteLength(output, 'utf8');
138
+ }
139
+ if (Buffer.isBuffer(output)) {
140
+ return output.length;
141
+ }
142
+ try {
143
+ return Buffer.byteLength(JSON.stringify(output), 'utf8');
144
+ }
145
+ catch {
146
+ return 0;
147
+ }
148
+ }
149
+ /**
150
+ * Parse Nostr context envelope injected by buzz-acp into prompt text.
151
+ * Isolates the active envelope using lastIndexOf('Event ID:') to prevent
152
+ * capturing cancelled or steered predecessor messages.
153
+ */
154
+ export function parseNostrContext(rawText) {
155
+ const result = {};
156
+ if (!rawText) {
157
+ return result;
158
+ }
159
+ const lastEventIdx = rawText.lastIndexOf('Event ID:');
160
+ const contextSnippet = lastEventIdx !== -1 ? rawText.slice(Math.max(0, lastEventIdx - 200)) : rawText;
161
+ // 1. Thread Root ID: "Thread root: <64-hex>" or "root=<64-hex>"
162
+ const rootMatch = contextSnippet.match(/Thread root:\s*([a-f0-9]{64})/i) ?? contextSnippet.match(/\broot=([a-f0-9]{64})\b/i);
163
+ if (rootMatch?.[1]) {
164
+ result.threadRootId = rootMatch[1].toLowerCase();
165
+ }
166
+ // 2. Event ID: "Event ID: <64-hex>"
167
+ const eventMatch = contextSnippet.match(/Event ID:\s*([a-f0-9]{64})/i);
168
+ if (eventMatch?.[1]) {
169
+ result.eventId = eventMatch[1].toLowerCase();
170
+ if (!result.threadRootId) {
171
+ result.threadRootId = result.eventId;
172
+ }
173
+ }
174
+ // 3. Channel ID: "(#<UUID>)"
175
+ const channelMatch = contextSnippet.match(/#([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i);
176
+ if (channelMatch?.[1]) {
177
+ result.channelId = channelMatch[1].toLowerCase();
178
+ }
179
+ // 4. Channel Name: "Channel: <name> (#...)"
180
+ const channelNameMatch = contextSnippet.match(/Channel:\s*([a-zA-Z0-9_\-]+)\s*\(/i);
181
+ if (channelNameMatch?.[1]) {
182
+ result.channelName = channelNameMatch[1];
183
+ }
184
+ // 5. Parent / Reply-To ID: "parent=<64-hex>"
185
+ const parentMatch = contextSnippet.match(/\bparent=([a-f0-9]{64})\b/i);
186
+ if (parentMatch?.[1]) {
187
+ result.replyToId = parentMatch[1].toLowerCase();
188
+ }
189
+ // 6. Sender info: "From: Name (npub: ..., hex: <64-hex>)"
190
+ const fromMatch = contextSnippet.match(/From:\s*([^(]+?)\s*\(.*?hex:\s*([a-f0-9]{64})\)/i);
191
+ if (fromMatch?.[1] && fromMatch[2]) {
192
+ result.senderName = fromMatch[1].trim();
193
+ result.senderPubkey = fromMatch[2].toLowerCase();
194
+ }
195
+ return result;
196
+ }
197
+ /**
198
+ * Extract the posted Buzz event ID from a reply tool result.
199
+ * buzz-thread reply tools return `posted (N characters) · event_id <64 hex>`.
200
+ */
201
+ export function extractPostedEventId(output) {
202
+ let text = '';
203
+ if (typeof output === 'string') {
204
+ text = output;
205
+ }
206
+ else if (output && typeof output === 'object') {
207
+ text = JSON.stringify(output);
208
+ }
209
+ if (!text) {
210
+ return undefined;
211
+ }
212
+ const match = /\bevent_id\s+([0-9a-f]{64})\b/i.exec(text.slice(0, 4096));
213
+ return match?.[1] ? match[1].toLowerCase() : undefined;
214
+ }
215
+ function isReplyTool(toolName) {
216
+ return toolName === 'mcp__buzz-thread__reply' || toolName === 'reply' || toolName.endsWith('reply');
217
+ }
218
+ // ============================================================================
219
+ // 4. FLUSH COALESCING & ERROR THROTTLING
220
+ // ============================================================================
221
+ let inFlightFlush = null;
222
+ let exportErrorCount = 0;
223
+ function logExportError(err) {
224
+ exportErrorCount++;
225
+ if (exportErrorCount === 1 || exportErrorCount % 100 === 0) {
226
+ const msg = err instanceof Error ? err.message : String(err);
227
+ console.error(`[deepseek-acp:TELEMETRY] Trace export failed (#${exportErrorCount.toString()}): ${msg}`);
228
+ }
229
+ }
230
+ export function backgroundFlush(provider, timeoutMs = 1500) {
231
+ if (inFlightFlush) {
232
+ return inFlightFlush;
233
+ }
234
+ let timer;
235
+ const timeoutPromise = new Promise((resolve) => {
236
+ timer = setTimeout(resolve, timeoutMs);
237
+ timer.unref(); // Prevent timer from pinning event loop
238
+ });
239
+ inFlightFlush = Promise.race([
240
+ provider.forceFlush().catch((err) => {
241
+ logExportError(err);
242
+ }),
243
+ timeoutPromise
244
+ ]).finally(() => {
245
+ if (timer) {
246
+ clearTimeout(timer);
247
+ }
248
+ inFlightFlush = null;
249
+ });
250
+ return inFlightFlush;
251
+ }
252
+ export function getPackageVersion() {
253
+ try {
254
+ const dir = dirname(fileURLToPath(import.meta.url));
255
+ const pkgPath = join(dir, '../package.json');
256
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
257
+ return pkg.version ?? '0.2.0';
258
+ }
259
+ catch {
260
+ return '0.2.0';
261
+ }
262
+ }
263
+ // ============================================================================
264
+ // 5. INITIALIZATION & ENGINE IMPLEMENTATION
265
+ // ============================================================================
266
+ export function initTelemetry(options) {
267
+ if (options?.secrets) {
268
+ for (const s of options.secrets) {
269
+ registerSecret(s);
270
+ }
271
+ }
272
+ const tracesEndpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
273
+ if (!tracesEndpoint) {
274
+ return null;
275
+ }
276
+ try {
277
+ const serviceName = process.env.OTEL_SERVICE_NAME ?? 'deepseek-agent';
278
+ const projectName = process.env.PHOENIX_PROJECT_NAME ?? 'default';
279
+ const apiKey = process.env.PHOENIX_API_KEY;
280
+ const headers = {
281
+ 'x-project-name': projectName
282
+ };
283
+ if (apiKey) {
284
+ headers['Authorization'] = `Bearer ${apiKey}`;
285
+ }
286
+ const exporter = new OTLPTraceExporter({
287
+ url: tracesEndpoint,
288
+ headers,
289
+ timeoutMillis: 1500
290
+ });
291
+ const batchProcessor = new BatchSpanProcessor(exporter, {
292
+ maxQueueSize: 2048,
293
+ maxExportBatchSize: 128,
294
+ scheduledDelayMillis: 5000,
295
+ exportTimeoutMillis: 1500
296
+ });
297
+ const resource = resourceFromAttributes({
298
+ [ATTR_SERVICE_NAME]: serviceName,
299
+ 'openinference.project.name': projectName
300
+ });
301
+ const provider = new NodeTracerProvider({
302
+ resource,
303
+ spanProcessors: [batchProcessor],
304
+ spanLimits: {
305
+ attributeValueLengthLimit: 8192
306
+ }
307
+ });
308
+ provider.register();
309
+ const tracer = trace.getTracer('deepseek-agent-acp', getPackageVersion());
310
+ return {
311
+ startTurnSpan(sessionId, promptText, modelName, nostrMeta) {
312
+ const sanitizedPrompt = truncatePayload(redactSecrets(promptText), 8192);
313
+ const resolvedSessionId = nostrMeta?.threadRootId ?? sessionId;
314
+ const rootSpan = tracer.startSpan('agent_turn', {
315
+ attributes: {
316
+ 'openinference.span.kind': 'AGENT',
317
+ 'session.id': resolvedSessionId,
318
+ 'user.id': nostrMeta?.senderPubkey ?? 'anonymous',
319
+ 'agent.name': serviceName,
320
+ 'input.value': sanitizedPrompt,
321
+ 'input.mime_type': 'text/plain',
322
+ ...(nostrMeta?.threadRootId ? { 'nostr.thread_root_id': nostrMeta.threadRootId } : {}),
323
+ ...(nostrMeta?.eventId ? { 'nostr.event_id': nostrMeta.eventId } : {}),
324
+ ...(nostrMeta?.channelId ? { 'nostr.channel_id': nostrMeta.channelId } : {}),
325
+ ...(nostrMeta?.channelName ? { 'nostr.channel_name': nostrMeta.channelName } : {}),
326
+ ...(nostrMeta?.replyToId ? { 'nostr.reply_to_id': nostrMeta.replyToId, 'nostr.reply_to': nostrMeta.replyToId } : {}),
327
+ ...(nostrMeta?.senderName ? { 'nostr.sender_name': nostrMeta.senderName } : {}),
328
+ ...(nostrMeta?.senderPubkey ? { 'nostr.sender_pubkey': nostrMeta.senderPubkey } : {}),
329
+ ...(process.env.AGENT_ID ? { 'agent.id': process.env.AGENT_ID } : {}),
330
+ ...(process.env.AGENT_COMMUNITY ? { 'agent.community': process.env.AGENT_COMMUNITY } : {})
331
+ }
332
+ });
333
+ const rootContext = trace.setSpan(context.active(), rootSpan);
334
+ const activeToolSpans = new Map();
335
+ const activeLlmSpans = new Set();
336
+ let hasEnded = false;
337
+ // Cumulative token counters across iterations for rootSpan
338
+ let totalPromptTokens = 0;
339
+ let totalCompletionTokens = 0;
340
+ let totalReasoningTokens = 0;
341
+ let totalCacheHitTokens = 0;
342
+ return {
343
+ startLlmSpan(llmModel, iteration = 1) {
344
+ const effectiveModel = llmModel || modelName || process.env.DEEPSEEK_MODEL || 'deepseek-chat';
345
+ const llmSpan = tracer.startSpan(`llm:${effectiveModel}`, {
346
+ attributes: {
347
+ 'openinference.span.kind': 'LLM',
348
+ 'session.id': resolvedSessionId,
349
+ 'llm.model_name': effectiveModel,
350
+ 'llm.provider': 'deepseek',
351
+ 'llm.iteration': iteration,
352
+ 'input.value': sanitizedPrompt,
353
+ 'input.mime_type': 'text/plain'
354
+ }
355
+ }, rootContext);
356
+ activeLlmSpans.add(llmSpan);
357
+ return {
358
+ recordUsage(usage) {
359
+ const promptTokens = usage.prompt_tokens || 0;
360
+ // In DeepSeek-R1, completion_tokens already includes reasoning_tokens
361
+ const completionTokens = usage.completion_tokens || 0;
362
+ const totalTokens = usage.total_tokens || (promptTokens + completionTokens);
363
+ const cacheHit = usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0;
364
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? 0;
365
+ totalPromptTokens += promptTokens;
366
+ totalCompletionTokens += completionTokens;
367
+ totalReasoningTokens += reasoning;
368
+ totalCacheHitTokens += cacheHit;
369
+ llmSpan.setAttributes({
370
+ 'llm.token_count.prompt': promptTokens,
371
+ 'llm.token_count.completion': completionTokens,
372
+ 'llm.token_count.total': totalTokens,
373
+ ...(cacheHit > 0 ? { 'llm.token_count.prompt_details.cache_read': cacheHit } : {}),
374
+ ...(reasoning > 0 ? { 'llm.token_count.completion_details.reasoning': reasoning } : {})
375
+ });
376
+ // Update root cumulative counts
377
+ rootSpan.setAttributes({
378
+ 'llm.token_count.prompt': totalPromptTokens,
379
+ 'llm.token_count.completion': totalCompletionTokens,
380
+ 'llm.token_count.total': totalPromptTokens + totalCompletionTokens,
381
+ ...(totalCacheHitTokens > 0 ? { 'llm.token_count.prompt_details.cache_read': totalCacheHitTokens } : {}),
382
+ ...(totalReasoningTokens > 0 ? { 'llm.token_count.completion_details.reasoning': totalReasoningTokens } : {})
383
+ });
384
+ },
385
+ end(outputValue, errorMessage) {
386
+ activeLlmSpans.delete(llmSpan);
387
+ if (outputValue) {
388
+ const sanitizedOut = truncatePayload(redactSecrets(outputValue), 8192);
389
+ llmSpan.setAttribute('output.value', sanitizedOut);
390
+ llmSpan.setAttribute('output.mime_type', 'text/plain');
391
+ }
392
+ if (errorMessage) {
393
+ llmSpan.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
394
+ llmSpan.setAttribute('error.message', errorMessage);
395
+ }
396
+ else {
397
+ llmSpan.setStatus({ code: SpanStatusCode.OK });
398
+ }
399
+ llmSpan.end();
400
+ }
401
+ };
402
+ },
403
+ startToolSpan(toolCallId, toolName, toolParams) {
404
+ if (activeToolSpans.has(toolCallId)) {
405
+ return;
406
+ }
407
+ const sanitizedParams = truncatePayload(redactSecrets(toolParams), 8192);
408
+ const toolSpan = tracer.startSpan(`tool:${toolName}`, {
409
+ attributes: {
410
+ 'openinference.span.kind': 'TOOL',
411
+ 'session.id': resolvedSessionId,
412
+ 'tool.name': toolName,
413
+ 'tool.call_id': toolCallId,
414
+ 'tool.parameters': sanitizedParams,
415
+ 'input.value': sanitizedParams,
416
+ 'input.mime_type': 'application/json'
417
+ }
418
+ }, rootContext);
419
+ activeToolSpans.set(toolCallId, { span: toolSpan, toolName });
420
+ },
421
+ endToolSpan(toolCallId, output, isError, toolName) {
422
+ const entry = activeToolSpans.get(toolCallId);
423
+ if (!entry) {
424
+ if (toolName) {
425
+ rootSpan.addEvent('unmapped_tool_result', {
426
+ 'tool.call_id': toolCallId,
427
+ 'tool.name': toolName
428
+ });
429
+ }
430
+ return;
431
+ }
432
+ const { span: toolSpan, toolName: resolvedToolName } = entry;
433
+ const outputBytes = computeOutputBytes(output);
434
+ toolSpan.setAttribute('tool.output_bytes', outputBytes);
435
+ // Extract posted_event_id for reply tools
436
+ if (!isError && isReplyTool(resolvedToolName)) {
437
+ const postedId = extractPostedEventId(output);
438
+ if (postedId) {
439
+ toolSpan.setAttribute('nostr.posted_event_id', postedId);
440
+ rootSpan.setAttribute('nostr.posted_event_id', postedId);
441
+ }
442
+ }
443
+ if (process.env.OTEL_CAPTURE_TOOL_OUTPUT === 'true' && output !== undefined) {
444
+ const sanitizedOut = truncatePayload(redactSecrets(output), 8192);
445
+ toolSpan.setAttribute('output.value', sanitizedOut);
446
+ toolSpan.setAttribute('output.mime_type', 'text/plain');
447
+ }
448
+ toolSpan.setAttribute('tool.is_error', Boolean(isError));
449
+ if (isError) {
450
+ toolSpan.setStatus({
451
+ code: SpanStatusCode.ERROR,
452
+ message: 'tool_execution_failed'
453
+ });
454
+ }
455
+ else {
456
+ toolSpan.setStatus({ code: SpanStatusCode.OK });
457
+ }
458
+ toolSpan.end();
459
+ activeToolSpans.delete(toolCallId);
460
+ },
461
+ recordUsage(usage) {
462
+ const promptTokens = usage.prompt_tokens || 0;
463
+ const completionTokens = usage.completion_tokens || 0;
464
+ const cacheHit = usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0;
465
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? 0;
466
+ totalPromptTokens = Math.max(totalPromptTokens, promptTokens);
467
+ totalCompletionTokens = Math.max(totalCompletionTokens, completionTokens);
468
+ totalReasoningTokens = Math.max(totalReasoningTokens, reasoning);
469
+ totalCacheHitTokens = Math.max(totalCacheHitTokens, cacheHit);
470
+ rootSpan.setAttributes({
471
+ 'llm.token_count.prompt': totalPromptTokens,
472
+ 'llm.token_count.completion': totalCompletionTokens,
473
+ 'llm.token_count.total': totalPromptTokens + totalCompletionTokens,
474
+ ...(totalCacheHitTokens > 0 ? { 'llm.token_count.prompt_details.cache_read': totalCacheHitTokens } : {}),
475
+ ...(totalReasoningTokens > 0 ? { 'llm.token_count.completion_details.reasoning': totalReasoningTokens } : {})
476
+ });
477
+ },
478
+ end(status, outputOrError, failureKind, errorMessage) {
479
+ if (hasEnded) {
480
+ return;
481
+ }
482
+ hasEnded = true;
483
+ // Turn boundary sweep: close any orphaned tool spans safely
484
+ for (const { span: orphanSpan } of activeToolSpans.values()) {
485
+ orphanSpan.setStatus({
486
+ code: SpanStatusCode.ERROR,
487
+ message: 'orphaned_at_turn_end'
488
+ });
489
+ orphanSpan.setAttribute('error.message', 'orphaned_at_turn_end');
490
+ orphanSpan.setAttribute('tool.is_error', true);
491
+ orphanSpan.end();
492
+ }
493
+ activeToolSpans.clear();
494
+ // Sweep any lingering LLM spans
495
+ for (const orphanLlm of activeLlmSpans) {
496
+ orphanLlm.setStatus({ code: SpanStatusCode.ERROR, message: 'orphaned_at_turn_end' });
497
+ orphanLlm.end();
498
+ }
499
+ activeLlmSpans.clear();
500
+ const outputValue = status === 'OK' ? outputOrError : undefined;
501
+ if (outputValue) {
502
+ const sanitizedOut = truncatePayload(redactSecrets(outputValue), 8192);
503
+ rootSpan.setAttribute('output.value', sanitizedOut);
504
+ rootSpan.setAttribute('output.mime_type', 'text/plain');
505
+ }
506
+ if (status === 'CANCELLED') {
507
+ // Leave status UNSET to preserve SLO Error Budget; record stop_reason
508
+ rootSpan.setAttribute('deepseek.stop_reason', 'cancelled');
509
+ }
510
+ else if (status === 'ERROR') {
511
+ const errDesc = errorMessage ?? failureKind ?? outputOrError ?? 'turn_failed';
512
+ rootSpan.setStatus({ code: SpanStatusCode.ERROR, message: errDesc });
513
+ if (failureKind) {
514
+ rootSpan.setAttribute('error.kind', failureKind);
515
+ }
516
+ rootSpan.setAttribute('error.message', truncatePayload(errDesc, 1024));
517
+ }
518
+ else {
519
+ rootSpan.setStatus({ code: SpanStatusCode.OK });
520
+ }
521
+ rootSpan.end();
522
+ }
523
+ };
524
+ },
525
+ backgroundFlush(timeoutMs = 1500) {
526
+ return backgroundFlush(provider, timeoutMs);
527
+ },
528
+ async shutdown() {
529
+ try {
530
+ await Promise.race([
531
+ provider.shutdown(),
532
+ new Promise((resolve) => {
533
+ const t = setTimeout(resolve, 1500);
534
+ t.unref();
535
+ })
536
+ ]);
537
+ }
538
+ catch (err) {
539
+ logExportError(err);
540
+ }
541
+ }
542
+ };
543
+ }
544
+ catch (err) {
545
+ const msg = err instanceof Error ? err.message : String(err);
546
+ console.error(`[deepseek-acp:TELEMETRY] Failed to initialize OpenTelemetry SDK (running fail-open): ${msg}`);
547
+ return null;
548
+ }
549
+ }
package/dist/types.d.ts CHANGED
@@ -36,12 +36,26 @@ export interface SSEChoice {
36
36
  delta?: SSEChoiceDelta;
37
37
  finish_reason?: 'stop' | 'tool_calls' | 'length' | null;
38
38
  }
39
+ export interface DeepSeekUsage {
40
+ prompt_tokens: number;
41
+ completion_tokens: number;
42
+ total_tokens: number;
43
+ prompt_cache_hit_tokens?: number;
44
+ prompt_cache_miss_tokens?: number;
45
+ prompt_tokens_details?: {
46
+ cached_tokens?: number;
47
+ };
48
+ completion_tokens_details?: {
49
+ reasoning_tokens?: number;
50
+ };
51
+ }
39
52
  export interface SSEChunkData {
40
53
  id?: string;
41
54
  object?: string;
42
55
  created?: number;
43
56
  model?: string;
44
57
  choices: SSEChoice[];
58
+ usage?: DeepSeekUsage;
45
59
  }
46
60
  export interface SSEEvent {
47
61
  done: boolean;
@@ -72,6 +86,7 @@ export interface StreamYieldEvent {
72
86
  content: string;
73
87
  toolCalls: ToolCall[];
74
88
  finishReason: string | null;
89
+ usage: DeepSeekUsage | null;
75
90
  isTruncated: () => boolean;
76
91
  getValidToolCalls: () => ToolCall[];
77
92
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepseek-agent-acp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Agent Client Protocol (ACP) adapter for DeepSeek reasoning and chat models with Model Context Protocol (MCP) tool execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -24,7 +24,7 @@
24
24
  "typecheck": "tsc --noEmit",
25
25
  "lint": "eslint src tests",
26
26
  "lint:fix": "eslint src tests --fix",
27
- "test": "node --test tests/test_sse_parser.test.mjs tests/test_deepseek_acp.test.mjs",
27
+ "test": "node --test tests/test_sse_parser.test.mjs tests/test_deepseek_acp.test.mjs tests/test_telemetry.test.mjs",
28
28
  "start": "node dist/index.js",
29
29
  "prepare": "husky",
30
30
  "prepublishOnly": "npm run typecheck && npm run lint && npm run test && npm run build"
@@ -59,7 +59,12 @@
59
59
  },
60
60
  "homepage": "https://github.com/dongitran/deepseek-agent-acp#readme",
61
61
  "dependencies": {
62
- "@modelcontextprotocol/sdk": "1.32.2"
62
+ "@modelcontextprotocol/sdk": "1.32.2",
63
+ "@opentelemetry/api": "^1.9.1",
64
+ "@opentelemetry/exporter-trace-otlp-proto": "^0.223.0",
65
+ "@opentelemetry/resources": "^2.12.0",
66
+ "@opentelemetry/sdk-trace-node": "^2.12.0",
67
+ "@opentelemetry/semantic-conventions": "^1.45.0"
63
68
  },
64
69
  "devDependencies": {
65
70
  "@types/node": "^22.0.0",