deepseek-agent-acp 0.1.0 → 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 +57 -14
- package/dist/sse-parser.d.ts +2 -1
- package/dist/sse-parser.js +4 -0
- package/dist/telemetry.d.ts +73 -0
- package/dist/telemetry.js +549 -0
- package/dist/types.d.ts +15 -0
- package/package.json +11 -3
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
|
-
|
|
615
|
-
.close()
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
});
|
package/dist/sse-parser.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/sse-parser.js
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -16,12 +16,15 @@
|
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=20.0.0"
|
|
18
18
|
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
19
22
|
"scripts": {
|
|
20
23
|
"build": "tsc && chmod +x dist/index.js",
|
|
21
24
|
"typecheck": "tsc --noEmit",
|
|
22
25
|
"lint": "eslint src tests",
|
|
23
26
|
"lint:fix": "eslint src tests --fix",
|
|
24
|
-
"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",
|
|
25
28
|
"start": "node dist/index.js",
|
|
26
29
|
"prepare": "husky",
|
|
27
30
|
"prepublishOnly": "npm run typecheck && npm run lint && npm run test && npm run build"
|
|
@@ -56,7 +59,12 @@
|
|
|
56
59
|
},
|
|
57
60
|
"homepage": "https://github.com/dongitran/deepseek-agent-acp#readme",
|
|
58
61
|
"dependencies": {
|
|
59
|
-
"@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"
|
|
60
68
|
},
|
|
61
69
|
"devDependencies": {
|
|
62
70
|
"@types/node": "^22.0.0",
|