minimal-agent 0.18.5 → 0.18.7

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 (74) hide show
  1. package/package.json +1 -1
  2. package/plugins/workflow-runner/src/runtime/context.js +5 -17
  3. package/plugins/workflow-runner/src/runtime/context.ts +4 -15
  4. package/plugins/workflow-runner/src/runtime/model.js +4 -2
  5. package/plugins/workflow-runner/src/runtime/model.ts +3 -2
  6. package/plugins/workflow-runner/test/api.test.ts +4 -2
  7. package/plugins/workflow-runner/test/embeddedExecution.test.ts +5 -6
  8. package/plugins/workflow-runner/test/modelOutcome.test.ts +36 -0
  9. package/skills/dynamic-ui/SKILL.md +38 -278
  10. package/skills/dynamic-ui/mermaid.md +32 -0
  11. package/skills/dynamic-ui/scenes/architecture-and-flow.md +4 -1
  12. package/skills/dynamic-ui/scenes/data-visualization.md +5 -2
  13. package/skills/dynamic-ui/svg-design.md +251 -0
  14. package/skills/dynamic-ui/templates/README.md +2 -1
  15. package/skills/dynamic-ui/tokens/visual-tokens.md +4 -5
  16. package/src/advisor/README.md +24 -0
  17. package/src/advisor/advisorCoordinator.js +101 -45
  18. package/src/advisor/advisorCoordinator.ts +90 -51
  19. package/src/advisor/deliveryReview.js +35 -0
  20. package/src/advisor/deliveryReview.ts +30 -0
  21. package/src/advisor/presentation.js +10 -0
  22. package/src/advisor/presentation.ts +12 -0
  23. package/src/advisor/reviewContext.js +60 -0
  24. package/src/advisor/reviewContext.ts +72 -0
  25. package/src/context/contextBundle.js +23 -3
  26. package/src/context/contextBundle.ts +28 -7
  27. package/src/context/tokenCounter.js +3 -0
  28. package/src/context/tokenCounter.ts +2 -0
  29. package/src/llm/messageProvenance.js +1 -1
  30. package/src/llm/messageProvenance.ts +1 -1
  31. package/src/loop.js +203 -30
  32. package/src/loop.ts +183 -30
  33. package/src/memory/experienceStore.js +1 -0
  34. package/src/memory/experienceStore.ts +1 -0
  35. package/src/memory/memoryService.js +3 -2
  36. package/src/memory/memoryService.ts +3 -0
  37. package/src/memory/projectKnowledge.js +1 -0
  38. package/src/memory/projectKnowledge.ts +1 -0
  39. package/src/memory/retrieval.js +4 -4
  40. package/src/memory/retrieval.ts +4 -4
  41. package/src/memory/types.ts +1 -0
  42. package/src/prompts/policyRegistry.js +31 -17
  43. package/src/prompts/policyRegistry.ts +31 -16
  44. package/src/prompts/promptAssembly.js +3 -9
  45. package/src/prompts/promptAssembly.ts +3 -9
  46. package/src/runtime/answerPreparation.js +9 -0
  47. package/src/runtime/answerPreparation.ts +11 -0
  48. package/src/runtime/eventSemantics.js +2 -0
  49. package/src/runtime/eventSemantics.ts +2 -0
  50. package/src/runtime/finalAnswer.js +7 -0
  51. package/src/runtime/finalAnswer.ts +9 -0
  52. package/src/runtime/lifecycleTrace.js +16 -0
  53. package/src/runtime/lifecycleTrace.ts +22 -0
  54. package/src/session/runTurn.js +15 -1
  55. package/src/session/runTurn.ts +13 -1
  56. package/src/tools/advisor/advisor.js +56 -40
  57. package/src/tools/advisor/advisor.ts +57 -39
  58. package/src/tools/advisor/advisorDecision.js +6 -3
  59. package/src/tools/advisor/advisorDecision.ts +6 -3
  60. package/src/tools/agent/agent.js +7 -1
  61. package/src/tools/agent/agent.ts +7 -1
  62. package/src/tools/bash/semantics.js +91 -26
  63. package/src/tools/bash/semantics.ts +63 -33
  64. package/src/tools/index.js +4 -1
  65. package/src/tools/index.ts +4 -1
  66. package/src/types.ts +15 -6
  67. package/src/ui/LiveArea.js +2 -1
  68. package/src/ui/LiveArea.tsx +2 -1
  69. package/src/ui/MessageList.js +18 -5
  70. package/src/ui/MessageList.tsx +25 -3
  71. package/src/ui/draftNotice.js +1 -8
  72. package/src/ui/draftNotice.ts +1 -10
  73. package/src/ui/hooks/useChat.js +25 -1
  74. package/src/ui/hooks/useChat.ts +22 -1
package/src/loop.ts CHANGED
@@ -28,6 +28,9 @@
28
28
  */
29
29
 
30
30
  import crypto from 'node:crypto';
31
+ import { answerPreparationNotice } from './runtime/answerPreparation.js';
32
+ import { finalAnswerMessages } from './runtime/finalAnswer.js';
33
+ import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
31
34
  import { conciseToolPath } from './runtime/toolDisplay.js';
32
35
  import { dirname, resolve } from 'node:path';
33
36
  import { loadProjectPromptInstructions } from './prompts/projectInstructions.js';
@@ -100,6 +103,7 @@ import {
100
103
  } from './advisor/advisorCoordinator.js';
101
104
  import { toolEffectFor } from './tools/capabilities.js';
102
105
  import { isReadOnlyShellCommand, isVerificationShellCommand } from './tools/bash/semantics.js';
