deepseek-agent-acp 0.2.2 โ†’ 0.3.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/README.md CHANGED
@@ -81,8 +81,9 @@ It bridges headless orchestrators, team chat relays, and multi-agent harnesses w
81
81
  4. **Transactional State Management & Rollback**:
82
82
  * Takes atomic message snapshots before executing each turn.
83
83
  * Restores session state seamlessly to the last stable checkpoint upon client cancellation (`session/cancel`) or uncaught runtime exceptions.
84
- 5. **Execution Circuit Breaker**:
84
+ 5. **Execution Circuit Breaker and Turn Limit**:
85
85
  * Detects stagnant loops (e.g., 2 consecutive identical tool failures).
86
+ * Caps the model calls per turn (`ACP_MAX_ITERATIONS`) and ends an exhausted turn with the ACP stop reason `max_turn_requests`.
86
87
  * Injects synthetic safeguards for remaining unexecuted tool calls to maintain strict OpenAI tool-call response invariants.
87
88
  6. **Full Model Context Protocol (MCP) Integration**:
88
89
  * Discovers tools dynamically from connected MCP servers and translates them to OpenAI function schemas.
@@ -115,15 +116,38 @@ deepseek-agent-acp
115
116
  | Variable | Default | Description |
116
117
  | :--- | :--- | :--- |
117
118
  | `DEEPSEEK_API_KEY` | *(Required)* | DeepSeek API key (or `DEEPSEEK_AUTH_TOKEN`). |
118
- | `DEEPSEEK_MODEL` | `deepseek-chat` | Target model (`deepseek-chat`, `deepseek-reasoner`). |
119
+ | `DEEPSEEK_MODEL` | `deepseek-chat` | Target model (for example `deepseek-flash`, `deepseek-chat`, `deepseek-reasoner`). |
119
120
  | `DEEPSEEK_BASE_URL` | `https://api.deepseek.com` | Base endpoint URL for completions API. |
120
121
  | `DEEPSEEK_TEMPERATURE` | `0.0` | Sampling temperature. |
121
- | `DEEPSEEK_REQUEST_TIMEOUT_MS`| `90000` | HTTP fetch and chunk read timeout (ms). |
122
+ | `DEEPSEEK_THINKING` | *(unset: API default)* | `enabled` or `disabled`, sent as `thinking.type`. DeepSeek models think by default, so set `disabled` for a zero-thinking agent. |
123
+ | `DEEPSEEK_REASONING_EFFORT` | *(unset: API default)* | `none` (turns thinking off) or `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`, sent as `reasoning_effort`. `DEEPSEEK_THINKING=disabled` wins over any level. |
124
+ | `DEEPSEEK_REQUEST_TIMEOUT_MS`| `90000` | HTTP fetch and chunk read timeout (ms). A positive integer. |
122
125
  | `ACP_MCP_COMMAND` | `buzz-dev-mcp` | Command to launch the Stdio MCP tool server. |
123
126
  | `ACP_SYSTEM_PROMPT_FILE` | `./system-prompt.md` | Path to custom system instructions. |
124
- | `ACP_MAX_ITERATIONS` | `15` (`25` for coder) | Maximum autonomous tool execution turn iterations. |
127
+ | `ACP_MAX_ITERATIONS` | `15` (`25` for coder/builder) | Maximum model calls in one `session/prompt` turn, a positive integer (`BUZZ_MAX_ITERATIONS` is the fallback). Every tool call costs one iteration, so an agent that stays in one turn for minutes (a meeting call that loops on `meeting_wait`) must raise it. |
125
128
  | `MCP_TOOL_TIMEOUT_MS` | `60000` | Timeout for individual MCP tool calls (ms). |
126
129
 
