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 +4 -0
- package/dist/index.js +100 -15
- 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 +8 -3
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
|
|
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
|
-
|
|
615
|
-
.close()
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
});
|
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: 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.
|
|
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",
|