@downcity/agent 1.1.232 → 1.1.236

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 (165) hide show
  1. package/bin/agent/local/Agent.d.ts +1 -1
  2. package/bin/agent/local/Agent.js +2 -2
  3. package/bin/agent/local/Agent.js.map +1 -1
  4. package/bin/agent/local/services/AgentSessions.d.ts +4 -4
  5. package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
  6. package/bin/agent/local/services/AgentSessions.js +8 -8
  7. package/bin/agent/local/services/AgentSessions.js.map +1 -1
  8. package/bin/agent/remote/RemoteSession.d.ts +2 -0
  9. package/bin/agent/remote/RemoteSession.d.ts.map +1 -1
  10. package/bin/agent/remote/RemoteSession.js +4 -0
  11. package/bin/agent/remote/RemoteSession.js.map +1 -1
  12. package/bin/agent/remote/RemoteTransport.d.ts +2 -0
  13. package/bin/agent/remote/RemoteTransport.d.ts.map +1 -1
  14. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts +1 -0
  15. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
  16. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +11 -0
  17. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
  18. package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts +1 -0
  19. package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts.map +1 -1
  20. package/bin/agent/remote/transports/RpcRemoteAgentTransport.js +3 -0
  21. package/bin/agent/remote/transports/RpcRemoteAgentTransport.js.map +1 -1
  22. package/bin/executor/Executor.d.ts +20 -2
  23. package/bin/executor/Executor.d.ts.map +1 -1
  24. package/bin/executor/Executor.js +102 -18
  25. package/bin/executor/Executor.js.map +1 -1
  26. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts +6 -0
  27. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts.map +1 -1
  28. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts +0 -9
  29. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts.map +1 -1
  30. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js +4 -38
  31. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js.map +1 -1
  32. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.d.ts +1 -3
  33. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.d.ts.map +1 -1
  34. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.js +7 -59
  35. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.js.map +1 -1
  36. package/bin/executor/core-engine/CoreEngineContextCompaction.d.ts +38 -0
  37. package/bin/executor/core-engine/CoreEngineContextCompaction.d.ts.map +1 -0
  38. package/bin/executor/core-engine/CoreEngineContextCompaction.js +392 -0
  39. package/bin/executor/core-engine/CoreEngineContextCompaction.js.map +1 -0
  40. package/bin/executor/core-engine/CoreEngineMessageState.d.ts +15 -0
  41. package/bin/executor/core-engine/CoreEngineMessageState.d.ts.map +1 -1
  42. package/bin/executor/core-engine/CoreEngineMessageState.js +20 -0
  43. package/bin/executor/core-engine/CoreEngineMessageState.js.map +1 -1
  44. package/bin/executor/core-engine/CoreEngineRunner.d.ts +9 -5
  45. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  46. package/bin/executor/core-engine/CoreEngineRunner.js +146 -35
  47. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  48. package/bin/executor/store/history/SessionHistoryStore.d.ts +4 -10
  49. package/bin/executor/store/history/SessionHistoryStore.d.ts.map +1 -1
  50. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.d.ts.map +1 -1
  51. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +1 -3
  52. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
  53. package/bin/executor/types/SessionRun.d.ts +8 -0
  54. package/bin/executor/types/SessionRun.d.ts.map +1 -1
  55. package/bin/index.d.ts +1 -1
  56. package/bin/index.d.ts.map +1 -1
  57. package/bin/index.js +1 -1
  58. package/bin/index.js.map +1 -1
  59. package/bin/plugin/core/PluginLocalExecution.js +3 -3
  60. package/bin/plugin/core/PluginLocalExecution.js.map +1 -1
  61. package/bin/plugin/core/PluginRegistry.d.ts +20 -2
  62. package/bin/plugin/core/PluginRegistry.d.ts.map +1 -1
  63. package/bin/plugin/core/PluginRegistry.js +100 -3
  64. package/bin/plugin/core/PluginRegistry.js.map +1 -1
  65. package/bin/rpc/Client.d.ts +4 -0
  66. package/bin/rpc/Client.d.ts.map +1 -1
  67. package/bin/rpc/Client.js +11 -0
  68. package/bin/rpc/Client.js.map +1 -1
  69. package/bin/session/Session.d.ts +7 -3
  70. package/bin/session/Session.d.ts.map +1 -1
  71. package/bin/session/Session.js +26 -19
  72. package/bin/session/Session.js.map +1 -1
  73. package/bin/session/SessionSystemBuilder.d.ts.map +1 -1
  74. package/bin/session/SessionSystemBuilder.js +3 -1
  75. package/bin/session/SessionSystemBuilder.js.map +1 -1
  76. package/bin/session/recorder/SessionRecorderCompaction.d.ts.map +1 -1
  77. package/bin/session/recorder/SessionRecorderCompaction.js +102 -60
  78. package/bin/session/recorder/SessionRecorderCompaction.js.map +1 -1
  79. package/bin/session/runtime/SessionPromptRuntime.d.ts +11 -7
  80. package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
  81. package/bin/session/runtime/SessionPromptRuntime.js +22 -24
  82. package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
  83. package/bin/session/services/SessionStateService.d.ts +10 -6
  84. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  85. package/bin/session/services/SessionStateService.js +29 -20
  86. package/bin/session/services/SessionStateService.js.map +1 -1
  87. package/bin/session/services/SessionTurnService.d.ts +13 -3
  88. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  89. package/bin/session/services/SessionTurnService.js +90 -7
  90. package/bin/session/services/SessionTurnService.js.map +1 -1
  91. package/bin/types/agent/SessionActor.d.ts +8 -0
  92. package/bin/types/agent/SessionActor.d.ts.map +1 -1
  93. package/bin/types/executor/SessionRunContext.d.ts +16 -8
  94. package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
  95. package/bin/types/plugin/PluginRuntime.d.ts +36 -4
  96. package/bin/types/plugin/PluginRuntime.d.ts.map +1 -1
  97. package/bin/types/plugin/PluginState.d.ts +10 -0
  98. package/bin/types/plugin/PluginState.d.ts.map +1 -1
  99. package/bin/types/rpc/RpcProtocol.d.ts +10 -0
  100. package/bin/types/rpc/RpcProtocol.d.ts.map +1 -1
  101. package/bin/types/runtime/agent/AgentContext.d.ts +2 -2
  102. package/bin/types/runtime/agent/AgentContext.js +1 -1
  103. package/bin/types/session/SessionLocalState.d.ts +2 -2
  104. package/bin/types/session/SessionOptions.d.ts +5 -5
  105. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  106. package/bin/types/session/SessionQueueCommand.d.ts +68 -0
  107. package/bin/types/session/SessionQueueCommand.d.ts.map +1 -0
  108. package/bin/types/session/SessionQueueCommand.js +10 -0
  109. package/bin/types/session/SessionQueueCommand.js.map +1 -0
  110. package/bin/utils/logger/Logger.d.ts +7 -9
  111. package/bin/utils/logger/Logger.d.ts.map +1 -1
  112. package/bin/utils/logger/Logger.js +11 -9
  113. package/bin/utils/logger/Logger.js.map +1 -1
  114. package/package.json +3 -3
  115. package/scripts/core-engine-context-compaction.test.mjs +411 -0
  116. package/scripts/logger-isolation.test.mjs +55 -0
  117. package/scripts/multi-agent-plugin-isolation.test.mjs +289 -0
  118. package/scripts/plugin-tool-bridge.test.mjs +52 -0
  119. package/scripts/remote-session-reconnect.test.mjs +18 -0
  120. package/scripts/session-compaction-context-window.test.mjs +27 -26
  121. package/scripts/session-config-turn-boundary.test.mjs +80 -26
  122. package/scripts/session-prompt-runtime.test.mjs +23 -15
  123. package/scripts/session-recorder.test.mjs +15 -24
  124. package/src/agent/local/Agent.ts +2 -2
  125. package/src/agent/local/services/AgentSessions.ts +10 -10
  126. package/src/agent/remote/RemoteSession.ts +5 -0
  127. package/src/agent/remote/RemoteTransport.ts +2 -0
  128. package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +15 -0
  129. package/src/agent/remote/transports/RpcRemoteAgentTransport.ts +4 -0
  130. package/src/executor/Executor.ts +111 -19
  131. package/src/executor/composer/compaction/SessionCompactionComposer.ts +7 -0
  132. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.ts +4 -57
  133. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.ts +8 -66
  134. package/src/executor/core-engine/CoreEngineContextCompaction.ts +477 -0
  135. package/src/executor/core-engine/CoreEngineMessageState.ts +29 -0
  136. package/src/executor/core-engine/CoreEngineRunner.ts +200 -48
  137. package/src/executor/store/history/SessionHistoryStore.ts +4 -12
  138. package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +1 -3
  139. package/src/executor/types/SessionRun.ts +9 -0
  140. package/src/index.ts +1 -1
  141. package/src/plugin/core/PluginLocalExecution.ts +3 -3
  142. package/src/plugin/core/PluginRegistry.ts +116 -3
  143. package/src/rpc/Client.ts +12 -0
  144. package/src/session/Session.ts +28 -20
  145. package/src/session/SessionSystemBuilder.ts +4 -1
  146. package/src/session/recorder/SessionRecorderCompaction.ts +113 -65
  147. package/src/session/runtime/SessionPromptRuntime.ts +31 -46
  148. package/src/session/services/SessionStateService.ts +34 -25
  149. package/src/session/services/SessionTurnService.ts +97 -8
  150. package/src/types/agent/SessionActor.ts +9 -0
  151. package/src/types/executor/SessionRunContext.ts +17 -8
  152. package/src/types/plugin/PluginRuntime.ts +38 -4
  153. package/src/types/plugin/PluginState.ts +10 -0
  154. package/src/types/rpc/RpcProtocol.ts +11 -0
  155. package/src/types/runtime/agent/AgentContext.ts +2 -2
  156. package/src/types/session/SessionLocalState.ts +2 -2
  157. package/src/types/session/SessionOptions.ts +6 -6
  158. package/src/types/session/SessionQueueCommand.ts +78 -0
  159. package/src/utils/logger/Logger.ts +10 -10
  160. package/tsconfig.tsbuildinfo +1 -1
  161. package/bin/types/session/SessionConfigMutation.d.ts +0 -68
  162. package/bin/types/session/SessionConfigMutation.d.ts.map +0 -1
  163. package/bin/types/session/SessionConfigMutation.js +0 -10
  164. package/bin/types/session/SessionConfigMutation.js.map +0 -1
  165. package/src/types/session/SessionConfigMutation.ts +0 -78
