deepseek-agent-acp 0.1.1 → 0.2.1

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.d.ts CHANGED
@@ -11,3 +11,7 @@
11
11
  * - Full Model Context Protocol (MCP) tool execution.
12
12
  */
13
13
  export declare function loadSecretKey(secretsPath?: string): string;
14
+ /**
15
+ * Extract clean prompt text from ACP params supporting string, ContentBlock[], or nested structures.
16
+ */
17
+ export declare function extractPromptText(params?: Record<string, unknown>): string;
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';
@@ -261,12 +267,54 @@ async function handleRpcMessage(rpc) {
261
267
  }
262
268
  }
263
269
  }
270
+ /**
271
+ * Extract clean prompt text from ACP params supporting string, ContentBlock[], or nested structures.
272
+ */
273
+ export function extractPromptText(params) {
274
+ if (!params) {
275
+ return '';
276
+ }
277
+ const prompt = params.prompt ?? params.message ?? params.text;
278
+ if (typeof prompt === 'string') {
279
+ return prompt;
280
+ }
281
+ if (Array.isArray(prompt)) {
282
+ return prompt
283
+ .map((block) => {
284
+ if (typeof block === 'string') {
285
+ return block;
286
+ }
287
+ if (block && typeof block === 'object') {
288
+ const rec = block;
289
+ if (typeof rec.text === 'string') {
290
+ return rec.text;
291
+ }
292
+ if (typeof rec.content === 'string') {
293
+ return rec.content;
294
+ }
295
+ }
296
+ return '';
297
+ })
298
+ .filter(Boolean)
299
+ .join('\n');
300
+ }
301
+ if (prompt && typeof prompt === 'object') {
302
+ const rec = prompt;
303
+ if (typeof rec.text === 'string') {
304
+ return rec.text;
305
+ }
306
+ if (typeof rec.content === 'string') {
307
+ return rec.content;
308
+ }
309
+ }
310
+ return '';
311
+ }
264
312
  /**
265
313
  * Handle ACP session/prompt execution with multi-turn tool loops.
266
314
  */
