deepccc 0.2.8 → 0.2.9

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
@@ -180,8 +180,9 @@ Provider 发送 `max_tokens`,使用模型服务端默认值。可通过 `DEEPC
180
180
  或命令行 `--max-output-tokens` 覆盖;只接受正整数。该限制会同时覆盖模型思考内容、
181
181
  工具参数和最终回答,设置过小可能导致工具调用或长回复被截断。
182
182
 
183
- `contextWindow` 是模型上下文窗口(token),默认 `1048576`(1M,DeepSeek V4 Pro/Flash
184
- 原生规格);上下文压缩阈值自动 = `contextWindow × 0.8`(超出即把较早消息压缩为摘要)。
183
+ `contextWindow` 是模型上下文窗口(token),默认 `1048576`(1M,DeepSeek V4 Pro/Flash
184
+ 原生规格);常规上下文压缩阈值自动 = `contextWindow × 0.8`。工具输入与结果另有独立预算:
185
+ 默认取 `min(64000, 常规压缩阈值 × 25%)`,超过后会提前压缩,避免长会话积累大量低信号工具输出。
185
186
  可通过 `DEEPCCC_CONTEXT_WINDOW` 环境变量覆盖。⚠️ 超过模型/服务端实际上限时请求会被
186
187
  API 拒绝(context length exceeded),实际窗口以模型与所用服务端为准(如 litellm 的
187
188
  `max_input_tokens`)。
@@ -324,7 +325,7 @@ deepccc-cli --dangerously-bypass-permissions
324
325
 
325
326
  交互模式下,每轮回复渲染为固定"过程区块":状态行(压缩上下文中/生成回复中/完成/已停止/异常结束)+ 折叠工具行 + 原地更新正文,不滚屏刷 JSON。活动状态有心跳点号动画;完成/停止/异常后区块定型留在屏幕上。
326
327
 
327
- 持久化上下文达到 token 阈值时,deepccc 会先按 token 预算保留最近消息,再压缩较早内容;超长历史消息、工具记录和压缩输入会被限长,避免单次摘要请求反复吞入巨量文本。一次压缩最多等待 5 分钟,超时或摘要失败会给出明确错误,不会自动重放用户请求。
328
+ 持久化上下文达到常规 token 阈值或独立工具预算时,deepccc 会先按预算保留最近消息,再压缩较早内容;工具历史使用标准结构化 tool-call/tool-result 消息重放,不会把内部 `[工具记录]` 文本重复送回模型。若模型仍输出伪工具记录,系统会丢弃并重试一次;重复失败时安全终止,避免把未执行命令当成真实结果。超长历史消息、工具记录和压缩输入会被限长,一次压缩最多等待 5 分钟。
328
329
 
329
330
  如果终端渲染出现异常,可以强制回退为纯文本流式输出:
330
331
 
package/dist/context.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import type { ModelMessage } from "ai";
1
2
  export type BuiltinContextRole = "user" | "assistant";
2
3
  /**
3
- * 结构化工具调用存档(与 content 里的 [Tool transcript] 文本互为冗余视图):
4
- * content 文本保持不变供模型消费;toolCalls 数组提供可检索、可回放的结构化数据。
4
+ * 结构化工具调用存档。content 只保存面向用户的回答正文;工具输入与结果
5
+ * 独立保存,供模型按标准 tool-call/tool-result 消息重放以及 UI 检索、回放。
5
6
  */
6
7
  export interface BuiltinContextToolCall {
7
8
  /** Provider tool-call id,用于 Web 在流式运行与持久化消息之间保持同一张工具卡状态。 */
@@ -33,7 +34,7 @@ export interface BuiltinContextMessage {
33
34
  content: string;
34
35
  /** 可选结构化工具调用记录(assistant 消息专用) */
35
36
  toolCalls?: BuiltinContextToolCall[];
36
- /** 文本与工具事件的真实发生顺序,仅供 UI 回放;模型上下文仍使用 content。 */
37
+ /** 文本与工具事件的真实发生顺序,同时用于 UI 与模型的结构化历史重放。 */
37
38
  timeline?: BuiltinContextTimelineEntry[];
38
39
  }
39
40
  export interface BuiltinContextState {
@@ -71,6 +72,8 @@ export interface BuiltinContextOptions {
71
72
  contextWindow?: number;
72
73
  /** 直接指定压缩阈值(token),优先于 contextWindow 的 80% 派生。 */
73
74
  compactAtTokens?: number;
75
+ /** Independent budget for retained structured tool inputs/results. */
76
+ maxToolContextTokens?: number;
74
77
  keepRecentMessages?: number;
75
78
  }
76
79
  export declare const DEFAULT_BUILTIN_CONTEXT_DIR: string;
@@ -81,6 +84,7 @@ export declare const COMPACTION_THRESHOLD_RATIO = 0.8;
81
84
  /** 默认压缩阈值 = 1M × 0.8。 */
82
85
  export declare const DEFAULT_COMPACT_AT_TOKENS: number;
83
86
  export declare const DEFAULT_KEEP_RECENT_MESSAGES = 16;
87
+ export declare const DEFAULT_MAX_TOOL_CONTEXT_TOKENS = 64000;
84
88
  export declare function normalizeBuiltinSessionId(value: string): string;
85
89
  export declare function defaultBuiltinSessionId(cwd?: string): string;
86
90
  export declare function newBuiltinSessionId(now?: Date, suffix?: string): string;
@@ -88,22 +92,18 @@ export declare function getBuiltinContextSession(sessionId: string, contextDir?:
88
92
  export declare function readBuiltinContextState(sessionId: string, contextDir?: string): BuiltinContextState | null;
89
93
  export declare function listBuiltinContextSessions(contextDir?: string): BuiltinContextSessionInfo[];
90
94
  export declare function latestBuiltinSessionForCwd(cwd: string, contextDir?: string): BuiltinContextSessionInfo | null;
91
- /**
92
- * 估算上下文的 token 数(用于决定何时压缩)。
93
- * 按字符类型加权,比旧版 chars/3 更接近真实:CJK 字符 ≈ 1 token/字,
94
- * 其他字符 ≈ 3.5 chars/token。避免中文长上下文被严重低估导致压缩过晚。
95
- */
95
+ export declare function estimateBuiltinToolContextTokens(messages: readonly BuiltinContextMessage[]): number;
96
96
  export declare function estimateBuiltinContextTokens(summary: string, messages: readonly BuiltinContextMessage[]): number;
97
97
  export declare function serializeMessagesForSummary(messages: readonly BuiltinContextMessage[]): string;
98
98
  export declare const DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS = 32000;
99
99
  export declare const DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24000;
100
100
  /**
101
- * 构造持久化的 assistant 消息:content 保持既有"正文 + [Tool transcript]"文本格式
102
- * (模型上下文行为不变),同时附加结构化 toolCalls 存档(供 session-search 等检索)。
101
+ * 构造持久化 assistant 消息。content 仅保存回答正文,工具事件保存在
102
+ * toolCalls/timeline,避免内部 transcript 语法回流并诱导模型伪造工具执行。
103
103
  */
104
104
  export declare function buildPersistedAssistantMessage(params: {
105
105
  fullText: string;
106
- /** [Tool transcript] 的文本行(按原始流顺序,含 tool_call / tool_result / tool_error) */
106
+ /** @deprecated 兼容旧调用方;工具记录只从 toolCalls/timeline 持久化。 */
107
107
  transcriptLines: readonly string[];
108
108
  /** 结构化工具调用(按调用顺序);有则写入消息的 toolCalls 字段 */
109
109
  toolCalls?: readonly BuiltinContextToolCall[];
@@ -118,6 +118,7 @@ export declare class BuiltinContextManager {
118
118
  readonly contextDir: string;
119
119
  readonly sessionId: string;
120
120
  readonly compactAtTokens: number;
121
+ readonly maxToolContextTokens: number;
121
122
  readonly keepRecentMessages: number;
122
123
  private readonly cwd?;
123
124
  private state;
@@ -128,7 +129,7 @@ export declare class BuiltinContextManager {
128
129
  get contextFilePath(): string;
129
130
  appendMessage(message: BuiltinContextMessage): void;
130
131
  setSummary(summary: string): void;
131
- buildModelMessages(): BuiltinContextMessage[];
132
+ buildModelMessages(): ModelMessage[];
132
133
  planCompaction(): BuiltinCompactionPlan | null;
133
134
  applyCompaction(summary: string, plan: BuiltinCompactionPlan): void;
134
135
  reset(): void;
package/dist/context.js CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { hasMalformedToolProtocolText } from "./tool-protocol.js";
5
+ import { hasImitatedToolTranscriptText, hasMalformedToolProtocolText, } from "./tool-protocol.js";
6
6
  export const DEFAULT_BUILTIN_CONTEXT_DIR = join(homedir(), ".deepccc", "sessions");
7
7
  /** 默认模型上下文窗口:1M(DeepSeek V4 Pro/Flash 原生规格)。 */
8
8
  export const DEFAULT_CONTEXT_WINDOW_TOKENS = 1_048_576;
@@ -15,6 +15,11 @@ const RECENT_CONTEXT_BUDGET_RATIO = 0.6;
15
15
  const MAX_COMPACTION_SUMMARY_CHARS = 8_000;
16
16
  const MAX_COMPACTION_MESSAGE_CHARS = 24_000;
17
17
  const MAX_COMPACTION_SOURCE_CHARS = 64_000;
18
+ export const DEFAULT_MAX_TOOL_CONTEXT_TOKENS = 64_000;
19
+ const TOOL_CONTEXT_BUDGET_RATIO = 0.25;
20
+ const RECENT_TOOL_CONTEXT_BUDGET_RATIO = 0.6;
21
+ const STORED_TOOL_TRANSCRIPT_MARKER = "\n\n[工具记录]\n";
22
+ const QUARANTINED_PROTOCOL_REPLY = "[上一轮响应因工具协议异常已隔离,不能视为已执行;请根据后续用户消息继续。]";
18
23
  export function normalizeBuiltinSessionId(value) {
19
24
  return value.replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "default";
20
25
  }
@@ -209,8 +214,11 @@ export function latestBuiltinSessionForCwd(cwd, contextDir = DEFAULT_BUILTIN_CON
209
214
  * 按字符类型加权,比旧版 chars/3 更接近真实:CJK 字符 ≈ 1 token/字,
210
215
  * 其他字符 ≈ 3.5 chars/token。避免中文长上下文被严重低估导致压缩过晚。
211
216
  */
212
- export function estimateBuiltinContextTokens(summary, messages) {
213
- const text = summary + messages.reduce((sum, m) => sum + `${m.role}\n${m.content}\n`, "");
217
+ function stripStoredToolTranscript(content) {
218
+ const markerIndex = content.indexOf(STORED_TOOL_TRANSCRIPT_MARKER);
219
+ return markerIndex >= 0 ? content.slice(0, markerIndex) : content;
220
+ }
221
+ function estimateTextTokens(text) {
214
222
  let cjk = 0;
215
223
  for (const ch of text) {
216
224
  if (/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch))
@@ -219,6 +227,30 @@ export function estimateBuiltinContextTokens(summary, messages) {
219
227
  const other = text.length - cjk;
220
228
  return Math.ceil(cjk + other / 3.5);
221
229
  }
230
+ export function estimateBuiltinToolContextTokens(messages) {
231
+ let text = "";
232
+ for (const message of messages) {
233
+ if (message.role !== "assistant")
234
+ continue;
235
+ if (message.toolCalls?.length) {
236
+ for (const call of message.toolCalls) {
237
+ text += `${call.name}\n${call.input ?? ""}\n${call.output ?? ""}\n`;
238
+ }
239
+ continue;
240
+ }
241
+ for (const entry of message.timeline ?? []) {
242
+ if (entry.type === "tool_use")
243
+ text += `${entry.name}\n${entry.input ?? ""}\n`;
244
+ if (entry.type === "tool_result")
245
+ text += `${entry.name ?? ""}\n${entry.output ?? ""}\n`;
246
+ }
247
+ }
248
+ return estimateTextTokens(text);
249
+ }
250
+ export function estimateBuiltinContextTokens(summary, messages) {
251
+ const text = summary + messages.reduce((sum, message) => sum + `${message.role}\n${stripStoredToolTranscript(message.content)}\n`, "");
252
+ return estimateTextTokens(text) + estimateBuiltinToolContextTokens(messages);
253
+ }
222
254
  export function serializeMessagesForSummary(messages) {
223
255
  return messages
224
256
  .map((message, index) => `### ${index + 1}. ${message.role}\n${message.content}`)