@@ -37,10 +37,8 @@ import { format_session_compaction_file_operations } from "@executor/composer/co
37
37
  export type SessionCompactParams = {
38
38
  model: LanguageModel;
39
39
  system: Array<SystemModelMessage>;
40
- keepLastMessages: number;
41
- maxInputTokensApprox: number;
42
40
  archiveOnCompact: boolean;
43
- compactRatio: number;
41
+ force?: boolean;
44
42
  onAction?: (action: SessionActionRecordV1) => Promise<void>;
45
43
  };
46
44
 
@@ -104,29 +102,15 @@ export async function compactSessionMessagesIfNeeded(
104
102
  : "";
105
103
  });
106
104
 
107
- if (snapshot.length < 4) {
108
- return { compacted: false, reason: "small_messages" };
105
+ if (snapshot.length === 0) {
106
+ return { compacted: false, reason: "nothing_to_compact" };
109
107
  }
110
-
111
- const systemText = (params.system || [])
112
- .map((m) => String(m.content ?? ""))
113
- .join("\n\n");
114
- // 关键点(中文):session messages 现在可能包含 tool parts / output,必须把它们计入预算估算,否则会低估 token。
115
- let messagesJson = "";
116
- try {
117
- messagesJson = JSON.stringify(snapshot);
118
- } catch {
119
- messagesJson = "";
120
- }
121
- const est = estimateTokensApproxFromText(systemText + "\n\n" + messagesJson);
122
- if (est <= params.maxInputTokensApprox) {
123
- return { compacted: false, reason: "under_budget" };
108
+ if (!params.force) {
109
+ return { compacted: false, reason: "not_requested" };
124
110
  }
125
111
 
126
- // 关键点(中文):触发 compact 后,优先压缩“最早一段消息”,默认比例 50%。
127
- const compactRatio = normalizeCompactRatio(params.compactRatio);
128
- const compactCount = resolveCompactCount(snapshot.length, compactRatio);
129
- const older = snapshot.slice(0, compactCount);
112
+ // 关键点(中文):明确 compact 时归档全部当前模型历史,避免单条巨大 assistant 永远留在 Active。
113
+ const older = snapshot;
130
114
  if (older.length === 0) return { compacted: false, reason: "nothing_to_compact" };
131
115
 
132
116
  action_started = true;
@@ -215,11 +199,7 @@ export async function compactSessionMessagesIfNeeded(
215
199
  const current_model_messages = current.filter(
216
200
  is_session_message_record,
217
201
  );
218
- const currentCompactCount = resolveCompactCount(
219
- current_model_messages.length,
220
- compactRatio,
221
- );
222
- const currentOlder = current_model_messages.slice(0, currentCompactCount);
202
+ const currentOlder = current_model_messages;
223
203
  if (currentOlder.length === 0) return;
224
204
  const compacted_ids = new Set(
225
205
  currentOlder
@@ -271,44 +251,6 @@ export async function compactSessionMessagesIfNeeded(
271
251
  return { compacted: true };
272
252
  }
273
253
 
274
- /**
275
- * 归一化压缩比例。
276
- *
277
- * 关键点(中文)
278
- * - 仅允许 0.1~0.9,避免“几乎不压缩”或“几乎全压缩”。
279
- */
280
- function normalizeCompactRatio(value: number): number {
281
- if (!Number.isFinite(value)) return 0.5;
282
- return Math.max(0.1, Math.min(0.9, value));
283
- }
284
-
285
- /**
286
- * 按比例计算前段压缩条数。
287
- *
288
- * 关键点(中文)
289
- * - 至少压缩 1 条;
290
- * - 至少保留 1 条未压缩消息。
291
- */
292
- function resolveCompactCount(total: number, ratio: number): number {
293
- const n = Math.max(0, Math.floor(total));
294
- if (n <= 1) return 0;
295
- const raw = Math.floor(n * ratio);
296
- return Math.max(1, Math.min(n - 1, raw));
297
- }
298
-
299
- /**
300
- * 近似 token 估算。
301
- *
302
- * 算法说明(中文)
303
- * - 这里使用经验近似,不追求精确 tokenizer 一致性。
304
- * - 目标是为 compact 提供保守预算,宁可略高估也不要低估。
305
- */
306
- function estimateTokensApproxFromText(text: string): number {
307
- const t = String(text || "");
308
- // 经验值:英文 ~4 chars/token;中文更接近 1-2 chars/token。这里用保守的 3 chars/token。
309
- return Math.ceil(t.length / 3);
310
- }
311
-
312
254
  /**
313
255
  * 从 UIMessage 提取可摘要的纯文本。
314
256
  *
@@ -0,0 +1,477 @@
1
+ /**
2
+ * CoreEngine 模型上下文压缩。
3
+ *
4
+ * 关键点(中文)
5
+ * - 触发条件只读取 Provider 返回的真实 usage,不做调用前 token 预估。
6
+ * - 压缩只作用于本次运行的 ModelMessage,不修改 Session canonical Message。
7
+ * - reasoning、tool call、tool result 与 approval 按 AI SDK 关联关系清理。
8
+ * - 首级保留文本历史,后续深度把历史前缀折叠为 checkpoint;最后 user 与最新工具事务始终原子保留。
9
+ * - 字符上限只用于约束已经决定压缩后的单个 part,不参与是否触发压缩的判断。
10
+ */
11
+
12
+ import { pruneMessages, type ModelMessage } from "ai";
13
+
14
+ /** usage 达到模型上下文窗口 95% 时安排下一 step 压缩。 */
15
+ export const MODEL_CONTEXT_COMPACTION_TRIGGER_RATIO = 0.95;
16
+
17
+ /** 压缩后的真实 usage 需要回落到模型上下文窗口 50% 以内。 */
18
+ export const MODEL_CONTEXT_COMPACTION_TARGET_RATIO = 0.5;
19
+
20
+ /** 历史 checkpoint 的基础最大字符数,后续深度逐级减半。 */
21
+ const INITIAL_HISTORY_CHECKPOINT_CHARS = 24_000;
22
+
23
+ /** 单条保留消息的基础字符预算,后续深度逐级减半。 */
24
+ const INITIAL_RETAINED_MESSAGE_CHARS = 16_000;
25
+
26
+ /** 单项折叠后至少保留的字符数,避免深度压缩后完全丢失语义。 */
27
+ const MIN_FOLDED_PART_CHARS = 64;
28
+
29
+ /**
30
+ * 读取 Provider usage 中的实际总 token。
31
+ *
32
+ * 优先使用 `totalTokens`;Provider 未返回时才回退为 input 与 output 之和。
33
+ */
34
+ export function resolve_model_usage_tokens(usage: unknown): number | null {
35
+ if (!usage || typeof usage !== "object") return null;
36
+ const record = usage as Record<string, unknown>;
37
+ const total_tokens = read_non_negative_number(record.totalTokens);
38
+ if (total_tokens !== null) return total_tokens;
39
+
40
+ const input_tokens = read_non_negative_number(record.inputTokens);
41
+ const output_tokens = read_non_negative_number(record.outputTokens);
42
+ if (input_tokens === null && output_tokens === null) return null;
43
+ return (input_tokens || 0) + (output_tokens || 0);
44
+ }
45
+
46
+ /** 计算真实 usage 占当前模型上下文窗口的比例。 */
47
+ export function resolve_model_usage_ratio(
48
+ usage: unknown,
49
+ context_window: number | undefined,
50
+ ): number | null {
51
+ if (!Number.isSafeInteger(context_window) || Number(context_window) <= 0) {
52
+ return null;
53
+ }
54
+ const used_tokens = resolve_model_usage_tokens(usage);
55
+ if (used_tokens === null) return null;
56
+ return used_tokens / Number(context_window);
57
+ }
58
+
59
+ /**
60
+ * 判断一次真实 usage 是否要求下一 step 继续压缩。
61
+ *
62
+ * 普通调用使用 95% 触发水位;刚执行过压缩的调用使用 50% 目标水位验收。
63
+ */
64
+ export function should_compact_after_usage(
65
+ usage_ratio: number | null,
66
+ validating_compaction: boolean,
67
+ ): boolean {
68
+ if (usage_ratio === null || !Number.isFinite(usage_ratio)) return false;
69
+ return validating_compaction
70
+ ? usage_ratio > MODEL_CONTEXT_COMPACTION_TARGET_RATIO
71
+ : usage_ratio >= MODEL_CONTEXT_COMPACTION_TRIGGER_RATIO;
72
+ }
73
+
74
+ /**
75
+ * 对当前 ModelMessage 做一次 part 级深度压缩。
76
+ *
77
+ * `compact_depth` 每增加一级都会把保留预算减半,用于真实 usage 未达到目标水位时继续收紧。
78
+ */
79
+ export function deep_compact_model_messages(
80
+ messages: ModelMessage[],
81
+ compact_depth = 0,
82
+ ): ModelMessage[] {
83
+ if (!Array.isArray(messages) || messages.length === 0) return [];
84
+ const depth = Math.max(0, Math.min(8, Math.floor(compact_depth)));
85
+ const pruned_messages = pruneMessages({
86
+ messages,
87
+ reasoning: "all",
88
+ // 工具事务由本模块按 toolCallId/approvalId 选择;先让 SDK 保留完整关联,
89
+ // 避免 approval response 作为最后一条消息时 SDK 丢掉更早的 tool-call。
90
+ toolCalls: "none",
91
+ emptyMessages: "remove",
92
+ });
93
+ if (pruned_messages.length === 0) return [];
94
+
95
+ const approval_to_tool_call = collect_approval_tool_call_map(pruned_messages);
96
+ const last_user_index = find_last_message_index(
97
+ pruned_messages,
98
+ (message) => message.role === "user",
99
+ );
100
+ const selected_tool_call_ids = collect_latest_tool_transaction_ids(
101
+ pruned_messages,
102
+ last_user_index,
103
+ approval_to_tool_call,
104
+ );
105
+ // 第一级只清理高噪声 part,保留全部文本历史;真实 usage 仍高于 50% 时,
106
+ // 后续深度才把历史前缀折叠为 checkpoint。
107
+ const retained_indices = depth === 0
108
+ ? new Set(pruned_messages.map((_message, index) => index))
109
+ : collect_retained_message_indices({
110
+ messages: pruned_messages,
111
+ last_user_index,
112
+ selected_tool_call_ids,
113
+ approval_to_tool_call,
114
+ });
115
+ const excluded_messages = pruned_messages.filter(
116
+ (_message, index) => !retained_indices.has(index),
117
+ );
118
+ const checkpoint_limit = resolve_depth_budget(
119
+ INITIAL_HISTORY_CHECKPOINT_CHARS,
120
+ depth,
121
+ );
122
+ const checkpoint_text = build_history_checkpoint(
123
+ excluded_messages,
124
+ checkpoint_limit,
125
+ );
126
+ const message_limit = resolve_depth_budget(
127
+ INITIAL_RETAINED_MESSAGE_CHARS,
128
+ depth,
129
+ );
130
+ const retained_messages = pruned_messages
131
+ .map((message, index) => ({ message, index }))
132
+ .filter(({ index }) => retained_indices.has(index))
133
+ .map(({ message }) =>
134
+ compact_retained_message(
135
+ message,
136
+ message_limit,
137
+ selected_tool_call_ids,
138
+ approval_to_tool_call,
139
+ ),
140
+ )
141
+ .filter((message): message is ModelMessage => message !== null);
142
+
143
+ const checkpoint_message: ModelMessage[] = checkpoint_text
144
+ ? [{ role: "assistant", content: checkpoint_text }]
145
+ : [];
146
+ const system_messages = retained_messages.filter(
147
+ (message) => message.role === "system",
148
+ );
149
+ const non_system_messages = retained_messages.filter(
150
+ (message) => message.role !== "system",
151
+ );
152
+ return remove_orphaned_tool_parts([
153
+ ...system_messages,
154
+ ...checkpoint_message,
155
+ ...non_system_messages,
156
+ ]);
157
+ }
158
+
159
+ /** 对文本做确定性的 head/tail 折叠。 */
160
+ export function fold_compacted_text(text: string, max_chars: number): string {
161
+ const value = String(text || "");
162
+ const limit = Math.max(MIN_FOLDED_PART_CHARS, Math.floor(max_chars));
163
+ if (value.length <= limit) return value;
164
+ const marker = `\n...[compacted ${String(value.length - limit)} chars]...\n`;
165
+ const content_limit = Math.max(0, limit - marker.length);
166
+ const head_chars = Math.ceil(content_limit / 2);
167
+ const tail_chars = Math.floor(content_limit / 2);
168
+ return `${value.slice(0, head_chars)}${marker}${value.slice(-tail_chars)}`;
169
+ }
170
+
171
+ function read_non_negative_number(value: unknown): number | null {
172
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
173
+ ? value
174
+ : null;
175
+ }
176
+
177
+ function resolve_depth_budget(initial_budget: number, depth: number): number {
178
+ return Math.max(
179
+ MIN_FOLDED_PART_CHARS,
180
+ Math.floor(initial_budget / Math.pow(2, depth)),
181
+ );
182
+ }
183
+
184
+ function find_last_message_index(
185
+ messages: ModelMessage[],
186
+ predicate: (message: ModelMessage) => boolean,
187
+ ): number {
188
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
189
+ if (predicate(messages[index])) return index;
190
+ }
191
+ return -1;
192
+ }
193
+
194
+ function collect_approval_tool_call_map(
195
+ messages: ModelMessage[],
196
+ ): Map<string, string> {
197
+ const output = new Map<string, string>();
198
+ for (const message of messages) {
199
+ if (!Array.isArray(message.content)) continue;
200
+ for (const part of message.content) {
201
+ if (part.type !== "tool-approval-request") continue;
202
+ output.set(part.approvalId, part.toolCallId);
203
+ }
204
+ }
205
+ return output;
206
+ }
207
+
208
+ function collect_latest_tool_transaction_ids(
209
+ messages: ModelMessage[],
210
+ last_user_index: number,
211
+ approval_to_tool_call: Map<string, string>,
212
+ ): Set<string> {
213
+ for (let index = messages.length - 1; index > last_user_index; index -= 1) {
214
+ const message = messages[index];
215
+ if (!Array.isArray(message.content)) continue;
216
+ const ids = new Set<string>();
217
+ for (const part of message.content) {
218
+ if (part.type === "tool-call" || part.type === "tool-result") {
219
+ ids.add(part.toolCallId);
220
+ } else if (part.type === "tool-approval-request") {
221
+ ids.add(part.toolCallId);
222
+ } else if (part.type === "tool-approval-response") {
223
+ const tool_call_id = approval_to_tool_call.get(part.approvalId);
224
+ if (tool_call_id) ids.add(tool_call_id);
225
+ }
226
+ }
227
+ if (ids.size > 0) return ids;
228
+ }
229
+ return new Set<string>();
230
+ }
231
+
232
+ function collect_retained_message_indices(input: {
233
+ messages: ModelMessage[];
234
+ last_user_index: number;
235
+ selected_tool_call_ids: Set<string>;
236
+ approval_to_tool_call: Map<string, string>;
237
+ }): Set<number> {
238
+ const retained = new Set<number>();
239
+ input.messages.forEach((message, index) => {
240
+ if (message.role === "system") retained.add(index);
241
+ if (index === input.last_user_index) retained.add(index);
242
+ if (
243
+ message_contains_selected_tool_transaction(
244
+ message,
245
+ input.selected_tool_call_ids,
246
+ input.approval_to_tool_call,
247
+ )
248
+ ) {
249
+ retained.add(index);
250
+ }
251
+ });
252
+
253
+ if (input.selected_tool_call_ids.size === 0) {
254
+ const last_assistant_index = find_last_message_index(
255
+ input.messages,
256
+ (message) => message.role === "assistant",
257
+ );
258
+ if (last_assistant_index > input.last_user_index) {
259
+ retained.add(last_assistant_index);
260
+ }
261
+ }
262
+ if (retained.size === 0) retained.add(input.messages.length - 1);
263
+ return retained;
264
+ }
265
+
266
+ function message_contains_selected_tool_transaction(
267
+ message: ModelMessage,
268
+ selected_tool_call_ids: Set<string>,
269
+ approval_to_tool_call: Map<string, string>,
270
+ ): boolean {
271
+ if (!Array.isArray(message.content) || selected_tool_call_ids.size === 0) {
272
+ return false;
273
+ }
274
+ return message.content.some((part) => {
275
+ if (part.type === "tool-call" || part.type === "tool-result") {
276
+ return selected_tool_call_ids.has(part.toolCallId);
277
+ }
278
+ if (part.type === "tool-approval-request") {
279
+ return selected_tool_call_ids.has(part.toolCallId);
280
+ }
281
+ if (part.type === "tool-approval-response") {
282
+ const tool_call_id = approval_to_tool_call.get(part.approvalId);
283
+ return Boolean(tool_call_id && selected_tool_call_ids.has(tool_call_id));
284
+ }
285
+ return false;
286
+ });
287
+ }
288
+
289
+ function build_history_checkpoint(
290
+ messages: ModelMessage[],
291
+ max_chars: number,
292
+ ): string {
293
+ if (messages.length === 0) return "";
294
+ const body = messages
295
+ .map((message) => serialize_model_message_for_checkpoint(message))
296
+ .filter(Boolean)
297
+ .join("\n\n");
298
+ if (!body) return "";
299
+ return [
300
+ "[Compacted conversation checkpoint]",
301
+ fold_compacted_text(body, max_chars),
302
+ ].join("\n");
303
+ }
304
+
305
+ function serialize_model_message_for_checkpoint(message: ModelMessage): string {
306
+ const role = message.role;
307
+ if (typeof message.content === "string") {
308
+ return `${role}: ${message.content}`;
309
+ }
310
+ const parts = message.content.map((part) => {
311
+ if (part.type === "text") return part.text;
312
+ if (part.type === "reasoning") return "";
313
+ if (part.type === "tool-call") {
314
+ return `[tool-call ${part.toolName} ${part.toolCallId}] ${safe_stringify(part.input)}`;
315
+ }
316
+ if (part.type === "tool-result") {
317
+ return `[tool-result ${part.toolName} ${part.toolCallId}] ${safe_stringify(part.output)}`;
318
+ }
319
+ if (part.type === "tool-approval-request") {
320
+ return `[tool-approval-request ${part.toolCallId} ${part.approvalId}]`;
321
+ }
322
+ if (part.type === "tool-approval-response") {
323
+ return `[tool-approval-response ${part.approvalId} approved=${String(part.approved)}]`;
324
+ }
325
+ if (part.type === "file") {
326
+ return `[file ${part.filename || "unnamed"} ${part.mediaType}]`;
327
+ }
328
+ if (part.type === "image") return `[image ${part.mediaType || "unknown"}]`;
329
+ return `[${String((part as { type?: unknown }).type || "part")}]`;
330
+ });
331
+ return `${role}: ${parts.filter(Boolean).join("\n")}`;
332
+ }
333
+
334
+ function compact_retained_message(
335
+ message: ModelMessage,
336
+ message_limit: number,
337
+ selected_tool_call_ids: Set<string>,
338
+ approval_to_tool_call: Map<string, string>,
339
+ ): ModelMessage | null {
340
+ if (typeof message.content === "string") {
341
+ return {
342
+ ...message,
343
+ content: fold_compacted_text(message.content, message_limit),
344
+ } as ModelMessage;
345
+ }
346
+ const relevant_parts = message.content.filter((part) => part.type !== "reasoning");
347
+ const part_limit = Math.max(
348
+ MIN_FOLDED_PART_CHARS,
349
+ Math.floor(message_limit / Math.max(1, relevant_parts.length)),
350
+ );
351
+ const content = relevant_parts
352
+ .filter((part) => {
353
+ if (part.type === "tool-call" || part.type === "tool-result") {
354
+ return selected_tool_call_ids.has(part.toolCallId);
355
+ }
356
+ if (part.type === "tool-approval-request") {
357
+ return selected_tool_call_ids.has(part.toolCallId);
358
+ }
359
+ if (part.type === "tool-approval-response") {
360
+ const tool_call_id = approval_to_tool_call.get(part.approvalId);
361
+ return Boolean(tool_call_id && selected_tool_call_ids.has(tool_call_id));
362
+ }
363
+ return true;
364
+ })
365
+ .map((part) =>
366
+ compact_model_part(
367
+ part as unknown as Record<string, unknown>,
368
+ part_limit,
369
+ ),
370
+ );
371
+ if (content.length === 0) return null;
372
+ return { ...message, content } as ModelMessage;
373
+ }
374
+
375
+ function compact_model_part(
376
+ part: Record<string, unknown>,
377
+ part_limit: number,
378
+ ): Record<string, unknown> {
379
+ if (part.type === "text") {
380
+ return {
381
+ ...part,
382
+ text: fold_compacted_text(String(part.text || ""), part_limit),
383
+ };
384
+ }
385
+ if (part.type === "tool-call") {
386
+ return {
387
+ ...part,
388
+ input: fold_compacted_value(part.input, part_limit),
389
+ };
390
+ }
391
+ if (part.type === "tool-result") {
392
+ const serialized_output = safe_stringify(part.output);
393
+ if (serialized_output.length <= part_limit) return part;
394
+ return {
395
+ ...part,
396
+ output: {
397
+ type: "text",
398
+ value: fold_compacted_text(serialized_output, part_limit),
399
+ },
400
+ };
401
+ }
402
+ if (part.type === "file") {
403
+ const data = part.data;
404
+ if (typeof data === "string" && data.length > part_limit) {
405
+ return {
406
+ type: "text",
407
+ text: `[compacted file ${String(part.filename || "unnamed")} ${String(part.mediaType || "unknown")}, ${String(data.length)} chars]`,
408
+ };
409
+ }
410
+ }
411
+ if (part.type === "image") {
412
+ const image = part.image;
413
+ if (typeof image === "string" && image.length > part_limit) {
414
+ return {
415
+ type: "text",
416
+ text: `[compacted image ${String(part.mediaType || "unknown")}, ${String(image.length)} chars]`,
417
+ };
418
+ }
419
+ }
420
+ return part;
421
+ }
422
+
423
+ function fold_compacted_value(value: unknown, max_chars: number): unknown {
424
+ const serialized = safe_stringify(value);
425
+ if (serialized.length <= max_chars) return value;
426
+ return {
427
+ compacted: true,
428
+ preview: fold_compacted_text(serialized, max_chars),
429
+ };
430
+ }
431
+
432
+ function remove_orphaned_tool_parts(messages: ModelMessage[]): ModelMessage[] {
433
+ const tool_call_ids = new Set<string>();
434
+ const approval_ids = new Set<string>();
435
+ for (const message of messages) {
436
+ if (!Array.isArray(message.content)) continue;
437
+ for (const part of message.content) {
438
+ if (part.type === "tool-call") tool_call_ids.add(part.toolCallId);
439
+ }
440
+ }
441
+ for (const message of messages) {
442
+ if (!Array.isArray(message.content)) continue;
443
+ for (const part of message.content) {
444
+ if (
445
+ part.type === "tool-approval-request" &&
446
+ tool_call_ids.has(part.toolCallId)
447
+ ) approval_ids.add(part.approvalId);
448
+ }
449
+ }
450
+
451
+ return messages
452
+ .map((message) => {
453
+ if (!Array.isArray(message.content)) return message;
454
+ const content = message.content.filter((part) => {
455
+ if (part.type === "tool-result") {
456
+ return tool_call_ids.has(part.toolCallId);
457
+ }
458
+ if (part.type === "tool-approval-request") {
459
+ return tool_call_ids.has(part.toolCallId);
460
+ }
461
+ if (part.type === "tool-approval-response") {
462
+ return approval_ids.has(part.approvalId);
463
+ }
464
+ return true;
465
+ });
466
+ return { ...message, content } as ModelMessage;
467
+ })
468
+ .filter((message) => message.content.length > 0);
469
+ }
470
+
471
+ function safe_stringify(value: unknown): string {
472
+ try {
473
+ return JSON.stringify(value) || String(value || "");
474
+ } catch {
475
+ return String(value || "");
476
+ }
477
+ }
@@ -134,6 +134,35 @@ export class CoreEngineMessageState {
134
134
  this.currentModelMessages = [...this.currentModelMessages, ...messages];
135
135
  }
136
136
 
137
+ /**
138
+ * 原子替换当前模型侧消息基线。
139
+ *
140
+ * 关键点(中文):compact 只替换 Provider 后续可见的 ModelMessage,
141
+ * Session 语义消息与持久化历史仍保持完整,等待 turn 收口后再单独归档。
142
+ */
143
+ replace_model_messages(messages: ModelMessage[]): void {
144
+ this.currentModelMessages = Array.isArray(messages) ? [...messages] : [];
145
+ }
146
+
147
+ /**
148
+ * 使用 canonical records 原子替换两份消息基线。
149
+ *
150
+ * 关键点(中文)
151
+ * - 显式 compact 会重写持久化历史,旧的 SessionRecord 与 ModelMessage 必须同时失效。
152
+ * - tools 使用当前 step 的执行视图,避免配置 command 生效后仍按旧工具解码历史。
153
+ */
154
+ async replace_session_messages(
155
+ messages: SessionRecordV1[],
156
+ tools: Record<string, Tool>,
157
+ ): Promise<void> {
158
+ this.sessionMessages = Array.isArray(messages) ? [...messages] : [];
159
+ this.currentModelMessages = await toModelMessages(
160
+ this.sessionMessages,
161
+ tools,
162
+ this.projectRoot,
163
+ );
164
+ }
165
+
137
166
  private async appendSessionMessagesAsModelMessages(
138
167
  messages: SessionRecordV1[],
139
168
  ): Promise<ModelMessage[]> {