130
+ `thinking` and `reasoning_effort` are only added to the request when the matching variable is set, so endpoints
131
+ that reject unknown fields keep working. Invalid values are ignored with a warning on stderr, and the effective
132
+ settings are logged once at start-up:
133
+
134
+ ```text
135
+ [config] model=deepseek-flash thinking=disabled max_iterations=10000 (ACP_MAX_ITERATIONS) request_timeout_ms=90000
136
+ ```
137
+
138
+ Every `DEEPSEEK_*` variable that is not a setting listed above is treated as a secret and removed from the process
139
+ environment at start-up (see In-Memory Secret Sanitization).
140
+
141
+ ### Stop reasons
142
+
143
+ `session/prompt` always ends with an ACP stop reason, so clients such as buzz-acp never see a protocol error:
144
+
145
+ | `stopReason` | When |
146
+ | :--- | :--- |
147
+ | `end_turn` | The model finished, or the circuit breaker stopped a loop of identical tool errors (the notice is streamed as text). |
148
+ | `max_turn_requests` | The turn used all `ACP_MAX_ITERATIONS` model calls. A notice is streamed and the client may rotate the session. |
149
+ | `error` | The turn failed (API error, timeout) and the session was rolled back. Not an ACP value: buzz-acp answers it by respawning the adapter and re-queueing the prompt. |
150
+
127
151
  ---
128
152
 
129
153
  ## ๐Ÿ›  Supported ACP Methods