@@ -236,19 +268,34 @@ export const DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
236
268
  export const DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
237
269
  const ASSISTANT_TRUNCATED_MARKER = "...[助手回复已在上下文中截断]...";
238
270
  const TOOL_TRANSCRIPT_TRUNCATED_MARKER = "...[工具记录已截断]...";
271
+ function truncateStructuredToolCalls(calls, maxChars) {
272
+ const payloads = calls.flatMap((call) => [call.input ?? "", call.output ?? ""]).filter(Boolean);
273
+ const total = payloads.reduce((sum, value) => sum + value.length, 0);
274
+ if (total <= maxChars)
275
+ return calls;
276
+ const sharedBudget = Math.max(0, maxChars - payloads.length);
277
+ const truncatePayload = (value) => {
278
+ if (value === undefined || value.length === 0)
279
+ return value;
280
+ const budget = 1 + Math.floor(sharedBudget * value.length / Math.max(1, total));
281
+ return truncateMiddle(value, budget, TOOL_TRANSCRIPT_TRUNCATED_MARKER);
282
+ };
283
+ return calls.map((call) => ({
284
+ ...call,
285
+ ...(call.input !== undefined ? { input: truncatePayload(call.input) } : {}),
286
+ ...(call.output !== undefined ? { output: truncatePayload(call.output) } : {}),
287
+ }));
288
+ }
239
289
  /**
240
- * 构造持久化的 assistant 消息:content 保持既有"正文 + [Tool transcript]"文本格式
241
- * (模型上下文行为不变),同时附加结构化 toolCalls 存档(供 session-search 等检索)。
290
+ * 构造持久化 assistant 消息。content 仅保存回答正文,工具事件保存在
291
+ * toolCalls/timeline,避免内部 transcript 语法回流并诱导模型伪造工具执行。
242
292
  */