106
+ import { collectReviewContext } from './advisor/reviewContext.js';
103
107
  import {
104
108
  createAgentRuntimeContext,
105
109
  type AgentRuntimeContext,
@@ -410,11 +414,13 @@ export async function* runQuery(
410
414
  if (!computerIntent.needed && sessionState) delete sessionState.computerAutomation;
411
415
  // DI fallback:测试可传 stub,生产走真实模块单例
412
416
  const chatFn = options.chat ?? defaultChat;
417
+ const trace = createLifecycleTrace('loop');
413
418
  const executeToolFn = options.executeTool ?? defaultExecuteTool;
414
419
  const explicitFileDeliveryRequested = expectsFormalDelivery(userInput);
415
420
  const currentUserMessageId = options.userMessageId ?? crypto.randomUUID();
416
421
 
417
422
  let currentTransientBundle = options.transientBundle;
423
+ let advisorContextBundle = currentTransientBundle;
418
424
  let modelEnvironmentCwd = runtimeContext.cwd;
419
425
  const observedRuleScopes = new Map<string, string>();
420
426
  const pendingRuleScopes = new Set<string>();
@@ -435,6 +441,7 @@ export async function* runQuery(
435
441
  const selected = availableForBundle >= bundle.diagnostics.usedTokens
436
442
  ? bundle
437
443
  : resizeContextBundle(bundle, availableForBundle);
444
+ advisorContextBundle = selected;
438
445
  return [selected.text, opaque].filter(Boolean).join('\n\n');
439
446
  };
440
447
  let resultPresented = false;
@@ -452,6 +459,16 @@ export async function* runQuery(
452
459
  executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
453
460
  history,
454
461
  sessionState,
462
+ advisorReviewContext: () => ({
463
+ ...collectReviewContext(advisorContextBundle),
464
+ constraints: [...collectReviewContext(advisorContextBundle).constraints,
465
+ { id: 'execution.boundary', authority: 'host', content: JSON.stringify({ cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
466
+ conversationMode: toolCallContext.conversationMode, profile: runtimeContext.profile,
467
+ availableTools: [...currentEffectiveToolSet.names], invocation: invocationInstructions(options.invocationPolicy) }) }],
468
+ omittedIds: [...new Set([...(currentTransientBundle?.diagnostics.omittedIds ?? []), ...(advisorContextBundle?.diagnostics.omittedIds ?? [])]),
469
+ ...(options.transientContext ? ['legacy.transient-context:not-forwarded'] : [])],
470
+ clippedIds: [...new Set([...(currentTransientBundle?.diagnostics.clippedIds ?? []), ...(advisorContextBundle?.diagnostics.clippedIds ?? [])])],
471
+ }),
455
472
  // Statistics only; consultations have no per-turn quota.
456
473
  advisorCalls: { used: 0 },
457
474
  advisorInvocation: 'agent',
@@ -910,7 +927,17 @@ export async function* runQuery(
910
927
  }
911
928
 
912
929
  // 3. 调 LLM 并组装 assistant 消息
930
+ trace('model_start');
913
931
  let assistantText = '';
932
+ // Candidate text is private until the delivery gates allow publication.
933
+ // Keep the compatibility event as a phase notice, never as a text retraction.
934
+ function* retireDraft(reason: Extract<LoopEvent, { type: 'assistant_draft_discarded' }>['reason'] | 'interrupted'): Generator<LoopEvent> {
935
+ const message: Message = { role: 'system', content: answerPreparationNotice(reason), id: crypto.randomUUID(),
936
+ timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
937
+ history.push(message);
938
+ yield { type: 'injected_message', message };
939
+ if (reason !== 'interrupted') yield { type: 'assistant_draft_discarded', reason, textLength: 0 };
940
+ }
914
941
  /** 按 index 累积 tool_calls(OpenAI 协议下 tool_calls 是分片返回的) */
915
942
  const toolCallsByIndex: ToolCall[] = [];
916
943
  /**
@@ -921,9 +948,6 @@ export async function* runQuery(
921
948
  let reasoningString = '';
922
949
  const reasoningDetails: unknown[] = [];
923
950
 
924
- /** 首个 text_delta 时切到 streaming,再来不重复 yield */
925
- let stageStreamingYielded = false;
926
-
927
951
  /**
928
952
  * R2:本轮 LLM 流的终止原因(来自 client 的 done.stopReason)。
929
953
  * 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
@@ -956,12 +980,7 @@ export async function* runQuery(
956
980
  : {}),
957
981
  })) {
958
982
  if (ev.type === 'text_delta') {
959
- if (!stageStreamingYielded) {
960
- stageStreamingYielded = true;
961
- yield { type: 'stage_change', stage: 'streaming' };
962
- }
963
983
  assistantText += ev.delta;
964
- yield { type: 'text', delta: ev.delta };
965
984
  } else if (ev.type === 'tool_call_delta') {
966
985
  mergeToolCallDelta(toolCallsByIndex, ev);
967
986
  } else if (ev.type === 'reasoning_delta') {
@@ -1002,7 +1021,9 @@ export async function* runQuery(
1002
1021
  }
1003
1022
  }
1004
1023
  } catch (e) {
1024
+ trace('model_error');
1005
1025
  if ((e as Error).name === 'AbortError') {
1026
+ yield* retireDraft('interrupted');
1006
1027
  yield interruption();
1007
1028
  return;
1008
1029
  }
@@ -1026,6 +1047,7 @@ export async function* runQuery(
1026
1047
  options.promptCache,
1027
1048
  );
1028
1049
  } catch (error) {
1050
+ yield* retireDraft('interrupted');
1029
1051
  if (signal?.aborted || (error as Error).name === 'AbortError') {
1030
1052
  yield interruption();
1031
1053
  return;
@@ -1057,6 +1079,7 @@ export async function* runQuery(
1057
1079
  }
1058
1080
 
1059
1081
  // code='llm_error':终结性错误,stop_reason='error'
1082
+ yield* retireDraft('interrupted');
1060
1083
  yield {
1061
1084
  type: 'error',
1062
1085
  error: `LLM 调用失败:${(e as Error).message}`,
@@ -1065,7 +1088,9 @@ export async function* runQuery(
1065
1088
  return;
1066
1089
  }
1067
1090
 
1091
+ trace('model_end');
1068
1092
  if (lastFinishReason === 'aborted' || signal?.aborted || options.hasQueuedInput?.()) {
1093
+ yield* retireDraft('interrupted');
1069
1094
  if (options.hasQueuedInput?.() && !signal?.aborted) {
1070
1095
  pushQueuedInputMarker(history);
1071
1096
  yield { type: 'interrupted', reason: 'queued_input' };
@@ -1086,6 +1111,19 @@ export async function* runQuery(
1086
1111
  }
1087
1112
  }
1088
1113
 
1114
+ // A pending review must run before another solving/publishing attempt.
1115
+ // Read-only diagnosis and human questions remain available while stalled.
1116
+ const pendingReview = options.advisorCheckpoints === false ? undefined : advisorCoordinator.pendingReview();
1117
+ if (pendingReview && turn < maxTurns && toolCallsByIndex.length > 0
1118
+ && !toolCallsByIndex.some(tc => ['Advisor', 'AskUserQuestion'].includes(tc.function.name))
1119
+ && toolCallsByIndex.some(tc => tc.function.name === 'PresentResult'
1120
+ || advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find(t => t.name === tc.function.name), { treatVerificationAsReadOnly: true }).mutatesWorkspace
1121
+ || stallGuard.shouldReject(tc))) {
1122
+ yield* retireDraft('advisor_checkpoint');
1123
+ toolCallsByIndex.splice(0, toolCallsByIndex.length, { id: crypto.randomUUID(), type: 'function',
1124
+ function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
1125
+ assistantText = '';
1126
+ }
1089
1127
  const assistantMsg: Message = {
1090
1128
  role: 'assistant',
1091
1129
  content: assistantText.length > 0 ? assistantText : null,
@@ -1098,12 +1136,12 @@ export async function* runQuery(
1098
1136
  ...(options.messageProvenance ? { provenance: options.messageProvenance } : {}),
1099
1137
  };
1100
1138
  // 4. 没有工具调用 → 整轮交互结束
1101
- if (toolCallsByIndex.length === 0) {
1139
+ finalResponse: if (toolCallsByIndex.length === 0) {
1102
1140
  if (!askCorrectionAttempted && runtimeContext.interactiveAsk
1103
1141
  && currentEffectiveToolSet.names.has('AskUserQuestion')
1104
1142
  && needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
1105
1143
  askCorrectionAttempted = true;
1106
- yield { type: 'assistant_draft_discarded', reason: 'interactive_question', textLength: assistantText.length };
1144
+ yield* retireDraft('interactive_question');
1107
1145
  continue;
1108
1146
  }
1109
1147
  const emptyResponse = stripThinkBlocks(assistantText).length === 0;
@@ -1112,12 +1150,11 @@ export async function* runQuery(
1112
1150
  if (pendingDelegations.length && !delegationCorrectionAttempted && turn < maxTurns) {
1113
1151
  delegationCorrectionAttempted = true;
1114
1152
  coordinationText = coordination!.text;
1115
- yield { type: 'assistant_draft_discarded', reason: 'formal_delivery', textLength: assistantText.length };
1153
+ yield* retireDraft('formal_delivery');
1116
1154
  continue;
1117
1155
  }
1118
1156
  const pendingMessages = [...(options.takePendingMessages?.() ?? []), ...await takeAgentNotifications(toolCallContext)];
1119
1157
  if (pendingMessages.length) {
1120
- if (!emptyResponse) history.push(assistantMsg);
1121
1158
  history.push(...pendingMessages);
1122
1159
  continue;
1123
1160
  }
@@ -1131,22 +1168,27 @@ export async function* runQuery(
1131
1168
  && turn < maxTurns
1132
1169
  ) {
1133
1170
  deliveryCorrectionAttempted = true;
1134
- yield {
1135
- type: 'assistant_draft_discarded',
1136
- reason: 'formal_delivery',
1137
- textLength: assistantText.length,
1138
- };
1171
+ yield* retireDraft('formal_delivery');
1139
1172
  continue;
1140
1173
  }
1141
- const deliveryCheckpoint = emptyResponse
1174
+ const deliveryCheckpoint = emptyResponse || options.advisorCheckpoints === false
1142
1175
  ? { allow: true as const }
1143
1176
  : advisorCoordinator.beforeDelivery(assistantText);
1177
+ const fallbackReview = !emptyResponse && options.advisorCheckpoints !== false
1178
+ ? advisorCoordinator.pendingReview(assistantText) : undefined;
1179
+ if (fallbackReview && turn < maxTurns) {
1180
+ yield* retireDraft('advisor_checkpoint');
1181
+ // Reuse the effective tool boundary, permissions, cancellation, events and
1182
+ // persistence. Never call a provider behind the host's tool pipeline.
1183
+ toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
1184
+ name: 'Advisor', arguments: JSON.stringify(fallbackReview),
1185
+ } });
1186
+ assistantMsg.content = null;
1187
+ assistantMsg.tool_calls = toolCallsByIndex;
1188
+ break finalResponse;
1189
+ }
1144
1190
  if (!deliveryCheckpoint.allow && turn < maxTurns) {
1145
- yield {
1146
- type: 'assistant_draft_discarded',
1147
- reason: 'advisor_checkpoint',
1148
- textLength: assistantText.length,
1149
- };
1191
+ yield* retireDraft('advisor_checkpoint');
1150
1192
  continue;
1151
1193
  }
1152
1194
  if (!emptyResponse) {
@@ -1156,19 +1198,110 @@ export async function* runQuery(
1156
1198
  if (emptyResponse) {
1157
1199
  // A no-tool response is already the model's terminal signal. Do not turn it
1158
1200
  // into another model round, and do not persist/broadcast an empty bubble.
1159
- yield {
1160
- type: 'assistant_draft_discarded',
1161
- reason: 'empty_response',
1162
- textLength: assistantText.length,
1163
- };
1201
+ yield* retireDraft('empty_response');
1164
1202
  } else {
1203
+ // Publication is a separate tool-free request. Nothing from preparation
1204
+ // has been streamed, and no gate may retract text after this boundary.
1205
+ let publicationError: unknown;
1206
+ let publicationInterrupted = false;
1207
+ let streamed = false;
1208
+ if (!options.responseFormat && runtimeContext.profile === 'main'
1209
+ && (runtimeContext.surface === 'desktop' || runtimeContext.surface === 'tui')) {
1210
+ const candidate = assistantText;
1211
+ const candidateFinishReason = lastFinishReason;
1212
+ let publishedText = '';
1213
+ let publicationDone = false;
1214
+ yield { type: 'stage_change', stage: 'streaming' };
1215
+ try {
1216
+ const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText);
1217
+ const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
1218
+ if (publicationPlan.overBudget) throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
1219
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1220
+ publicationInterrupted = true;
1221
+ throw new Error('最终发布已取消。');
1222
+ }
1223
+ turnUsage.totalCalls += 1;
1224
+ for await (const event of chatFn({
1225
+ provider,
1226
+ messages: publicationMessages,
1227
+ tools: [], signal,
1228
+ conversationMode: runtimeContext.conversationMode ?? 'private',
1229
+ promptCache: options.promptCache,
1230
+ })) {
1231
+ if (event.type === 'done' && !publicationDone) {
1232
+ publicationDone = true;
1233
+ if (typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number') {
1234
+ turnUsage.reportedCalls += 1;
1235
+ turnUsage.inputTokens += event.promptTokens ?? 0;
1236
+ turnUsage.outputTokens += event.outputTokens ?? 0;
1237
+ }
1238
+ }
1239
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1240
+ publicationInterrupted = true;
1241
+ break;
1242
+ }
1243
+ if (event.type === 'text_delta') {
1244
+ // Never expose a rewrite of the reviewed candidate. Previously
1245
+ // published chunks are necessarily an exact prefix of it.
1246
+ if (!candidate.startsWith(publishedText + event.delta)) {
1247
+ throw new Error('发布内容偏离定稿,使用原定稿。');
1248
+ }
1249
+ publishedText += event.delta;
1250
+ streamed = true;
1251
+ yield { type: 'text', delta: event.delta };
1252
+ } else if (event.type === 'tool_call_delta') {
1253
+ throw new Error('最终表达阶段返回了非预期工具调用,未执行该工具。');
1254
+ } else if (event.type === 'done') {
1255
+ publicationDone = true;
1256
+ lastFinishReason = event.stopReason;
1257
+ if (event.stopReason === 'aborted') publicationInterrupted = true;
1258
+ }
1259
+ }
1260
+ if (!publicationDone && !publicationInterrupted) throw new Error('最终答案流未正常结束。');
1261
+ if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length') throw new Error('最终答案流缺少有效的结束状态。');
1262
+ if (!publishedText.trim() && !publicationInterrupted) throw new Error('最终表达未返回正文。');
1263
+ if (!publicationInterrupted && publishedText !== candidate) throw new Error('最终表达未完整传递定稿。');
1264
+ } catch (error) {
1265
+ publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || (error as Error)?.name === 'AbortError';
1266
+ publicationError = error;
1267
+ }
1268
+ if (publicationError && !publicationInterrupted) {
1269
+ yield { type: 'warning', message: '流式发布未完成,已使用原定稿补齐;未重新生成或改写答案。' };
1270
+ const remaining = candidate.slice(publishedText.length);
1271
+ if (remaining) {
1272
+ yield { type: 'text', delta: remaining };
1273
+ streamed = true;
1274
+ }
1275
+ publishedText = candidate;
1276
+ publicationError = undefined;
1277
+ }
1278
+ assistantText = publishedText;
1279
+ if (!publicationInterrupted) lastFinishReason = candidateFinishReason;
1280
+ assistantMsg.content = publishedText || null;
1281
+ if (!publishedText) {
1282
+ if (publicationInterrupted) yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1283
+ else yield { type: 'error', code: 'llm_error', error: `最终答案生成失败:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1284
+ return;
1285
+ }
1286
+ }
1287
+ assistantMsg.presentation = 'final';
1288
+ trace('final_published');
1165
1289
  if (assistantMsg.role === 'assistant') assistantMsg.advisorTaskVersion = advisorCoordinator.versions.taskVersion;
1166
1290
  assistantMsg.advisorCheckpoints = advisorCoordinator.drainDegradations();
1167
1291
  history.push(assistantMsg);
1292
+ // Non-interactive consumers retain atomic output. Interactive hosts
1293
+ // already received real deltas; commit the identical text only once.
1294
+ if (!streamed) yield { type: 'text', delta: assistantText };
1168
1295
  yield {
1169
1296
  type: 'assistant_message',
1170
1297
  message: assistantMsg as Extract<Message, { role: 'assistant' }>,
1171
1298
  };
1299
+ if (publicationInterrupted || publicationError) {
1300
+ yield { type: 'warning', message: '最终答案未完整生成,已保留已输出内容;未撤回或重新生成。' };
1301
+ if (publicationInterrupted) yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1302
+ else yield { type: 'error', code: 'llm_error', error: `最终答案流中断:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1303
+ return;
1304
+ }
1172
1305
  }
1173
1306
  // R2:最终回答被 provider 因 max_tokens 截断(finish_reason='length')时,
1174
1307
  // 不能静默当正常 end_turn 收尾 —— 否则 -p 宿主会拿到半截答案却以为成功。
@@ -1220,7 +1353,11 @@ export async function* runQuery(
1220
1353
  ...turnUsage,
1221
1354
  trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
1222
1355
  };
1223
- if (pendingDelegations.length) {
1356
+ if (emptyResponse && !resultPresented) {
1357
+ const warning = '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1358
+ yield { type: 'warning', message: warning };
1359
+ yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
1360
+ } else if (pendingDelegations.length) {
1224
1361
  const warning = `必要子任务尚未收口:${pendingDelegations.map((r) => r.id).join(', ')}。本轮不能视为全部完成。`;
1225
1362
  yield { type: 'warning', message: warning };
1226
1363
  yield { type: 'turn_done', completionStatus: 'delivery_incomplete', warning, usage };
@@ -1240,11 +1377,14 @@ export async function* runQuery(
1240
1377
  usage,
1241
1378
  };
1242
1379
  } else {
1243
- yield { type: 'turn_done', completionStatus: 'completed', usage };
1380
+ yield { type: 'turn_done', completionStatus: 'completed', usage,
1381
+ ...(emptyResponse ? { warning: '成果已提交,未生成文字总结;请查看已提交的成果。' } : {}) };
1244
1382
  }
1245
1383
  return;
1246
1384
  }
1247
1385
 
1386
+ assistantMsg.presentation = 'progress';
1387
+ trace('progress_published');
1248
1388
  history.push(assistantMsg);
1249
1389
  yield {
1250
1390
  type: 'assistant_message',
@@ -1471,7 +1611,19 @@ export async function* runQuery(
1471
1611
  };
1472
1612
 
1473
1613
  const runOne: typeof runOneImpl = async (tc, reportProgress) => {
1614
+ // Also settle denied/invalid/unavailable calls that never reached the
1615
+ // provider adapter. This records a real attempt, not an ignored reminder.
1616
+ let consultation;
1617
+ if (tc.function.name === 'Advisor') {
1618
+ let reason: import('./types.js').AdvisorReason | undefined;
1619
+ try { reason = JSON.parse(tc.function.arguments).reason; } catch { /* validation handles malformed input */ }
1620
+ consultation = advisorCoordinator.beginConsultation(reason);
1621
+ }
1474
1622
  const outcome = await runOneImpl(tc, reportProgress);
1623
+ if (consultation && outcome.result.metadata?.kind !== 'advisor_consultation'
1624
+ && !( !outcome.result.ok && outcome.result.error === 'aborted')) {
1625
+ outcome.result = { ...outcome.result, metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok) };
1626
+ }
1475
1627
  const metadata = outcome.result.metadata;
1476
1628
  if (metadata?.kind === 'advisor_decision' && outcome.result.ok
1477
1629
  || metadata?.kind === 'advisor_consultation') {
@@ -1788,6 +1940,7 @@ export function buildModelMessages(
1788
1940
  transientContext?: string,
1789
1941
  currentUserMessageId?: string,
1790
1942
  ): Message[] {
1943
+ history = history.filter(message => !(message.role === 'system' && message._ephemeral));
1791
1944
  const transient = transientContext?.trim();
1792
1945
  if (!transient) return history;
1793
1946
  let index = currentUserMessageId
@@ -673,6 +673,7 @@ export async function recallExperiences(workspaceId, query, limit = 5) {
673
673
  item.conditions,
674
674
  item.procedure,
675
675
  ])
676
+ .normalize('NFKC')
676
677
  .toLowerCase()
677
678
  .includes(term)
678
679
  ? 1
@@ -848,6 +848,7 @@ export async function recallExperiences(
848
848
  item.conditions,
849
849
  item.procedure,
850
850
  ])
851
+ .normalize('NFKC')
851
852
  .toLowerCase()
852
853
  .includes(term)
853
854
  ? 1
@@ -747,7 +747,7 @@ export const memoryService = {
747
747
  .toLowerCase()
748
748
  .includes(term))))
