chatccc 0.2.270 → 0.2.276

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.
Files changed (42) hide show
  1. package/README.md +16 -10
  2. package/config.sample.json +4 -3
  3. package/deepccc-agent/README.md +147 -61
  4. package/deepccc-agent/package.json +5 -2
  5. package/dist/deepccc-agent/src/attachments.js +192 -0
  6. package/dist/deepccc-agent/src/cli.js +59 -13
  7. package/dist/deepccc-agent/src/config.js +57 -4
  8. package/dist/deepccc-agent/src/context.js +299 -16
  9. package/dist/deepccc-agent/src/file-tools.js +33 -0
  10. package/dist/deepccc-agent/src/index.js +68 -21
  11. package/dist/deepccc-agent/src/tool-protocol.js +14 -3
  12. package/dist/deepccc-agent/src/web-entry.js +72 -0
  13. package/dist/deepccc-agent/src/web-page.js +414 -0
  14. package/dist/deepccc-agent/src/web-runtime.js +331 -0
  15. package/dist/deepccc-agent/src/web-server.js +476 -0
  16. package/dist/deepccc-agent/src/web-session-store.js +162 -0
  17. package/dist/deepccc-agent/src/web-tool-presentation.js +123 -0
  18. package/dist/src/adapters/ccc-adapter.js +5 -1
  19. package/dist/src/agent-capability-grants.js +26 -0
  20. package/dist/src/agent-delegate-task.js +5 -2
  21. package/dist/src/agent-file-rpc.js +6 -1
  22. package/dist/src/agent-image-rpc.js +6 -1
  23. package/dist/src/agent-team/application/task-execution-service.js +330 -97
  24. package/dist/src/agent-team/domain/task-run.js +14 -1
  25. package/dist/src/agent-team/infrastructure/task-execution-runtime.js +7 -2
  26. package/dist/src/agent-team/main-agent-bootstrap.js +24 -1
  27. package/dist/src/agent-team/repositories/json-task-run-repository.js +22 -4
  28. package/dist/src/agent-team/web/agent-team-page.js +14 -7
  29. package/dist/src/cards.js +7 -4
  30. package/dist/src/config.js +12 -0
  31. package/dist/src/im-skills.js +9 -2
  32. package/dist/src/orchestrator.js +117 -29
  33. package/dist/src/safe-maintenance.js +4 -1
  34. package/dist/src/session-name.js +15 -0
  35. package/dist/src/session.js +54 -9
  36. package/dist/src/web-ui.js +76 -32
  37. package/im-skills/feishu-skill/receive-send-file.md +3 -2
  38. package/im-skills/feishu-skill/receive-send-image.md +3 -2
  39. package/im-skills/feishu-skill/send-file.mjs +6 -5
  40. package/im-skills/feishu-skill/send-image.mjs +6 -5
  41. package/im-skills/feishu-skill/skill.md +4 -2
  42. package/package.json +1 -1
@@ -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
  }
@@ -49,8 +54,42 @@ function normalizeMessage(value) {
49
54
  const toolCalls = normalizeToolCalls(raw.toolCalls);
50
55
  if (toolCalls.length > 0)
51
56
  message.toolCalls = toolCalls;
57
+ const timeline = normalizeTimeline(raw.timeline);
58
+ if (timeline.length > 0)
59
+ message.timeline = timeline;
52
60
  return message;
53
61
  }