@@ -0,0 +1,61 @@
1
+ /**
2
+ * config.ts โ€” Pure, side-effect-free resolution of the adapter's non-secret settings from the environment.
3
+ *
4
+ * Every function takes the environment as an argument and returns the resolved value together with the
5
+ * warnings the caller should log, so the rules are unit-testable without spawning the adapter.
6
+ * Invalid input never throws: it falls back to the documented default and produces a warning that names
7
+ * the offending variable (a silently ignored setting is how a "zero thinking" agent kept thinking).
8
+ */
9
+ export type Env = Readonly<Record<string, string | undefined>>;
10
+ /**
11
+ * DEEPSEEK_* variables that are settings, not secrets. loadSecretKey() purges every other DEEPSEEK_* variable
12
+ * from process.env (default-deny, so an unknown variable is never exposed to child processes); a new setting
13
+ * must be listed here to survive that purge.
14
+ */
15
+ export declare const PRESERVED_DEEPSEEK_CONFIG: ReadonlySet<string>;
16
+ declare const REASONING_EFFORTS: readonly ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
17
+ export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
18
+ /** Fields merged into the chat-completions request body. Empty means "leave the API default". */
19
+ export interface ReasoningRequestParams {
20
+ thinking?: {
21
+ type: 'enabled' | 'disabled';
22
+ };
23
+ reasoning_effort?: ReasoningEffort;
24
+ }
25
+ export interface ReasoningResolution {
26
+ params: ReasoningRequestParams;
27
+ warnings: string[];
28
+ }
29
+ /**
30
+ * DEEPSEEK_THINKING `enabled` | `disabled` โ†’ thinking: { type }
31
+ * DEEPSEEK_REASONING_EFFORT `none` | minimal โ€ฆ max | ultra โ†’ `none` disables thinking, any other level is
32
+ * sent as reasoning_effort
33
+ *
34
+ * Nothing is sent unless it is configured, so endpoints that reject unknown fields keep working. Disabling
35
+ * thinking always wins over an effort level (a disabled model has no effort to set).
36
+ */
37
+ export declare function resolveReasoningParams(env: Env): ReasoningResolution;
38
+ /** Short, log-friendly summary of what the request will say about thinking, e.g. `disabled` or `api-default`. */
39
+ export declare function describeReasoning(params: ReasoningRequestParams): string;
40
+ export declare const DEFAULT_ITERATION_LIMIT = 15;
41
+ export declare const CODER_ITERATION_LIMIT = 25;
42
+ export interface IterationLimitResolution {
43
+ limit: number;
44
+ source: 'ACP_MAX_ITERATIONS' | 'BUZZ_MAX_ITERATIONS' | 'default';
45
+ warnings: string[];
46
+ }
47
+ /**
48
+ * Maximum model calls (tool-loop iterations) in one session/prompt turn.
49
+ * ACP_MAX_ITERATIONS wins over BUZZ_MAX_ITERATIONS; without either the default is 25 for coder/builder
50
+ * agents and 15 otherwise. A long-lived loop such as a meeting call spends one iteration per tool call, so
51
+ * agents that stay in one turn for minutes must raise it.
52
+ */
53
+ export declare function resolveIterationLimit(env: Env, model: string): IterationLimitResolution;
54
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 90000;
55
+ export interface RequestTimeoutResolution {
56
+ timeoutMs: number;
57
+ warnings: string[];
58
+ }
59
+ /** HTTP request and stream-read timeout (ms) for one chat-completions call. */
60
+ export declare function resolveRequestTimeoutMs(env: Env): RequestTimeoutResolution;
61
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * config.ts โ€” Pure, side-effect-free resolution of the adapter's non-secret settings from the environment.
3
+ *
4
+ * Every function takes the environment as an argument and returns the resolved value together with the
5
+ * warnings the caller should log, so the rules are unit-testable without spawning the adapter.
6
+ * Invalid input never throws: it falls back to the documented default and produces a warning that names
7
+ * the offending variable (a silently ignored setting is how a "zero thinking" agent kept thinking).
8
+ */
9
+ /**
10
+ * DEEPSEEK_* variables that are settings, not secrets. loadSecretKey() purges every other DEEPSEEK_* variable
11
+ * from process.env (default-deny, so an unknown variable is never exposed to child processes); a new setting
12
+ * must be listed here to survive that purge.
13
+ */
14
+ export const PRESERVED_DEEPSEEK_CONFIG = new Set([
15
+ 'DEEPSEEK_BASE_URL',
16
+ 'DEEPSEEK_MODEL',
17
+ 'DEEPSEEK_TEMPERATURE',
18
+ 'DEEPSEEK_THINKING',
19
+ 'DEEPSEEK_REASONING_EFFORT',
20
+ 'DEEPSEEK_REQUEST_TIMEOUT_MS'
21
+ ]);
22
+ // ============================================================================
23
+ // Thinking / reasoning effort
24
+ // ============================================================================
25
+ const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
26
+ function normalized(value) {
27
+ return (value ?? '').trim().toLowerCase();
28
+ }
29
+ function isReasoningEffort(value) {
30
+ return REASONING_EFFORTS.includes(value);
31
+ }
32
+ /**
33
+ * DEEPSEEK_THINKING `enabled` | `disabled` โ†’ thinking: { type }
34
+ * DEEPSEEK_REASONING_EFFORT `none` | minimal โ€ฆ max | ultra โ†’ `none` disables thinking, any other level is
35
+ * sent as reasoning_effort
36
+ *
37
+ * Nothing is sent unless it is configured, so endpoints that reject unknown fields keep working. Disabling
38
+ * thinking always wins over an effort level (a disabled model has no effort to set).
39
+ */
40
+ export function resolveReasoningParams(env) {
41
+ const warnings = [];
42
+ const thinkingRaw = normalized(env.DEEPSEEK_THINKING);
43
+ const effortRaw = normalized(env.DEEPSEEK_REASONING_EFFORT);
44
+ let thinking;
45
+ if (thinkingRaw === 'enabled' || thinkingRaw === 'disabled') {
46
+ thinking = thinkingRaw;
47
+ }
48
+ else if (thinkingRaw !== '') {
49
+ warnings.push(`Ignoring DEEPSEEK_THINKING="${thinkingRaw}": expected "enabled" or "disabled"; the API default applies.`);
50
+ }
51
+ let effort;
52
+ if (effortRaw === 'none' || isReasoningEffort(effortRaw)) {
53
+ effort = effortRaw;
54
+ }
55
+ else if (effortRaw !== '') {
56
+ warnings.push(`Ignoring DEEPSEEK_REASONING_EFFORT="${effortRaw}": expected "none" or one of ${REASONING_EFFORTS.join(', ')}; the API default applies.`);
57
+ }
58
+ if (thinking === 'disabled' || effort === 'none') {
59
+ if (thinking === 'enabled' && effort === 'none') {
60
+ warnings.push('DEEPSEEK_THINKING=enabled conflicts with DEEPSEEK_REASONING_EFFORT=none; thinking stays disabled.');
61
+ }
62
+ if (thinking === 'disabled' && effort !== undefined && effort !== 'none') {
63
+ warnings.push(`DEEPSEEK_REASONING_EFFORT=${effort} is ignored because DEEPSEEK_THINKING=disabled.`);
64
+ }
65
+ return { params: { thinking: { type: 'disabled' } }, warnings };
66
+ }
67
+ const params = {};
68
+ if (thinking === 'enabled') {
69
+ params.thinking = { type: 'enabled' };
70
+ }
71
+ if (effort !== undefined) {
72
+ params.reasoning_effort = effort;
73
+ }
74
+ return { params, warnings };
75
+ }
76
+ /** Short, log-friendly summary of what the request will say about thinking, e.g. `disabled` or `api-default`. */
77
+ export function describeReasoning(params) {
78
+ const parts = [];
79
+ if (params.thinking) {
80
+ parts.push(params.thinking.type);
81
+ }
82
+ if (params.reasoning_effort) {
83
+ parts.push(`effort:${params.reasoning_effort}`);
84
+ }
85
+ return parts.length > 0 ? parts.join('+') : 'api-default';
86
+ }
87
+ // ============================================================================
88
+ // Positive-integer settings
89
+ // ============================================================================
90
+ /** Strict positive decimal integer; rejects "10abc", "1.5", "1e3", "0" and "-5" that parseInt would half-accept. */
91
+ function parsePositiveInt(raw) {
92
+ if (!/^[1-9]\d*$/.test(raw)) {
93
+ return null;
94
+ }
95
+ const value = Number(raw);
96
+ return Number.isSafeInteger(value) ? value : null;
97
+ }
98
+ export const DEFAULT_ITERATION_LIMIT = 15;
99
+ export const CODER_ITERATION_LIMIT = 25;
100
+ /**
101
+ * Maximum model calls (tool-loop iterations) in one session/prompt turn.
102
+ * ACP_MAX_ITERATIONS wins over BUZZ_MAX_ITERATIONS; without either the default is 25 for coder/builder
103
+ * agents and 15 otherwise. A long-lived loop such as a meeting call spends one iteration per tool call, so
104
+ * agents that stay in one turn for minutes must raise it.
105
+ */
106
+ export function resolveIterationLimit(env, model) {
107
+ const warnings = [];
108
+ for (const name of ['ACP_MAX_ITERATIONS', 'BUZZ_MAX_ITERATIONS']) {
109
+ const raw = (env[name] ?? '').trim();
110
+ if (raw === '') {
111
+ continue;
112
+ }
113
+ const value = parsePositiveInt(raw);
114
+ if (value !== null) {
115
+ return { limit: value, source: name, warnings };
116
+ }
117
+ warnings.push(`Ignoring ${name}="${raw}": expected a positive integer.`);
118
+ }
119
+ const isCoder = model.includes('coder') || env.AGENT_ROLE === 'coder' || env.AGENT_ROLE === 'builder';
120
+ return { limit: isCoder ? CODER_ITERATION_LIMIT : DEFAULT_ITERATION_LIMIT, source: 'default', warnings };
121
+ }
122
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 90_000;
123
+ /** HTTP request and stream-read timeout (ms) for one chat-completions call. */
124
+ export function resolveRequestTimeoutMs(env) {
125
+ const raw = (env.DEEPSEEK_REQUEST_TIMEOUT_MS ?? '').trim();
126
+ if (raw === '') {
127
+ return { timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, warnings: [] };
128
+ }
129
+ const value = parsePositiveInt(raw);
130
+ if (value === null) {
131
+ return {
132
+ timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
133
+ warnings: [
134
+ `Ignoring DEEPSEEK_REQUEST_TIMEOUT_MS="${raw}": expected a positive integer; using ${DEFAULT_REQUEST_TIMEOUT_MS.toString()} ms.`
135
+ ]
136
+ };
137
+ }
138
+ return { timeoutMs: value, warnings: [] };
139
+ }
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 { PRESERVED_DEEPSEEK_CONFIG, describeReasoning, resolveIterationLimit, resolveReasoningParams, resolveRequestTimeoutMs } from './config.js';
18
19
  import { initTelemetry, registerSecret, parseNostrContext } from './telemetry.js';