749
749
  .slice(0, count(3));
750
- const pushSection = (id, content, ids = []) => {
750
+ const pushSection = (id, content, ids = [], recordList) => {
751
751
  if (countTextTokens([...sections, content].join('\n\n')) > budget)
752
752
  return false;
753
753
  sections.push(content);
@@ -757,6 +757,7 @@ export const memoryService = {
757
757
  content,
758
758
  includedIds: ids,
759
759
  estimatedTokens: countTextTokens(content),
760
+ ...(recordList ? { recordList } : {}),
760
761
  });
761
762
  return true;
762
763
  };
@@ -778,7 +779,7 @@ export const memoryService = {
778
779
  selected.push(entry);
779
780
  }
780
781
  if (selected.length)
781
- pushSection(id, `${header}\n${selected.map((item) => item.text).join('\n')}`, selected.map((item) => item.id));
782
+ pushSection(id, `${header}\n${selected.map((item) => item.text).join('\n')}`, selected.map((item) => item.id), { header, entries: selected });
782
783
  };
783
784
  addRecords('preferences', '【响应偏好|personal 全局,workspace 项目】当前要求优先;项目偏好仅在当前项目适用;待复核信息不作为确定约束。', preferences.map((record) => ({ id: record.id, text: describe(record) })), share(550));