267
315
  async function handleSessionPrompt(id, params) {
268
316
  const sessionId = params?.sessionId || `session-${Date.now().toString()}`;
269
- const promptText = params?.prompt || params?.message || params?.text || '';
317
+ const promptText = extractPromptText(params);
270
318
  let session = sessions.get(sessionId);
271
319
  if (!session) {
272
320
  session = new Session(sessionId);
@@ -285,6 +333,8 @@ async function handleSessionPrompt(id, params) {
285
333
  session.abortController = new AbortController();
286
334
  session.activePromptId = id ?? null;
287
335
  session.messages.push({ role: 'user', content: promptText });
336
+ const nostrMeta = parseNostrContext(promptText);
337
+ const turnHandle = telemetryService?.startTurnSpan(sessionId, promptText, MODEL, nostrMeta);
288
338
  const maxIterations = getIterationLimit(MODEL);
289
339
  let iteration = 0;
290
340
  let lastToolError = null;
@@ -298,6 +348,7 @@ async function handleSessionPrompt(id, params) {
298
348
  }
299
349
  iteration++;
300
350
  console.info(`Turn iteration ${iteration.toString()}/${maxIterations.toString()} for session ${sessionId}`);
351
+ const llmSpan = turnHandle?.startLlmSpan(MODEL, iteration);
301
352
  // Prepare messages for DeepSeek API: preserve reasoning_content (CoT Invariant)
302
353
  const messagesPayload = session.messages.map((m) => {
303
354
  const msg = {
@@ -335,18 +386,21 @@ async function handleSessionPrompt(id, params) {
335
386
  messages: messagesPayload,
336
387
  tools: tools && tools.length > 0 ? tools : undefined,
337
388
  temperature: TEMPERATURE,
338
- stream: true
389
+ stream: true,
390
+ stream_options: { include_usage: true }
339
391
  }),
340
392
  signal: combinedSignal
341
393
  });
342
394
  if (!response.ok) {
343
395
  const errorText = await response.text();
396
+ llmSpan?.end(undefined, `DeepSeek API error (HTTP ${response.status.toString()}): ${errorText}`);
344
397
  throw new Error(`DeepSeek API error (HTTP ${response.status.toString()}): ${errorText}`);
345
398
  }
346
399
  // Stream accumulator for 3-phase events
347
400
  let accumulator = null;
348
401
  for await (const event of parseSSEStream(response.body, requestTimeoutMs)) {
349
402
  if (session.abortController?.signal?.aborted || !session.abortController) {
403
+ llmSpan?.end(undefined, 'Stream aborted by client cancellation.');
350
404
  throw new Error('Stream aborted by client cancellation.');
351
405
  }
352
406
  if (event.type === 'delta') {
@@ -376,6 +430,10 @@ async function handleSessionPrompt(id, params) {
376
430
  if (!accumulator) {
377
431
  accumulator = new StreamAccumulator();
378
432
  }
433
+ if (accumulator.usage) {
434
+ llmSpan?.recordUsage(accumulator.usage);
435
+ turnHandle?.recordUsage(accumulator.usage);
436
+ }
379
437
  // Build assistant message
380
438
  const assistantMsg = {
381
439
  role: 'assistant',
@@ -387,6 +445,7 @@ async function handleSessionPrompt(id, params) {
387
445
  // Truncated Stream / Synthetic Tool Error
388
446
  if (accumulator.isTruncated() && accumulator.toolCalls.length > 0) {
389
447
  console.warn(`[SAFE STREAM] finish_reason === 'length' on tool call. Injecting synthetic tool error.`);
448
+ llmSpan?.end(undefined, 'Tool execution truncated due to token limits.');
390
449
  assistantMsg.tool_calls = accumulator.toolCalls.map((tc) => ({
391
450
  id: tc.id,
392
451
  type: 'function',
@@ -418,9 +477,12 @@ async function handleSessionPrompt(id, params) {
418
477
  const validToolCalls = accumulator.getValidToolCalls();
419
478
  // No tool calls means the model produced its final response text
420
479
  if (validToolCalls.length === 0) {
480
+ llmSpan?.end(accumulator.content || undefined);
421
481
  session.messages.push(assistantMsg);
422
482
  session.commit();
423
483
  turnCompleted = true;
484
+ turnHandle?.end('OK', accumulator.content || '');
485
+ void telemetryService?.backgroundFlush();
424
486
  sendResponse(id, {
425
487
  sessionId,
426
488
  message: {
@@ -432,7 +494,8 @@ async function handleSessionPrompt(id, params) {
432
494
  sendNotification('session/turnComplete', { sessionId });
433
495
  return;
434
496
  }
435
- // Tool calls were produced
497
+ // Tool calls were produced: close current iteration's LLM span before executing tools
498
+ llmSpan?.end(accumulator.content || undefined);
436
499
  assistantMsg.tool_calls = validToolCalls;
437
500
  session.messages.push(assistantMsg);
438
501
  let loopAborted = false;
@@ -447,6 +510,8 @@ async function handleSessionPrompt(id, params) {
447
510
  const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
448
511
  console.warn(`JSON parse error on tool arguments for ${tc.function.name}: ${msg}`);
449
512
  const errorMsg = `Tool execution truncated due to token limits: malformed JSON arguments (${msg})`;
513
+ turnHandle?.startToolSpan(tc.id, tc.function.name, tc.function.arguments);
514
+ turnHandle?.endToolSpan(tc.id, JSON.stringify({ error: errorMsg }), true, tc.function.name);
450
515
  session.messages.push({
451
516
  role: 'tool',
452
517
  tool_call_id: tc.id,
@@ -476,6 +541,7 @@ async function handleSessionPrompt(id, params) {
476
541
  }
477
542
  const argsPreview = JSON.stringify(parsedArgs).slice(0, 150);
478
543
  console.info(`Executing tool via MCP: ${tc.function.name} args: ${argsPreview}`);
544
+ turnHandle?.startToolSpan(tc.id, tc.function.name, parsedArgs);
479
545
  const toolResult = await mcpClient.callTool(tc.function.name, parsedArgs);
480
546
  console.info(`Tool ${tc.function.name} output: ${toolResult.slice(0, 150).replace(/\n/g, ' ')}`);
481
547
  session.messages.push({
@@ -495,6 +561,7 @@ async function handleSessionPrompt(id, params) {
495
561
  catch {
496
562
  // Ignored
497
563
  }
564
+ turnHandle?.endToolSpan(tc.id, toolResult, Boolean(errorString), tc.function.name);
498
565
  if (errorString) {
499
566
  if (lastToolError === errorString) {
500
567
  consecutiveErrorCount++;
@@ -545,6 +612,8 @@ async function handleSessionPrompt(id, params) {
545
612
  const finalContent = iteration >= maxIterations
546
613
  ? (session.messages[session.messages.length - 1]?.content ?? 'Turn reached maximum iteration limit.')
547
614
  : 'Turn execution stopped because repetitive tool errors occurred.';
615
+ turnHandle?.end('OK', finalContent);
616
+ void telemetryService?.backgroundFlush();
548
617
  sendResponse(id, {
549
618
  sessionId,
550
619
  message: {
@@ -561,6 +630,15 @@ async function handleSessionPrompt(id, params) {
561
630
  console.error(`Turn execution error in session ${sessionId}: ${msg}`);
562
631
  // Rollback session state on error
563
632
  session.rollback();
633
+ const isCancelled = session.abortController?.signal?.aborted ||
634
+ (err instanceof Error && (err.name === 'AbortError' || err.message.includes('cancellation')));
635
+ if (isCancelled) {
636
+ turnHandle?.end('CANCELLED');
637
+ }
638
+ else {
639
+ turnHandle?.end('ERROR', undefined, 'turn_failed', msg);
640
+ }
641
+ void telemetryService?.backgroundFlush();
564
642
  sendNotification('session/update', {
565
643
  sessionId,
566
644
  delta: {
@@ -604,29 +682,36 @@ rl.on('line', (line) => {
604
682
  console.error(`Invalid JSON-RPC frame: ${msg}`);
605
683
  }
606
684
  });
607
- const shutdown = (signal) => {
685
+ const shutdown = async (signal) => {
608
686
  if (signal) {
609
687
  console.info(`Received ${signal}, closing...`);
610
688
  }
611
689
  else {
612
690
  console.info('Stdin closed. Shutting down adapter...');
613
691
  }
614
- mcpClient
615
- .close()
616
- .catch((err) => {
617
- console.error('Error during shutdown:', err);
618
- })
619
- .finally(() => {
620
- process.exit(0);
621
- });
692
+ try {
693
+ await mcpClient.close();
694
+ }
695
+ catch (err) {
696
+ console.error('Error during MCP client close:', err);
697
+ }
698
+ try {
699
+ if (telemetryService) {
700
+ await telemetryService.shutdown();
701
+ }
702
+ }
703
+ catch (err) {
704
+ console.error('Error during telemetry shutdown:', err);
705
+ }
706
+ process.exit(0);
622
707
  };
623
708
  rl.on('close', () => {
624
- shutdown();
709
+ void shutdown();
625
710
  });
626
711
  // Handle graceful termination signals
627
712
  process.on('SIGTERM', () => {
628
- shutdown('SIGTERM');
713
+ void shutdown('SIGTERM');
629
714
  });
630
715
  process.on('SIGINT', () => {
631
- shutdown('SIGINT');
716
+ void shutdown('SIGINT');
632
717
  });
@@ -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: unknown): 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 (typeof rawText !== 'string' || !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.1",
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",