19
20
  // ============================================================================
20
21
  // 1. STDIO HYGIENE
@@ -69,9 +70,9 @@ export function loadSecretKey(secretsPath = '/run/agent-secrets/env') {
69
70
  delete process.env.DEEPSEEK_AUTH_TOKEN;
70
71
  delete process.env.DEEPSEEK_TOKEN;
71
72
  delete process.env.DEEPSEEK_SECRET;
72
- const preservedDeepseekConfig = new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_MODEL', 'DEEPSEEK_TEMPERATURE']);
73
+ // Default-deny: any DEEPSEEK_* variable that is not a documented setting could be a secret.
73
74
  for (const envKey of Object.keys(process.env)) {
74
- if (/^DEEPSEEK_/i.test(envKey) && !preservedDeepseekConfig.has(envKey)) {
75
+ if (/^DEEPSEEK_/i.test(envKey) && !PRESERVED_DEEPSEEK_CONFIG.has(envKey)) {
75
76
  delete process.env[envKey];
76
77
  }
77
78
  }
@@ -91,6 +92,16 @@ const BASE_URL = (process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com').r
91
92
  const MODEL = process.env.DEEPSEEK_MODEL ?? 'deepseek-chat';
92
93
  const TEMPERATURE = parseFloat(process.env.DEEPSEEK_TEMPERATURE ?? '0.0');
93
94
  const MCP_COMMAND = process.env.ACP_MCP_COMMAND ?? process.env.BUZZ_ACP_MCP_COMMAND ?? 'buzz-dev-mcp';
95
+ // Resolved once at start-up (after the secret purge, so every setting listed in PRESERVED_DEEPSEEK_CONFIG is intact).
96
+ const REASONING = resolveReasoningParams(process.env);
97
+ const ITERATION_LIMIT = resolveIterationLimit(process.env, MODEL);
98
+ const REQUEST_TIMEOUT = resolveRequestTimeoutMs(process.env);
99
+ for (const warning of [...REASONING.warnings, ...ITERATION_LIMIT.warnings, ...REQUEST_TIMEOUT.warnings]) {
100
+ console.warn(warning);
101
+ }
102
+ console.info(`[config] model=${MODEL} thinking=${describeReasoning(REASONING.params)} ` +
103
+ `max_iterations=${ITERATION_LIMIT.limit.toString()} (${ITERATION_LIMIT.source}) ` +
104
+ `request_timeout_ms=${REQUEST_TIMEOUT.timeoutMs.toString()}`);
94
105
  // ============================================================================
95
106
  // 3. PROMPT ADAPTATION LAYER
96
107
  // ============================================================================
@@ -125,19 +136,6 @@ function getSystemPrompt(model) {
125
136
  }
126
137
  return adaptSystemPrompt(raw, model);
127
138
  }
128
- function getIterationLimit(model) {
129
- const maxIterEnv = process.env.ACP_MAX_ITERATIONS ?? process.env.BUZZ_MAX_ITERATIONS;
130
- if (maxIterEnv) {
131
- const val = parseInt(maxIterEnv, 10);
132
- if (!isNaN(val) && val > 0) {
133
- return val;
134
- }
135
- }
136
- const isCoder = model.includes('coder') ||
137
- process.env.AGENT_ROLE === 'coder' ||
138
- process.env.AGENT_ROLE === 'builder';
139
- return isCoder ? 25 : 15;
140
- }
141
139
  // ============================================================================
142
140
  // 4. SESSION & TRANSACTIONAL STATE MANAGER
143
141
  // ============================================================================
@@ -346,11 +344,13 @@ async function handleSessionPrompt(id, params) {
346
344
  const nostrMeta = parseNostrContext(promptText);
347
345
  const turnHandle = telemetryService?.startTurnSpan(sessionId, promptText, MODEL, nostrMeta);
348
346
  session.activeTurnHandle = turnHandle ?? null;
349
- const maxIterations = getIterationLimit(MODEL);
347
+ const maxIterations = ITERATION_LIMIT.limit;
350
348
  let iteration = 0;
351
349
  let lastToolError = null;
352
350
  let consecutiveErrorCount = 0;
353
351
  let turnCompleted = false;
352
+ // Why the loop ended when the model did not finish by itself: the while condition (iteration limit) unless the breaker fires.
353
+ let stoppedBy = 'iteration_limit';
354
354
  try {
355
355
  const tools = await mcpClient.getTools();
356
356
  while (iteration < maxIterations) {
@@ -380,8 +380,8 @@ async function handleSessionPrompt(id, params) {
380
380
  if (!DEEPSEEK_API_KEY) {
381
381
  throw new Error('DEEPSEEK_API_KEY is missing or empty. Cannot authenticate with DeepSeek API.');
382
382
  }
383
- // Call DeepSeek Chat Completions with Streaming with 90s request timeout
384
- const requestTimeoutMs = parseInt(process.env.DEEPSEEK_REQUEST_TIMEOUT_MS ?? '90000', 10);
383
+ // Call DeepSeek Chat Completions with Streaming (request timeout: DEEPSEEK_REQUEST_TIMEOUT_MS, default 90s)
384
+ const requestTimeoutMs = REQUEST_TIMEOUT.timeoutMs;
385
385
  const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
386
386
  const combinedSignal = session.abortController
387
387
  ? AbortSignal.any([session.abortController.signal, timeoutSignal])
@@ -397,6 +397,8 @@ async function handleSessionPrompt(id, params) {
397
397
  messages: messagesPayload,
398
398
  tools: tools && tools.length > 0 ? tools : undefined,
399
399
  temperature: TEMPERATURE,
400
+ // thinking / reasoning_effort: only present when DEEPSEEK_THINKING / DEEPSEEK_REASONING_EFFORT are set.
401
+ ...REASONING.params,
400
402
  stream: true,
401
403
  stream_options: { include_usage: true }
402
404
  }),
@@ -615,23 +617,39 @@ async function handleSessionPrompt(id, params) {
615
617
  }
616
618
  }
617
619
  console.warn(`Circuit breaker triggered for session ${sessionId}. Ending turn.`);
620
+ stoppedBy = 'circuit_breaker';
618
621
  break;
619
622
  }
620
623
  }
621
624
  if (!turnCompleted) {
622
625
  session.commit();
623
- const finalContent = iteration >= maxIterations
624
- ? (session.messages[session.messages.length - 1]?.content ?? 'Turn reached maximum iteration limit.')
626
+ const hitIterationLimit = stoppedBy === 'iteration_limit';
627
+ const finalContent = hitIterationLimit
628
+ ? `Turn stopped after ${maxIterations.toString()} model iterations (ACP_MAX_ITERATIONS). Send a new message to continue.`
625
629
  : 'Turn execution stopped because repetitive tool errors occurred.';
630
+ if (hitIterationLimit) {
631
+ console.warn(`[ITERATION LIMIT] Session ${sessionId} used all ${maxIterations.toString()} iterations of the turn. ` +
632
+ 'Ending it with stopReason max_turn_requests.');
633
+ sendNotification('session/update', {
634
+ sessionId,
635
+ delta: {
636
+ type: 'text',
637
+ text: `\n\n*(Notice: iteration limit of ${maxIterations.toString()} reached; the turn was stopped.)*`
638
+ }
639
+ });
640
+ }
626
641
  turnHandle?.end('OK', finalContent);
627
642
  void telemetryService?.backgroundFlush();
643
+ // Only ACP stop reasons are valid here: buzz-acp answers any other value ("unknown stopReason") with a
644
+ // protocol error that kills and respawns this process. An exhausted iteration budget is `max_turn_requests`;
645
+ // a circuit-breaker stop is a normal, committed end of turn.
628
646
  sendResponse(id, {
629
647
  sessionId,
630
648
  message: {
631
649
  role: 'assistant',
632
650
  content: finalContent
633
651
  },
634
- stopReason: iteration >= maxIterations ? 'max_iterations' : 'loop_aborted'
652
+ stopReason: hitIterationLimit ? 'max_turn_requests' : 'end_turn'
635
653
  });
636
654
  sendNotification('session/turnComplete', { sessionId });
637
655
  }
@@ -657,6 +675,9 @@ async function handleSessionPrompt(id, params) {
657
675
  text: `โš ๏ธ **DeepSeek Agent Notice:** Encountered an error during processing: ${msg}. Conversation state has been restored to last stable checkpoint.`
658
676
  }
659
677
  });