784
785
  addRecords('relevant', '【相关本机记忆|低优先级、可纠正】当前消息优先;来源见 RecallMemory。', relevant.map((record) => ({ id: record.id, text: describe(record) })), share(850));
@@ -1110,6 +1110,7 @@ export const memoryService = {
1110
1110
  id: MemoryContextBundle['sections'][number]['id'],
1111
1111
  content: string,
1112
1112
  ids: string[] = [],
1113
+ recordList?: MemoryContextBundle['sections'][number]['recordList'],
1113
1114
  ) => {
1114
1115
  if (countTextTokens([...sections, content].join('\n\n')) > budget)
1115
1116
  return false;
@@ -1120,6 +1121,7 @@ export const memoryService = {
1120
1121
  content,
1121
1122
  includedIds: ids,
1122
1123
  estimatedTokens: countTextTokens(content),
1124
+ ...(recordList ? { recordList } : {}),
1123
1125
  });
1124
1126
  return true;
1125
1127
  };
@@ -1155,6 +1157,7 @@ export const memoryService = {
1155
1157
  id,
1156
1158
  `${header}\n${selected.map((item) => item.text).join('\n')}`,
1157
1159
  selected.map((item) => item.id),
1160
+ { header, entries: selected },
1158
1161
  );
1159
1162
  };