62
+ function normalizeTimeline(value) {
63
+ if (!Array.isArray(value))
64
+ return [];
65
+ const entries = [];
66
+ for (const item of value.slice(0, 1000)) {
67
+ if (!item || typeof item !== "object" || Array.isArray(item))
68
+ continue;
69
+ const raw = item;
70
+ if (raw.type === "text" && typeof raw.text === "string") {
71
+ entries.push({ type: "text", text: raw.text });
72
+ }
73
+ else if (raw.type === "tool_use" && typeof raw.id === "string" && typeof raw.name === "string") {
74
+ entries.push({
75
+ type: "tool_use",
76
+ id: raw.id,
77
+ name: raw.name,
78
+ ...(typeof raw.input === "string" ? { input: raw.input } : {}),
79
+ });
80
+ }
81
+ else if (raw.type === "tool_result" && typeof raw.tool_use_id === "string") {
82
+ entries.push({
83
+ type: "tool_result",
84
+ tool_use_id: raw.tool_use_id,
85
+ ...(typeof raw.name === "string" ? { name: raw.name } : {}),
86
+ ...(typeof raw.output === "string" ? { output: raw.output } : {}),
87
+ ...(typeof raw.is_error === "boolean" ? { is_error: raw.is_error } : {}),
88
+ });
89
+ }
90
+ }
91
+ return entries;
92
+ }
54
93
  function normalizeToolCalls(value) {
55
94
  if (!Array.isArray(value))
56
95
  return [];
@@ -62,6 +101,8 @@ function normalizeToolCalls(value) {
62
101
  if (typeof raw.name !== "string" || raw.name.length === 0)
63
102
  continue;
64
103
  const call = { name: raw.name };
104
+ if (typeof raw.id === "string" && raw.id.length > 0)
105
+ call.id = raw.id;
65
106
  if (typeof raw.input === "string")
66
107
  call.input = raw.input;
67
108
  if (typeof raw.output === "string")
@@ -135,6 +176,18 @@ function readSessionInfo(contextDir, sessionId) {
135
176
  export function getBuiltinContextSession(sessionId, contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
136
177
  return readSessionInfo(contextDir, sessionId);
137
178
  }
179
+ export function readBuiltinContextState(sessionId, contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
180
+ const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
181
+ const filePath = contextFilePath(contextDir, normalizedSessionId);
182
+ if (!existsSync(filePath))
183
+ return null;
184
+ try {
185
+ return normalizeState(JSON.parse(readFileSync(filePath, "utf8")), normalizedSessionId);
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
138
191
  export function listBuiltinContextSessions(contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
139
192
  if (!existsSync(contextDir))
140
193
  return [];
@@ -161,8 +214,11 @@ export function latestBuiltinSessionForCwd(cwd, contextDir = DEFAULT_BUILTIN_CON
161
214
  * 按字符类型加权,比旧版 chars/3 更接近真实:CJK 字符 ≈ 1 token/字,
162
215
  * 其他字符 ≈ 3.5 chars/token。避免中文长上下文被严重低估导致压缩过晚。
163
216
  */
164
- export function estimateBuiltinContextTokens(summary, messages) {
165
- 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) {
166
222
  let cjk = 0;
167
223
  for (const ch of text) {
168
224
  if (/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch))
@@ -171,6 +227,30 @@ export function estimateBuiltinContextTokens(summary, messages) {
171
227
  const other = text.length - cjk;
172
228
  return Math.ceil(cjk + other / 3.5);
173
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
+ }
174
254
  export function serializeMessagesForSummary(messages) {
175
255
  return messages
176
256
  .map((message, index) => `### ${index + 1}. ${message.role}\n${message.content}`)
@@ -188,23 +268,205 @@ export const DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
188
268
  export const DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
189
269
  const ASSISTANT_TRUNCATED_MARKER = "...[助手回复已在上下文中截断]...";
190
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
+ }
191
289
  /**
192
- * 构造持久化的 assistant 消息:content 保持既有"正文 + [Tool transcript]"文本格式
193
- * (模型上下文行为不变),同时附加结构化 toolCalls 存档(供 session-search 等检索)。
290
+ * 构造持久化 assistant 消息。content 仅保存回答正文,工具事件保存在
291
+ * toolCalls/timeline,避免内部 transcript 语法回流并诱导模型伪造工具执行。
194
292
  */
195
293
  export function buildPersistedAssistantMessage(params) {
196
294
  const maxAssistantChars = params.maxAssistantChars ?? DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS;
197
295
  const maxTranscriptChars = params.maxTranscriptChars ?? DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS;
198
296
  const persistedAssistantText = truncateMiddle(params.fullText, maxAssistantChars, ASSISTANT_TRUNCATED_MARKER);
199
- const content = params.transcriptLines.length > 0
200
- ? `${persistedAssistantText}\n\n[工具记录]\n${truncateMiddle(params.transcriptLines.join("\n"), maxTranscriptChars, TOOL_TRANSCRIPT_TRUNCATED_MARKER)}`
201
- : persistedAssistantText;
202
- const message = { role: "assistant", content };
203
- const toolCalls = normalizeToolCalls(params.toolCalls);
297
+ const message = { role: "assistant", content: persistedAssistantText };
298
+ const toolCalls = truncateStructuredToolCalls(normalizeToolCalls(params.toolCalls), maxTranscriptChars);
204
299
  if (toolCalls.length > 0)
205
300
  message.toolCalls = toolCalls;
301
+ const timeline = truncatePersistedTimeline(normalizeTimeline(params.timeline));
302
+ if (timeline.length > 0)
303
+ message.timeline = timeline;
206
304
  return message;
207
305
  }
306
+ function truncatePersistedTimeline(entries) {
307
+ const maxTextChars = DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS;
308
+ const maxToolChars = DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS;
309
+ const totalText = entries.reduce((sum, entry) => sum + (entry.type === "text" ? entry.text.length : 0), 0);
310
+ const textPayloads = entries.filter((entry) => entry.type === "text" && entry.text.length > 0).length;
311
+ const totalTool = entries.reduce((sum, entry) => {
312
+ if (entry.type === "tool_use")
313
+ return sum + (entry.input?.length ?? 0);
314
+ if (entry.type === "tool_result")
315
+ return sum + (entry.output?.length ?? 0);
316
+ return sum;
317
+ }, 0);
318
+ const toolPayloads = entries.filter((entry) => (entry.type === "tool_use" && (entry.input?.length ?? 0) > 0)
319
+ || (entry.type === "tool_result" && (entry.output?.length ?? 0) > 0)).length;
320
+ const payloadBudget = (length, total, count, maximum) => {
321
+ if (total <= maximum)
322
+ return length;
323
+ const sharedBudget = Math.max(0, maximum - count);
324
+ return length > 0 ? 1 + Math.floor(sharedBudget * length / Math.max(1, total)) : 0;
325
+ };
326
+ const truncatePayload = (value, budget, marker) => {
327
+ if (value.length <= budget)
328
+ return value;
329
+ if (budget <= marker.length + 2)
330
+ return value.slice(0, budget);
331
+ return truncateMiddle(value, budget, marker);
332
+ };
333
+ return entries.map((entry) => {
334
+ if (entry.type === "text") {
335
+ const budget = payloadBudget(entry.text.length, totalText, textPayloads, maxTextChars);
336
+ return { ...entry, text: truncatePayload(entry.text, budget, ASSISTANT_TRUNCATED_MARKER) };
337
+ }
338
+ if (entry.type === "tool_use" && entry.input !== undefined) {
339
+ const budget = payloadBudget(entry.input.length, totalTool, toolPayloads, maxToolChars);
340
+ return { ...entry, input: truncatePayload(entry.input, budget, TOOL_TRANSCRIPT_TRUNCATED_MARKER) };
341
+ }
342
+ if (entry.type === "tool_result" && entry.output !== undefined) {
343
+ const budget = payloadBudget(entry.output.length, totalTool, toolPayloads, maxToolChars);
344
+ return { ...entry, output: truncatePayload(entry.output, budget, TOOL_TRANSCRIPT_TRUNCATED_MARKER) };
345
+ }
346
+ return entry;
347
+ });
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 completeToolStep = () => {
406
+ // OpenAI/LiteLLM requires every tool message to immediately follow the
407
+ // assistant message that declared all matching tool_calls for that step.
408
+ flushAssistant();
409
+ for (const [toolCallId, toolName] of pendingCalls) {
410
+ toolParts.push({
411
+ type: "tool-result",
412
+ toolCallId,
413
+ toolName,
414
+ output: { type: "text", value: "[tool execution interrupted; result unavailable]" },
415
+ });
416
+ }
417
+ pendingCalls.clear();
418
+ if (!toolParts.length)
419
+ return;
420
+ messages.push({ role: "tool", content: toolParts });
421
+ toolParts = [];
422
+ };
423
+ for (const entry of timeline) {
424
+ if (entry.type === "text") {
425
+ if (pendingCalls.size > 0 || toolParts.length > 0)
426
+ completeToolStep();
427
+ const previous = assistantParts[assistantParts.length - 1];
428
+ if (previous?.type === "text")
429
+ previous.text += entry.text;
430
+ else if (entry.text)
431
+ assistantParts.push({ type: "text", text: entry.text });
432
+ }
433
+ else if (entry.type === "tool_use") {
434
+ // Consecutive tool calls belong to one assistant step. Only close the
435
+ // previous step after at least one result has arrived.
436
+ if (toolParts.length > 0)
437
+ completeToolStep();
438
+ knownNames.set(entry.id, entry.name);
439
+ pendingCalls.set(entry.id, entry.name);
440
+ emittedCallIds.add(entry.id);
441
+ assistantParts.push({
442
+ type: "tool-call",
443
+ toolCallId: entry.id,
444
+ toolName: entry.name,
445
+ input: parseToolInput(entry.input),
446
+ });
447
+ }
448
+ else {
449
+ if (!emittedCallIds.has(entry.tool_use_id))
450
+ continue;
451
+ const toolName = entry.name ?? knownNames.get(entry.tool_use_id);
452
+ if (!toolName)
453
+ continue;
454
+ flushAssistant();
455
+ pendingCalls.delete(entry.tool_use_id);
456
+ toolParts.push({
457
+ type: "tool-result",
458
+ toolCallId: entry.tool_use_id,
459
+ toolName,
460
+ output: parseToolOutput(entry.output),
461
+ });
462
+ }
463
+ }
464
+ if (pendingCalls.size > 0 || toolParts.length > 0)
465
+ completeToolStep();
466
+ else
467
+ flushAssistant();
468
+ return messages;
469
+ }
208
470
  function serializeMessagesForCompaction(messages) {
209
471
  const sections = [];
210
472
  let remaining = MAX_COMPACTION_SOURCE_CHARS;
@@ -245,6 +507,7 @@ export class BuiltinContextManager {
245
507
  contextDir;
246
508
  sessionId;
247
509
  compactAtTokens;
510
+ maxToolContextTokens;
248
511
  keepRecentMessages;
249
512
  cwd;
250
513
  state;
@@ -255,6 +518,8 @@ export class BuiltinContextManager {
255
518
  this.cwd = options.cwd;
256
519
  this.compactAtTokens = options.compactAtTokens
257
520
  ?? Math.floor((options.contextWindow ?? DEFAULT_CONTEXT_WINDOW_TOKENS) * COMPACTION_THRESHOLD_RATIO);
521
+ this.maxToolContextTokens = Math.max(1, options.maxToolContextTokens
522
+ ?? Math.min(DEFAULT_MAX_TOOL_CONTEXT_TOKENS, Math.floor(this.compactAtTokens * TOOL_CONTEXT_BUDGET_RATIO)));
258
523
  this.keepRecentMessages = Math.max(1, options.keepRecentMessages ?? DEFAULT_KEEP_RECENT_MESSAGES);
259
524
  this.state = this.load();
260
525
  }
@@ -294,7 +559,12 @@ export class BuiltinContextManager {
294
559
  // Keep malformed provider output on disk for diagnosis, but quarantine it
295
560
  // from future prompts so one protocol failure cannot teach the model to
296
561
  // repeat the same invalid tool syntax on every later turn.
297
- messages.push(...this.modelSafeMessages());
562
+ for (const [index, message] of this.modelSafeMessages().entries()) {
563
+ if (message.role === "user")
564
+ messages.push({ role: "user", content: message.content });
565
+ else
566
+ messages.push(...replayStructuredAssistantMessage(message, index));
567
+ }
298
568
  return messages;
299
569
  }
300
570
  planCompaction() {
@@ -302,16 +572,19 @@ export class BuiltinContextManager {
302
572
  // leaks here too, otherwise a later summary could reintroduce the bad syntax.
303
573
  const messages = this.modelSafeMessages();
304
574
  const estimated = estimateBuiltinContextTokens(this.state.summary, messages);
305
- if (estimated <= this.compactAtTokens)
575
+ const toolEstimated = estimateBuiltinToolContextTokens(messages);
576
+ if (estimated <= this.compactAtTokens && toolEstimated <= this.maxToolContextTokens)
306
577
  return null;
307
578
  if (messages.length <= 1)
308
579
  return null;
309
580
  const earliestAllowed = Math.max(0, messages.length - this.keepRecentMessages);
310
581
  const recentTokenBudget = Math.max(1, Math.floor(this.compactAtTokens * RECENT_CONTEXT_BUDGET_RATIO));
582
+ const recentToolBudget = Math.max(1, Math.floor(this.maxToolContextTokens * RECENT_TOOL_CONTEXT_BUDGET_RATIO));
311
583
  let splitAt = messages.length - 1;
312
584
  for (let index = messages.length - 2; index >= earliestAllowed; index -= 1) {
313
585
  const candidate = messages.slice(index);
314
- if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget)
586
+ if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget
587
+ || estimateBuiltinToolContextTokens(candidate) > recentToolBudget)
315
588
  break;
316
589
  splitAt = index;
317
590
  }
@@ -322,8 +595,8 @@ export class BuiltinContextManager {
322
595
  splitAt = 1;
323
596
  return {
324
597
  previousSummary: this.state.summary,
325
- oldMessages: this.state.messages.slice(0, splitAt),
326
- recentMessages: this.state.messages.slice(splitAt),
598
+ oldMessages: messages.slice(0, splitAt),
599
+ recentMessages: messages.slice(splitAt),
327
600
  };
328
601
  }
329
602
  applyCompaction(summary, plan) {
@@ -347,7 +620,17 @@ export class BuiltinContextManager {
347
620
  renameSync(tmp, this.contextFilePath);
348
621
  }
349
622
  modelSafeMessages() {
350
- return this.state.messages.filter((message) => message.role !== "assistant" || !hasMalformedToolProtocolText(message.content));
623
+ return this.state.messages.map((message) => {
624
+ if (message.role !== "assistant")
625
+ return message;
626
+ const hasStructuredTools = Boolean(message.toolCalls?.length
627
+ || message.timeline?.some((entry) => entry.type === "tool_use" || entry.type === "tool_result"));
628
+ const copiedStoredTranscript = hasImitatedToolTranscriptText(message.content) && hasStructuredTools;
629
+ if (hasMalformedToolProtocolText(message.content) && !copiedStoredTranscript) {
630
+ return { role: "assistant", content: QUARANTINED_PROTOCOL_REPLY };
631
+ }
632
+ return { ...message, content: stripStoredToolTranscript(message.content) };
633
+ });
351
634
  }
352
635
  load() {
353
636
  if (!this.persist || !existsSync(this.contextFilePath))
@@ -8,6 +8,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
8
8
  import { createInterface } from "node:readline";
9
9
  import { jsonSchema, tool } from "ai";
10
10
  import { isDangerousCommand } from "./permissions.js";
11
+ import { detectImageMime, MAX_ATTACHMENT_BYTES } from "./attachments.js";
11
12
  import { killProcessTree } from "./proc-tree-kill.js";
12
13
  import { searchBuiltinSessions, } from "./session-search.js";
13
14
  import { webFetchForTool, webSearchForTool, } from "./web-tools.js";
@@ -74,6 +75,25 @@ async function pathExists(path) {
74
75
  return false;
75
76
  }
76
77
  }
78
+ export async function presentFileForTool(cwd, input) {
79
+ const path = resolveToolPath(cwd, input.path);
80
+ const info = await stat(path).catch(() => null);
81
+ if (!info?.isFile())
82
+ throw new Error(`Image file not found: ${input.path}`);
83
+ if (info.size > MAX_ATTACHMENT_BYTES)
84
+ throw new Error(`Image file exceeds the 20 MB limit: ${input.path}`);
85
+ const mimeType = detectImageMime(await readFile(path));
86
+ if (!mimeType)
87
+ throw new Error(`present_file only supports PNG, JPEG, or WebP images: ${input.path}`);
88
+ const caption = input.caption?.trim();
89
+ return {
90
+ path,
91
+ name: basename(path),
92
+ mimeType,
93
+ size: info.size,
94
+ ...(caption ? { caption: caption.slice(0, 500) } : {}),
95
+ };
96
+ }
77
97
  function sha256(buffer) {
78
98
  return createHash("sha256").update(buffer).digest("hex");
79
99
  }
@@ -1011,6 +1031,19 @@ export function createBuiltinFileTools(cwd, options = {}) {
1011
1031
  }),
1012
1032
  execute: (input, options) => searchCodeForTool(cwd, input, options.abortSignal),
1013
1033
  }),
1034
+ present_file: tool({
1035
+ description: "把本地 PNG、JPEG 或 WebP 图片作为会话图片展示给用户。仅在图片文件已经生成并需要交付时调用。",
1036
+ inputSchema: jsonSchema({
1037
+ type: "object",
1038
+ additionalProperties: false,
1039
+ properties: {
1040
+ path: { type: "string", description: "图片绝对路径或相对于会话工作目录的路径。" },
1041
+ caption: { type: "string", description: "可选的图片说明。" },
1042
+ },
1043
+ required: ["path"],
1044
+ }),
1045
+ execute: (input) => presentFileForTool(cwd, input),
1046
+ }),
1014
1047
  run_command: tool({
1015
1048
  description: "在本地工作区运行非交互式 shell 命令。用于测试、git 和包脚本。返回 stdout/stderr 和 exitCode;非零退出码不是工具错误。",
1016
1049
  inputSchema: jsonSchema({