678
+ // Known deviation, left as is: `error` is not an ACP stop reason. buzz-acp treats it as a protocol error and
679
+ // answers by respawning this process and re-queueing the prompt with backoff, which is the retry that transient
680
+ // API failures (HTTP 429/5xx, timeouts) currently get. Moving to a JSON-RPC error needs that retry decided first.
660
681
  sendResponse(id, {
661
682
  sessionId,
662
683
  message: {
@@ -44,8 +44,9 @@ export interface NostrMetadata {
44
44
  */
45
45
  export declare function parseNostrContext(rawText: unknown): NostrMetadata;
46
46
  /**
47
- * Regex matching posted Nostr event ID across text and JSON formats:
47
+ * Regex matching posted Nostr event ID across text, JSON, and escaped JSON formats:
48
48
  * - buzz messages send / relay stdout: {"accepted":true,"event_id":"<64 hex>",...}
49
+ * - escaped JSON stdout: {\"accepted\":true,\"event_id\":\"<64 hex>\"}
49
50
  * - reply tools: `posted (N characters) ยท event_id <64 hex>`
50
51
  * - shell assignments: `event_id=<64 hex>` or `event_id: <64 hex>`
51
52
  */
package/dist/telemetry.js CHANGED
@@ -195,12 +195,13 @@ export function parseNostrContext(rawText) {
195
195
  return result;
196
196
  }
197
197
  /**
198
- * Regex matching posted Nostr event ID across text and JSON formats:
198
+ * Regex matching posted Nostr event ID across text, JSON, and escaped JSON formats:
199
199
  * - buzz messages send / relay stdout: {"accepted":true,"event_id":"<64 hex>",...}
200
+ * - escaped JSON stdout: {\"accepted\":true,\"event_id\":\"<64 hex>\"}
200
201
  * - reply tools: `posted (N characters) ยท event_id <64 hex>`
201
202
  * - shell assignments: `event_id=<64 hex>` or `event_id: <64 hex>`
202
203
  */
203
- export const POSTED_OUTPUT_PATTERN = /\bevent_id["']?\s*[:=]?\s*["']?([0-9a-f]{64})(?![0-9a-f])/i;
204
+ export const POSTED_OUTPUT_PATTERN = /\bevent_id\\?["']?\s*[:=]?\s*\\?["']?([0-9a-f]{64})(?![0-9a-f])/i;
204
205
  /**
205
206
  * Extract the posted Buzz event ID from a reply tool result or shell output.
206
207
  */
@@ -208,9 +209,22 @@ export function extractPostedEventId(output) {
208
209
  let text = '';
209
210
  if (typeof output === 'string') {
210
211
  text = output;
212
+ try {
213
+ const parsed = JSON.parse(output);
214
+ if (parsed && typeof parsed.stdout === 'string') {
215
+ text += ` ${parsed.stdout}`;
216
+ }
217
+ }
218
+ catch {
219
+ // Ignored
220
+ }
211
221
  }
212
222
  else if (output && typeof output === 'object') {
223
+ const rec = output;
213
224
  text = JSON.stringify(output);
225
+ if (typeof rec.stdout === 'string') {
226
+ text += ` ${rec.stdout}`;
227
+ }
214
228
  }
215
229
  if (!text) {
216
230
  return undefined;
@@ -312,7 +326,16 @@ export function initTelemetry(options) {
312
326
  const tracer = trace.getTracer('deepseek-agent-acp', getPackageVersion());
313
327
  return {
314
328
  startTurnSpan(sessionId, promptText, modelName, nostrMeta) {
315
- const sanitizedPrompt = truncatePayload(redactSecrets(promptText), 8192);
329
+ // Extract the event message block if present for cleaner prompt preview in Phoenix & buzz-trace
330
+ let promptForSpan = promptText;
331
+ if (promptText.includes('\n') && promptText.includes('Event ID:')) {
332
+ const blocks = promptText.split(/\n{2,}/);
333
+ const eventBlock = blocks.find((b) => b.includes('Event ID:'));
334
+ if (eventBlock) {
335
+ promptForSpan = eventBlock;
336
+ }
337
+ }
338
+ const sanitizedPrompt = truncatePayload(redactSecrets(promptForSpan), 8192);
316
339
  const resolvedSessionId = nostrMeta?.threadRootId ?? sessionId;
317
340
  const rootSpan = tracer.startSpan('agent_turn', {
318
341
  attributes: {
@@ -436,14 +459,22 @@ export function initTelemetry(options) {
436
459
  const outputBytes = computeOutputBytes(output);
437
460
  toolSpan.setAttribute('tool.output_bytes', outputBytes);
438
461
  // Extract posted_event_id for any posting tool (reply, buzz-thread__reply, shell, etc.)
462
+ let hasPostedId = false;
439
463
  if (!isError) {
440
464
  const postedId = extractPostedEventId(output);
441
465
  if (postedId) {
466
+ hasPostedId = true;
442
467
  toolSpan.setAttribute('nostr.posted_event_id', postedId);
443
468
  rootSpan.setAttribute('nostr.posted_event_id', postedId);
469
+ // Ensure output.value and tool.output are captured on the posting span for trace fidelity
470
+ const rawStr = typeof output === 'string' ? output : JSON.stringify(output);
471
+ const sanitizedOut = truncatePayload(redactSecrets(rawStr), 8192);
472
+ toolSpan.setAttribute('output.value', sanitizedOut);
473
+ toolSpan.setAttribute('tool.output', sanitizedOut);
474
+ toolSpan.setAttribute('output.mime_type', 'text/plain');
444
475
  }
445
476
  }
446
- if (process.env.OTEL_CAPTURE_TOOL_OUTPUT === 'true' && output !== undefined) {
477
+ if (process.env.OTEL_CAPTURE_TOOL_OUTPUT === 'true' && output !== undefined && !hasPostedId) {
447
478
  const sanitizedOut = truncatePayload(redactSecrets(output), 8192);
448
479
  toolSpan.setAttribute('output.value', sanitizedOut);
449
480
  toolSpan.setAttribute('output.mime_type', 'text/plain');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepseek-agent-acp",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Agent Client Protocol (ACP) adapter for DeepSeek reasoning and chat models with Model Context Protocol (MCP) tool execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -24,7 +24,7 @@
24
24
  "typecheck": "tsc --noEmit",
25
25
  "lint": "eslint src tests",
26
26
  "lint:fix": "eslint src tests --fix",
27
- "test": "node --test tests/test_sse_parser.test.mjs tests/test_deepseek_acp.test.mjs tests/test_telemetry.test.mjs",
27
+ "test": "node --test tests/test_config.test.mjs 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"