243
293
  export function buildPersistedAssistantMessage(params) {
244
294
  const maxAssistantChars = params.maxAssistantChars ?? DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS;
245
295
  const maxTranscriptChars = params.maxTranscriptChars ?? DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS;
246
296
  const persistedAssistantText = truncateMiddle(params.fullText, maxAssistantChars, ASSISTANT_TRUNCATED_MARKER);
247
- const content = params.transcriptLines.length > 0
248
- ? `${persistedAssistantText}\n\n[工具记录]\n${truncateMiddle(params.transcriptLines.join("\n"), maxTranscriptChars, TOOL_TRANSCRIPT_TRUNCATED_MARKER)}`
249
- : persistedAssistantText;
250
- const message = { role: "assistant", content };
251
- const toolCalls = normalizeToolCalls(params.toolCalls);
297
+ const message = { role: "assistant", content: persistedAssistantText };
298
+ const toolCalls = truncateStructuredToolCalls(normalizeToolCalls(params.toolCalls), maxTranscriptChars);
252
299
  if (toolCalls.length > 0)
253
300
  message.toolCalls = toolCalls;
254
301
  const timeline = truncatePersistedTimeline(normalizeTimeline(params.timeline));
@@ -299,6 +346,118 @@ function truncatePersistedTimeline(entries) {
299
346
  return entry;
300
347
  });