1160
1163
  addRecords(
@@ -304,6 +304,7 @@ export async function recallProjectKnowledge(workspaceId, query, limit = 5) {
304
304
  item.applicability,
305
305
  item.aliases,
306
306
  ])
307
+ .normalize('NFKC')
307
308
  .toLowerCase()
308
309
  .includes(term)
309
310
  ? 1
@@ -434,6 +434,7 @@ export async function recallProjectKnowledge(
434
434
  item.applicability,
435
435
  item.aliases,
436
436
  ])
437
+ .normalize('NFKC')
437
438
  .toLowerCase()
438
439
  .includes(term)
439
440
  ? 1
@@ -13,10 +13,10 @@ export function isReviewDue(record, settings, now = Date.now()) {
13
13
  export function scoreMemory(record, query, settings) {
14
14
  if (record.status === 'superseded' || (record.validUntil !== undefined && record.validUntil <= Date.now()))
15
15
  return Number.NEGATIVE_INFINITY;
16
- const haystack = `${record.category} ${record.subject} ${record.topicKey} ${record.aliases.join(' ')} ${record.statement}`.toLocaleLowerCase();
16
+ const haystack = `${record.category} ${record.subject} ${record.topicKey} ${record.aliases.join(' ')} ${record.statement}`.normalize('NFKC').toLocaleLowerCase();
17
17
  const terms = memoryTerms(query);
18
18
  const lexical = terms.reduce((sum, term) => sum + (haystack.includes(term) ? 10 : 0), 0);
19
- const phrase = query.trim() && haystack.includes(query.trim().toLocaleLowerCase())
19
+ const phrase = query.trim() && haystack.includes(query.trim().normalize('NFKC').toLocaleLowerCase())
20
20
  ? 25
21
21
  : 0;
22
22
  const stalePenalty = isReviewDue(record, settings) || record.status === 'needs_review' ? 8 : 0;
@@ -53,10 +53,10 @@ export function recallFromSnapshot(snapshot, query, settings, limit) {
53
53
  }
54
54
  recordSearchStrategy('bm25', eligible.length);
55
55
  const bm25 = index.search(query);
56
- const normalized = query.trim().toLocaleLowerCase();
56
+ const normalized = query.trim().normalize('NFKC').toLocaleLowerCase();
57
57
  return eligible
58
58
  .map((record) => {
59
- const haystack = `${record.category} ${record.subject} ${record.topicKey} ${record.aliases.join(' ')} ${record.statement}`.toLocaleLowerCase();
59
+ const haystack = `${record.category} ${record.subject} ${record.topicKey} ${record.aliases.join(' ')} ${record.statement}`.normalize('NFKC').toLocaleLowerCase();
60
60
  const phrase = normalized && haystack.includes(normalized) ? 25 : 0;
61
61
  const stalePenalty = isReviewDue(record, settings) || record.status === 'needs_review' ? 8 : 0;
62
62
  return {
@@ -39,14 +39,14 @@ export function scoreMemory(
39
39
  ): number {
40
40
  if (record.status === 'superseded' || (record.validUntil !== undefined && record.validUntil <= Date.now())) return Number.NEGATIVE_INFINITY;
41
41
  const haystack =
42
- `${record.category} ${record.subject} ${record.topicKey} ${record.aliases.join(' ')} ${record.statement}`.toLocaleLowerCase();
42
+ `${record.category} ${record.subject} ${record.topicKey} ${record.aliases.join(' ')} ${record.statement}`.normalize('NFKC').toLocaleLowerCase();
43
43
  const terms = memoryTerms(query);
44
44
  const lexical = terms.reduce(
45
45
  (sum, term) => sum + (haystack.includes(term) ? 10 : 0),
46
46
  0,
47
47
  );
48
48
  const phrase =
49
- query.trim() && haystack.includes(query.trim().toLocaleLowerCase())
49
+ query.trim() && haystack.includes(query.trim().normalize('NFKC').toLocaleLowerCase())
50
50
  ? 25
51
51
  : 0;
52
52
  const stalePenalty =
@@ -93,10 +93,10 @@ export function recallFromSnapshot(
93
93
  }
94
94
  recordSearchStrategy('bm25', eligible.length);
95
95
  const bm25 = index.search(query);
96
- const normalized = query.trim().toLocaleLowerCase();
96
+ const normalized = query.trim().normalize('NFKC').toLocaleLowerCase();
97
97
  return eligible
98
98
  .map((record) => {
99
- const haystack = `${record.category} ${record.subject} ${record.topicKey} ${record.aliases.join(' ')} ${record.statement}`.toLocaleLowerCase();
99
+ const haystack = `${record.category} ${record.subject} ${record.topicKey} ${record.aliases.join(' ')} ${record.statement}`.normalize('NFKC').toLocaleLowerCase();
100
100
  const phrase = normalized && haystack.includes(normalized) ? 25 : 0;
101
101
  const stalePenalty = isReviewDue(record, settings) || record.status === 'needs_review' ? 8 : 0;
102
102
  return {
@@ -176,6 +176,7 @@ export interface MemoryContextBundle {
176
176
  content: string;
177
177
  includedIds: string[];
178
178
  estimatedTokens: number;
179
+ recordList?: { header: string; entries: Array<{ id: string; text: string }> };
179
180
  }>;
180
181
  }
181
182
 
@@ -38,8 +38,8 @@ ${toolNames.has('TaskCreate') && toolNames.has('TaskUpdate') ? '- 用户已要
38
38
  id: 'execution.communication', layer: 'execution', profiles: ['main'], source: 'native',
39
39
  render: () => `# 结果与沟通
40
40
  - 按用途选择对话答案、可编辑文件或可运行成果,不按篇幅自动建文件;要求生成成品时实际产出。
41
- - 每个有意义的工作步骤开始前,用一句简短正文说明当前要做什么;步骤结束或方向变化时,说明新增发现、阶段结果或阻塞,并明确下一步具体工作。工具简介由界面展示,不逐条复述工具日志,不输出私有推理。连续相关的小工具操作合并成一个步骤;不要为汇报暂停执行或向用户索要无必要确认。
42
- - Skill、顾问、浏览器和子 Agent 的工作也遵循同一规则:说明目的,返回后核验结果,说明采纳结论与下一步。
41
+ - 开始执行时简述目标与近期动作;出现实质发现、阶段成果、方向变化或阻塞时更新,并说明下一步。连续相关操作合并汇报,不机械套用“工作进展/下一步”标题,不逐条复述界面已有的工具日志,不输出私有推理;没有新增信息就继续工作,不为汇报暂停执行。
42
+ - 使用 Skill、顾问、浏览器或子 Agent 时说明其目的;返回后核验结果,只汇报影响后续行动的结论,不复述全部过程。
43
43
  - 最终答案先给明确结果;复杂任务按“完成内容 / 验证结果 / 未完成与下一步”使用 Markdown 小标题和简短列表,附相关文件或成果入口。简单问题直接回答,不强套空标题。区分已验证、失败与尚未验证,不捏造成功或百分比。代码使用带语言的代码块,比较项用表格,正文不重复粘贴全部工具输出。
44
44
  - 可视化须有助理解关系;已有成品不在正文重复。`,
45
45
  },
@@ -60,6 +60,14 @@ ${toolNames.has('TaskCreate') && toolNames.has('TaskUpdate') ? '- 用户已要
60
60
  - 不经明确授权不得 amend 提交、丢弃工作区修改或强推。Git 使用非交互命令。发现无关他人改动保留并绕开;重叠且无法确定归属时暂停相关修改并询问,不回退他人成果。
61
61
  - 测试按风险选择:修改后主动运行能证明行为的最小验证;共享协议、跨模块和用户流程改动应扩大到类型检查、相关测试或构建。不能运行时明确说明。
62
62
  - 收尾前审阅本轮 diff,清除自己产生的半成品,确认实现、测试和用户目标一致。`,
63
+ },
64
+ {
65
+ id: 'execution.follow-through', layer: 'execution', profiles: ['main'], source: 'native',
66
+ render: () => `# 推进与完成
67
+ - 多阶段任务明确完成条件和关键路径,不把计划本身当成交付;小任务直接执行。
68
+ - 在任务范围内持续完成实现、检查和必要返工,不因首次失败提前收尾;受阻时先推进独立工作。
69
+ - 失败后依据回执改变方法,不原样循环或臆测环境故障;真正受外部条件阻塞时说明尝试与所需输入。
70
+ - 完成以成果和验证证据为准,不以操作次数或投入时长为准。`,
63
71
  },
64
72
  {
65
73
  id: 'execution.interruption', layer: 'execution', profiles: ['main'], source: 'native',
@@ -185,10 +193,18 @@ const CAPABILITY_POLICIES = [
185
193
  render: ({ toolNames }) => `- 需求匹配已安装 Skill 或用户点名时,${skillInvokeInstruction(toolNames.has('Skill'))};前端开发与交互修复也要主动匹配设计技能,不等用户说“设计”。引用资源按需读取,多阶段按需组合,不预读整个目录或加载无关技能。`,
186
194
  },
187
195
  {
188
- id: 'execution.desktop-diagrams', layer: 'execution', profiles: MAIN_PROFILE, source: 'native',
189
- render: ({ surface, toolNames }) => surface === 'desktop'
190
- ? `- Desktop 需要架构、流程或关系图时,${toolNames.has('Skill') || toolNames.has('Read') ? '优先加载可用的 dynamic-ui,按规范输出 svg 围栏;' : ''}少用大段 ASCII 框图。简单文字、清晰表格不画图;用户指定 ASCII 或原样代码/目录树除外。图不可用时用文字/表格,保留关键结论,不伪造控件或成果。`
191
- : '- 纯文本回复不输出 SVG 源码,改用文字或表格。',
196
+ id: 'execution.desktop-diagrams', layer: 'execution', profiles: ALL_PROFILES, source: 'native',
197
+ render: ({ surface, toolNames }) => [
198
+ '- 按需配图,优先 Mermaid/SVG;用户指定 ASCII、代码/目录树除外。',
199
+ surface === 'desktop'
200
+ ? '- Desktop/Web 聊天支持 mermaid 和 svg 围栏,不用裸 HTML。'
201
+ : surface === 'cli' || surface === 'tui'
202
+ ? '- CLI/TUI 直接回复用文字/表格/紧凑 ASCII,生成文件仍优先 Mermaid/SVG。'
203
+ : '- 跟随最终阅读载体,不等同于终端:Web 用 mermaid/svg 围栏;保持协议契约,未知不假定可渲染。',
204
+ toolNames.has('Skill') || toolNames.has('Read')
205
+ ? '- 加载可用的 dynamic-ui。'
206
+ : '',
207
+ ].filter(Boolean).join('\n'),
192
208
  },
193
209
  {
194
210
  id: 'capability.session-worktree', layer: 'capability', profiles: MAIN_PROFILE, source: '06',
@@ -238,17 +254,17 @@ const CAPABILITY_POLICIES = [
238
254
  requiresTools: ['Advisor'],
239
255
  // 注意:文本内不得硬编码其他工具名(如 PresentResult),否则该工具缺席时这里
240
256
  // 成为唯一残留的「幽灵引用」——capability 规则交叉引用必须在 toolNames 条件下拼接。
241
- render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:Advisor 是第二意见,主 Agent 自行核实并决定采纳、部分采纳或拒绝;顾问不授予权限,也不替用户验收。
242
- 咨询条件只有:计划准备执行前(reason=plan_review)、实质结果提交前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、自身非常不确定或无法解决(uncertainty)。只读调查不等于计划执行;按问题与阶段咨询,不因命令字符串、工具切换或每次写入重新咨询。
243
- 同一阶段已主动咨询,就不用等系统提示再问。相同问题的一次咨询已结束后先继续执行;只有新的两次失败或新的实质问题才需要再问。同一问题的失败次数不能因换工具清零,也不能把无关失败合并。
244
- 不设咨询配额。每次咨询只尝试一次;不可用、出错、空答或超时即降级为自己决定并继续,不重复咨询、不转成人工审批。不要把尝试失败说成已获得顾问验证。
245
- 成功后由主 Agent 按代码、测试和用户要求作决定,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject。登记不是执行许可,登记失败也不阻止继续任务。' : '直接说明采纳判断即可,不要求调用未提供的审计工具。'}意见与证据冲突时以实测为准,不强制复核咨询;只有冲突使你无法判断时才主动咨询。
246
- 顾问只看见有限历史和证据;note 提供关键疑点与现场,不能把顾问意见本身当一手证据。咨询返回后必须先阅读结果并作出自己的判断,再生成后续行动;同批预先生成的调用会暂缓且不算执行证据。不要因暂缓而重复咨询。Advisor 不可用或未提供时自行继续。`,
257
+ render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:提供第二意见,由主 Agent 核实取舍;不授予权限、不替用户验收。
258
+ 咨询时机:实质计划执行前(plan_review)、实质交付前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、非常不确定或无法解决(uncertainty)、关键证据冲突无法消解(evidence_conflict)。换工具不清零失败次数,无关失败不合并。
259
+ 文字调研、计算、比较和推荐也是交付;自信、没文件不豁免。自动咨询不再问用户;寒暄、进度、拼写修正无需咨询。
260
+ 同阶段不重复问;成果、假设实质变化或新冲突才重开。questions/note 指明疑点与证据 ID,历史由宿主附带。缺材料先补查,不算验证通过。读完意见再操作;暂缓操作未执行,提醒不算咨询。
261
+ 不设咨询配额,每次咨询只尝试一次;失败、空答或超时自行降级继续,不转成人工审批,也不宣称获得验证。
262
+ 以代码、测试和用户要求核实意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}顾问不是事实来源,冲突以实测为准。`,
247
263
  },
248
264
  {
249
265
  id: 'capability.subagent', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
250
266
  requiresTools: ['Agent'],
251
- render: () => '- 大范围调查先定位入口、模块边界与基线,不穷尽读取。至少两个独立且需多轮探索的问题,优先同轮派发 2–3 个 explore Agent(不超宿主额度);少量已知文件/网址直接批量读,琐碎、强顺序或成本高于收益不派发。主 Agent 保留跨模块判断,有独立工作时后台派发并推进,不重复调查或立即轮询。context.researchQuestions 写问题 ID、范围、停止条件,exclude/baseline 传排除项与已知结果,不复制私有历史。按问题合并 findings/readingEvidence,区分观察、推断、建议;冲突核验不投票,只补读关键结论、版本变化与修改位置。coveredFiles 不代表全文覆盖。子任务用实际可用的搜索/正文工具,动态浏览器归主 Agent;不得为只读命令升级为 general。',
267
+ render: () => '- 大范围调查先定位入口、模块边界与基线,不穷尽读取。独立且需多轮探索的问题可同轮派发 explore Agent,数量取决于独立工作量和宿主额度,不为并行而拆分;少量已知文件/网址直接批量读,强顺序或协调开销大于收益时自行完成。主 Agent 保留跨模块判断,有独立工作时后台派发并推进,不重复调查或立即轮询。任务书写清问题、范围、停止条件、排除项和已知证据,不复制私有历史。按问题整合结论与阅读证据,区分观察、推断、建议;冲突核验不投票,只补读关键结论、版本变化与修改位置。文件清单不代表全文覆盖。子任务用实际可用的搜索/正文工具,动态浏览器归主 Agent;不得为只读命令升级为 general。',
252
268
  },
253
269
  {
254
270
  id: 'capability.agent-handoff', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
@@ -263,7 +279,7 @@ const CAPABILITY_POLICIES = [
263
279
  {
264
280
  id: 'capability.delivery', layer: 'capability', profiles: MAIN_PROFILE, source: '05',
265
281
  requiresTools: ['PresentResult'],
266
- render: () => '- 文件或网站须用 PresentResult 交付真实成品,路径和中间文件不算交付。失败须说明,本机预览不是部署;已提交、检查通过、人类验收须区分。最终先给结果,再说明用法、验证证据和遗留项。',
282
+ render: () => '- 文件或网站须用 PresentResult 交付真实成品,路径和中间文件不算交付。交付失败须说明;本机预览不是部署,已提交、检查通过与人类验收是不同状态。',
267
283
  },
268
284
  {
269
285
  id: 'capability.media', layer: 'capability', profiles: MAIN_PROFILE, source: '05',
@@ -281,9 +297,7 @@ const CAPABILITY_POLICIES = [
281
297
  if (toolNames.has('RememberMemory'))
282
298
  rules.push('RememberMemory 保存明确要求记住的新事实;后台提炼启用时普通陈述交后台处理,不重复写入。自动记忆关闭时仅响应明确保存要求;失败如实说明,不阻断任务。');
283
299
  if (toolNames.has('RecallMemory'))
284
- rules.push('在询问稳定用户信息或项目背景前先 RecallMemory;回答明显依赖跨会话画像、偏好、决定或项目上下文时也先召回,避免重复询问。');
285
- if (toolNames.has('RecallMemory'))
286
- rules.push('自包含的简单任务跳过记忆检索;相关任务先利用已注入记忆,再按关键词少量召回,无相关命中即停止。反复失败或与旧结论冲突时重新查证;未在本轮核验的历史结论须说明来源及可能过时。');
300
+ rules.push('自包含任务无需检索记忆。依赖跨会话用户信息、偏好、决定或项目背景时,先检查已注入证据;缺少相关信息再用 RecallMemory,避免重复检索或直接反问用户。查询无关命中时停止扩大检索;旧结论与现场冲突时重新查证,未在本轮核验的历史结论标明来源及可能过时。');
287
301
  if (toolNames.has('RecallMemory') && toolNames.has('UpdateMemory'))
288
302
  rules.push('纠正:RecallMemory 定位 → UpdateMemory(expectedVersion);冲突回执充分则合并重试,否则重读。语义未变不重写。');
289
303
  if (toolNames.has('ForgetMemory'))