301
348
  }
349
+ function parseToolInput(value) {
350
+ if (!value?.trim())
351
+ return {};
352
+ try {
353
+ return JSON.parse(value);
354
+ }
355
+ catch {
356
+ return { raw: value };
357
+ }
358
+ }
359
+ function parseToolOutput(value) {
360
+ if (!value?.trim())
361
+ return { type: "text", value: "" };
362
+ try {
363
+ return { type: "json", value: JSON.parse(value) };
364
+ }
365
+ catch {
366
+ return { type: "text", value };
367
+ }
368
+ }
369
+ function replayStructuredAssistantMessage(message, messageIndex) {
370
+ const plainText = stripStoredToolTranscript(message.content);
371
+ const hasStructuredTools = Boolean(message.toolCalls?.length
372
+ || message.timeline?.some((entry) => entry.type === "tool_use" || entry.type === "tool_result"));
373
+ if (!hasStructuredTools)
374
+ return [{ role: "assistant", content: plainText }];
375
+ const timeline = message.timeline?.length
376
+ ? message.timeline
377
+ : [
378
+ ...(message.toolCalls ?? []).map((call, callIndex) => ({
379
+ type: "tool_use",
380
+ id: call.id ?? `context-tool-${messageIndex}-${callIndex}`,
381
+ name: call.name,
382
+ ...(call.input !== undefined ? { input: call.input } : {}),
383
+ })),
384
+ ...(message.toolCalls ?? []).map((call, callIndex) => ({
385
+ type: "tool_result",
386
+ tool_use_id: call.id ?? `context-tool-${messageIndex}-${callIndex}`,
387
+ name: call.name,
388
+ ...(call.output !== undefined ? { output: call.output } : {}),
389
+ ...(call.is_error !== undefined ? { is_error: call.is_error } : {}),
390
+ })),
391
+ ...(plainText ? [{ type: "text", text: plainText }] : []),
392
+ ];
393
+ const messages = [];
394
+ let assistantParts = [];
395
+ let toolParts = [];
396
+ const pendingCalls = new Map();
397
+ const knownNames = new Map();
398
+ const emittedCallIds = new Set();
399
+ const flushAssistant = () => {
400
+ if (!assistantParts.length)
401
+ return;
402
+ messages.push({ role: "assistant", content: assistantParts });
403
+ assistantParts = [];
404
+ };
405
+ const flushTools = () => {
406
+ for (const [toolCallId, toolName] of pendingCalls) {
407
+ toolParts.push({
408
+ type: "tool-result",
409
+ toolCallId,
410
+ toolName,
411
+ output: { type: "text", value: "[tool execution interrupted; result unavailable]" },
412
+ });
413
+ }
414
+ pendingCalls.clear();
415
+ if (!toolParts.length)
416
+ return;
417
+ messages.push({ role: "tool", content: toolParts });
418
+ toolParts = [];
419
+ };
420
+ for (const entry of timeline) {
421
+ if (entry.type === "text") {
422
+ flushTools();
423
+ const previous = assistantParts[assistantParts.length - 1];
424
+ if (previous?.type === "text")
425
+ previous.text += entry.text;
426
+ else if (entry.text)
427
+ assistantParts.push({ type: "text", text: entry.text });
428
+ }
429
+ else if (entry.type === "tool_use") {
430
+ flushTools();
431
+ knownNames.set(entry.id, entry.name);
432
+ pendingCalls.set(entry.id, entry.name);
433
+ emittedCallIds.add(entry.id);
434
+ assistantParts.push({
435
+ type: "tool-call",
436
+ toolCallId: entry.id,
437
+ toolName: entry.name,
438
+ input: parseToolInput(entry.input),
439
+ });
440
+ }
441
+ else {
442
+ if (!emittedCallIds.has(entry.tool_use_id))
443
+ continue;
444
+ const toolName = entry.name ?? knownNames.get(entry.tool_use_id);
445
+ if (!toolName)
446
+ continue;
447
+ flushAssistant();
448
+ pendingCalls.delete(entry.tool_use_id);
449
+ toolParts.push({
450
+ type: "tool-result",
451
+ toolCallId: entry.tool_use_id,
452
+ toolName,
453
+ output: parseToolOutput(entry.output),
454
+ });
455
+ }
456
+ }
457
+ flushAssistant();
458
+ flushTools();
459
+ return messages;
460
+ }
302
461
  function serializeMessagesForCompaction(messages) {
303
462
  const sections = [];
304
463
  let remaining = MAX_COMPACTION_SOURCE_CHARS;
@@ -339,6 +498,7 @@ export class BuiltinContextManager {
339
498
  contextDir;
340
499
  sessionId;
341
500
  compactAtTokens;
501
+ maxToolContextTokens;
342
502
  keepRecentMessages;
343
503
  cwd;
344
504
  state;
@@ -349,6 +509,8 @@ export class BuiltinContextManager {
349
509
  this.cwd = options.cwd;
350
510
  this.compactAtTokens = options.compactAtTokens
351
511
  ?? Math.floor((options.contextWindow ?? DEFAULT_CONTEXT_WINDOW_TOKENS) * COMPACTION_THRESHOLD_RATIO);
512
+ this.maxToolContextTokens = Math.max(1, options.maxToolContextTokens
513
+ ?? Math.min(DEFAULT_MAX_TOOL_CONTEXT_TOKENS, Math.floor(this.compactAtTokens * TOOL_CONTEXT_BUDGET_RATIO)));
352
514
  this.keepRecentMessages = Math.max(1, options.keepRecentMessages ?? DEFAULT_KEEP_RECENT_MESSAGES);
353
515
  this.state = this.load();
354
516
  }
@@ -388,7 +550,12 @@ export class BuiltinContextManager {
388
550
  // Keep malformed provider output on disk for diagnosis, but quarantine it
389
551
  // from future prompts so one protocol failure cannot teach the model to
390
552
  // repeat the same invalid tool syntax on every later turn.
391
- messages.push(...this.modelSafeMessages());
553
+ for (const [index, message] of this.modelSafeMessages().entries()) {
554
+ if (message.role === "user")
555
+ messages.push({ role: "user", content: message.content });
556
+ else
557
+ messages.push(...replayStructuredAssistantMessage(message, index));
558
+ }
392
559
  return messages;
393
560
  }
394
561
  planCompaction() {
@@ -396,16 +563,19 @@ export class BuiltinContextManager {
396
563
  // leaks here too, otherwise a later summary could reintroduce the bad syntax.
397
564
  const messages = this.modelSafeMessages();
398
565
  const estimated = estimateBuiltinContextTokens(this.state.summary, messages);
399
- if (estimated <= this.compactAtTokens)
566
+ const toolEstimated = estimateBuiltinToolContextTokens(messages);
567
+ if (estimated <= this.compactAtTokens && toolEstimated <= this.maxToolContextTokens)
400
568
  return null;
401
569
  if (messages.length <= 1)
402
570
  return null;
403
571
  const earliestAllowed = Math.max(0, messages.length - this.keepRecentMessages);
404
572
  const recentTokenBudget = Math.max(1, Math.floor(this.compactAtTokens * RECENT_CONTEXT_BUDGET_RATIO));
573
+ const recentToolBudget = Math.max(1, Math.floor(this.maxToolContextTokens * RECENT_TOOL_CONTEXT_BUDGET_RATIO));
405
574
  let splitAt = messages.length - 1;
406
575
  for (let index = messages.length - 2; index >= earliestAllowed; index -= 1) {
407
576
  const candidate = messages.slice(index);
408
- if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget)
577
+ if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget
578
+ || estimateBuiltinToolContextTokens(candidate) > recentToolBudget)
409
579
  break;
410
580
  splitAt = index;
411
581
  }
@@ -416,8 +586,8 @@ export class BuiltinContextManager {
416
586
  splitAt = 1;
417
587
  return {
418
588
  previousSummary: this.state.summary,
419
- oldMessages: this.state.messages.slice(0, splitAt),
420
- recentMessages: this.state.messages.slice(splitAt),
589
+ oldMessages: messages.slice(0, splitAt),
590
+ recentMessages: messages.slice(splitAt),
421
591
  };
422
592
  }
423
593
  applyCompaction(summary, plan) {
@@ -441,7 +611,17 @@ export class BuiltinContextManager {
441
611
  renameSync(tmp, this.contextFilePath);
442
612
  }
443
613
  modelSafeMessages() {
444
- return this.state.messages.filter((message) => message.role !== "assistant" || !hasMalformedToolProtocolText(message.content));
614
+ return this.state.messages.map((message) => {
615
+ if (message.role !== "assistant")
616
+ return message;
617
+ const hasStructuredTools = Boolean(message.toolCalls?.length
618
+ || message.timeline?.some((entry) => entry.type === "tool_use" || entry.type === "tool_result"));
619
+ const copiedStoredTranscript = hasImitatedToolTranscriptText(message.content) && hasStructuredTools;
620
+ if (hasMalformedToolProtocolText(message.content) && !copiedStoredTranscript) {
621
+ return { role: "assistant", content: QUARANTINED_PROTOCOL_REPLY };
622
+ }
623
+ return { ...message, content: stripStoredToolTranscript(message.content) };
624
+ });
445
625
  }
446
626
  load() {
447
627
  if (!this.persist || !existsSync(this.contextFilePath))
package/dist/index.d.ts CHANGED
@@ -55,6 +55,8 @@ export interface ChatSessionOptions {
55
55
  sessionId?: string;
56
56
  /** Compact older context when the rough token estimate exceeds this value. */
57
57
  compactAtTokens?: number;
58
+ /** Independent retained-tool budget; defaults to min(64K, compaction threshold x 25%). */
59
+ maxToolContextTokens?: number;
58
60
  /**
59
61
  * 模型上下文窗口(token),默认 1048576(1M)。压缩阈值自动 = contextWindow × 0.8;
60
62
  * 显式 compactAtTokens 优先于该派生值。
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
7
7
  import { createAnthropic } from "@ai-sdk/anthropic";
8
- import { generateText, isLoopFinished, stepCountIs, streamText } from "ai";
8
+ import { generateText, isLoopFinished, stepCountIs, streamText, } from "ai";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { isAbsolute, join, resolve } from "node:path";
@@ -144,7 +144,7 @@ function addAnthropicToolJsonCompatibilityNote(messages) {
144
144
  // 部分 Anthropic→OpenAI/Ark 转换器会用 response_format=json_object
145
145
  // 实现工具调用,却只在 messages 中校验 JSON 关键词、不读取顶层 system。
146
146
  // 这里仅声明工具参数的编码方式,并明确不要求普通最终回复输出 JSON。
147
- return messages.map((message, index) => (index === lastUserIndex
147
+ return messages.map((message, index) => (index === lastUserIndex && message.role === "user" && typeof message.content === "string"
148
148
  ? {
149
149
  ...message,
150
150
  content: `${message.content}\n\n${ANTHROPIC_TOOL_JSON_COMPATIBILITY_NOTE}`,
@@ -161,11 +161,13 @@ function addAnthropicToolJsonCompatibilityNote(messages) {
161
161
  function maybeAppendCompactionRecoveryHint(messages, summary, rawLogsEnabled, sessionId) {
162
162
  if (!summary.trim())
163
163
  return messages;
164
- const summaryIndex = messages.findIndex((message) => message.role === "user" && message.content.startsWith("以下是更早的对话摘要"));
164
+ const summaryIndex = messages.findIndex((message) => message.role === "user"
165
+ && typeof message.content === "string"
166
+ && message.content.startsWith("以下是更早的对话摘要"));
165
167
  if (summaryIndex < 0)
166
168
  return messages;
167
169
  const hint = rawLogsEnabled ? buildCompactionRecoveryHint(sessionId) : COMPACTION_RECOVERY_HINT_DISABLED;
168
- return messages.map((message, index) => (index === summaryIndex
170
+ return messages.map((message, index) => (index === summaryIndex && message.role === "user" && typeof message.content === "string"
169
171
  ? { ...message, content: `${message.content}\n\n${hint}` }
170
172
  : message));
171
173
  }
@@ -353,6 +355,7 @@ export class ChatSession {
353
355
  cwd: this.cwd,
354
356
  contextWindow: options.contextWindow ?? appConfig.contextWindow,
355
357
  compactAtTokens: options.compactAtTokens,
358
+ maxToolContextTokens: options.maxToolContextTokens,
356
359
  keepRecentMessages: options.keepRecentMessages,
357
360
  });
358
361
  this.permissionGate = new PermissionGate(this.permissionMode, this.permissionResolver);
@@ -559,7 +562,7 @@ export class ChatSession {
559
562
  }
560
563
  }
561
564
  if (hasMalformedToolProtocolText(fullText)) {
562
- console.warn(`[DeepCCC] malformed DSML tool output detected for ${this.context.sessionId} `
565
+ console.warn(`[DeepCCC] malformed tool protocol text detected for ${this.context.sessionId} `
563
566
  + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
564
567
  rawLog?.writeLine(safeRawStreamJson({
565
568
  type: "deepccc_tool_protocol_recovery",
@@ -572,8 +575,8 @@ export class ChatSession {
572
575
  continue;
573
576
  }
574
577
  throw new Error(toolCallOrder.length > 0
575
- ? "工具调用协议异常:检测到混合的结构化与 DSML 文本调用,为避免重复执行工具,本轮已安全终止"
576
- : "工具调用协议异常:模型重试后仍输出了无效的 DSML 工具调用");
578
+ ? "工具调用协议异常:检测到混合的结构化调用与伪造工具文本,为避免重复执行工具,本轮已安全终止"
579
+ : "工具调用协议异常:模型重试后仍输出了无效或伪造的工具调用文本");
577
580
  }
578
581
  completed = true;
579
582
  const collectedToolCalls = toolCallOrder
@@ -1,2 +1,8 @@
1
+ /**
2
+ * Detects a model copying DeepCCC's persisted/debug transcript syntax into
3
+ * normal assistant text. Both the dedicated header and an event line are
4
+ * required so documentation and ordinary discussion remain valid.
5
+ */
6
+ export declare function hasImitatedToolTranscriptText(text: string): boolean;
1
7
  export declare function hasMalformedToolProtocolText(text: string): boolean;
2
8
  export declare const TOOL_PROTOCOL_RECOVERY_PROMPT: string;
@@ -6,12 +6,23 @@
6
6
  const MALFORMED_DSML_TRAILER = /<\/[||]{2}DSML[||]{2}(?:parameter|invoke)>\s*$/iu;
7
7
  const RENDERED_TOOL_CALL = /\[调用\s+[A-Za-z_][\w.-]*\]/u;
8
8
  const DSML_INVOKE_TAG = /<[||]{2}DSML[||]{2}invoke\b/iu;
9
+ const TOOL_TRANSCRIPT_HEADER = /(?:^|\n)\s*\[工具记录\]\s*(?:\n|$)/u;
10
+ const TOOL_TRANSCRIPT_EVENT = /(?:^|\n)\s*tool_(?:call|result|error)\s+[A-Za-z_][\w.-]*\s*:/u;
11
+ /**
12
+ * Detects a model copying DeepCCC's persisted/debug transcript syntax into
13
+ * normal assistant text. Both the dedicated header and an event line are
14
+ * required so documentation and ordinary discussion remain valid.
15
+ */
16
+ export function hasImitatedToolTranscriptText(text) {
17
+ return TOOL_TRANSCRIPT_HEADER.test(text) && TOOL_TRANSCRIPT_EVENT.test(text);
18
+ }
9
19
  export function hasMalformedToolProtocolText(text) {
10
- return MALFORMED_DSML_TRAILER.test(text)
20
+ const malformedDsml = MALFORMED_DSML_TRAILER.test(text)
11
21
  && (RENDERED_TOOL_CALL.test(text) || DSML_INVOKE_TAG.test(text));
22
+ return malformedDsml || hasImitatedToolTranscriptText(text);
12
23
  }
13
24
  export const TOOL_PROTOCOL_RECOVERY_PROMPT = [
14
25
  "[系统恢复提示] 上一次响应泄漏了内部工具调用协议,因此已被丢弃。",
15
- "请重新完成当前用户请求。需要调用工具时,只能使用 API 提供的结构化工具调用;不要把 DSML 或工具参数作为普通文本输出。",
16
- "不要提及本恢复提示,也不要假装工具已经执行。",
26
+ "请重新完成当前用户请求。需要调用工具时,只能使用 API 提供的结构化工具调用;不要把 DSML、[工具调用]、[工具记录] 或工具参数作为普通文本输出。",
27
+ "不要提及本恢复提示,不要伪造工具已执行,也不要根据未执行的命令声称得到了结果。",
17
28
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [