minimal-agent 0.18.13 → 0.18.14

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 (174) hide show
  1. package/package.json +1 -3
  2. package/plugins/workflow-runner/src/runtime/context.js +1 -1
  3. package/plugins/workflow-runner/src/runtime/context.ts +1 -1
  4. package/plugins/workflow-runner/test/embeddedExecution.test.ts +2 -5
  5. package/plugins/workflow-runner/test/execLifetime.test.ts +15 -2
  6. package/skills/ssh-remote-dev/SKILL.md +61 -0
  7. package/skills/ssh-remote-dev/agents/openai.yaml +4 -0
  8. package/src/advisor/README.md +62 -24
  9. package/src/advisor/advisorNudge.js +199 -0
  10. package/src/advisor/advisorNudge.ts +232 -0
  11. package/src/advisor/deliveryReview.js +35 -12
  12. package/src/advisor/deliveryReview.ts +35 -11
  13. package/src/advisor/presentation.js +7 -10
  14. package/src/advisor/presentation.ts +10 -13
  15. package/src/advisor/reviewContext.js +22 -58
  16. package/src/advisor/reviewContext.ts +24 -67
  17. package/src/cli/configMenu.js +17 -128
  18. package/src/cli/configMenu.tsx +17 -193
  19. package/src/cli/print.js +2 -0
  20. package/src/cli/print.ts +2 -0
  21. package/src/cli/streamJson.js +2 -0
  22. package/src/cli/streamJson.ts +2 -0
  23. package/src/cli/wizards/advisorWizard.js +9 -7
  24. package/src/cli/wizards/advisorWizard.tsx +9 -7
  25. package/src/cli/wizards/contextAdvisorWizard.js +9 -7
  26. package/src/cli/wizards/contextAdvisorWizard.tsx +9 -7
  27. package/src/cli/wizards/providerPresets.js +27 -1
  28. package/src/cli/wizards/providerPresets.ts +44 -2
  29. package/src/cli/wizards/providerWizard.js +51 -10
  30. package/src/cli/wizards/providerWizard.tsx +59 -9
  31. package/src/config/configDefaults.js +1 -1
  32. package/src/config/configDefaults.ts +1 -1
  33. package/src/config/configDiagnostics.js +1 -21
  34. package/src/config/configDiagnostics.ts +3 -23
  35. package/src/config/configFile.js +11 -4
  36. package/src/config/configFile.ts +27 -7
  37. package/src/config/configManagement.js +1 -0
  38. package/src/config/configManagement.ts +2 -0
  39. package/src/config/configService.js +12 -8
  40. package/src/config/configService.ts +16 -11
  41. package/src/config/numericLimits.js +0 -4
  42. package/src/config/numericLimits.ts +0 -4
  43. package/src/config/reasoningEffort.js +16 -0
  44. package/src/config/reasoningEffort.ts +18 -0
  45. package/src/context/budgetPlan.js +1 -1
  46. package/src/context/budgetPlan.ts +1 -1
  47. package/src/context/optimizeConversation.js +1 -1
  48. package/src/context/optimizeConversation.ts +1 -1
  49. package/src/harness/stallGuard.js +8 -13
  50. package/src/harness/stallGuard.ts +8 -12
  51. package/src/llm/client.js +64 -17
  52. package/src/llm/client.ts +62 -7
  53. package/src/llm/providers/openaiFormat.js +14 -3
  54. package/src/llm/providers/openaiFormat.ts +17 -3
  55. package/src/llm/providers/responsesFormat.js +275 -0
  56. package/src/llm/providers/responsesFormat.ts +306 -0
  57. package/src/loop/README.md +18 -3
  58. package/src/loop/liveDiagramGate.js +63 -0
  59. package/src/loop/liveDiagramGate.ts +61 -0
  60. package/src/loop/messages.js +12 -0
  61. package/src/loop/messages.ts +13 -0
  62. package/src/loop/options.ts +7 -2
  63. package/src/loop/toolBatch.js +0 -1
  64. package/src/loop/toolBatch.ts +0 -1
  65. package/src/loop/toolExecutor.js +11 -46
  66. package/src/loop/toolExecutor.ts +12 -55
  67. package/src/loop/toolPresentation.js +2 -13
  68. package/src/loop/toolPresentation.ts +2 -13
  69. package/src/loop/toolSemantics.js +3 -4
  70. package/src/loop/toolSemantics.ts +4 -6
  71. package/src/loop.js +222 -214
  72. package/src/loop.ts +226 -232
  73. package/src/memory/dependencies.js +11 -3
  74. package/src/memory/dependencies.ts +11 -3
  75. package/src/memory/episodicMemory.js +59 -18
  76. package/src/memory/episodicMemory.ts +58 -18
  77. package/src/memory/experienceStore.js +4 -3
  78. package/src/memory/experienceStore.ts +4 -3
  79. package/src/memory/factCandidates.js +53 -1
  80. package/src/memory/factCandidates.ts +54 -1
  81. package/src/memory/factLearning.js +2 -2
  82. package/src/memory/factLearning.ts +2 -2
  83. package/src/memory/jobPolicy.js +24 -5
  84. package/src/memory/jobPolicy.ts +25 -5
  85. package/src/memory/learning.js +2 -2
  86. package/src/memory/learning.ts +2 -2
  87. package/src/memory/learningModel.js +7 -0
  88. package/src/memory/learningModel.ts +7 -0
  89. package/src/memory/managementService.js +8 -7
  90. package/src/memory/managementService.ts +16 -8
  91. package/src/memory/memoryService.js +104 -33
  92. package/src/memory/memoryService.ts +132 -28
  93. package/src/memory/projectKnowledge.js +39 -3
  94. package/src/memory/projectKnowledge.ts +45 -3
  95. package/src/memory/recallQuery.js +56 -0
  96. package/src/memory/recallQuery.ts +58 -0
  97. package/src/memory/retrieval.js +39 -10
  98. package/src/memory/retrieval.ts +46 -11
  99. package/src/memory/turnJournal.js +75 -10
  100. package/src/memory/turnJournal.ts +66 -10
  101. package/src/memory/types.ts +7 -1
  102. package/src/prompts/delegation.js +2 -1
  103. package/src/prompts/delegation.ts +3 -1
  104. package/src/prompts/policyRegistry.js +9 -12
  105. package/src/prompts/policyRegistry.ts +9 -12
  106. package/src/prompts/promptAssembly.js +6 -5
  107. package/src/prompts/promptAssembly.ts +8 -6
  108. package/src/prompts/visualPresentation.js +39 -10
  109. package/src/prompts/visualPresentation.ts +38 -9
  110. package/src/runtime/answerPreparation.js +1 -1
  111. package/src/runtime/answerPreparation.ts +1 -1
  112. package/src/runtime/fileLock.js +19 -3
  113. package/src/runtime/fileLock.ts +19 -3
  114. package/src/runtime/invocationPolicy.js +1 -1
  115. package/src/runtime/invocationPolicy.ts +1 -1
  116. package/src/runtime/outputContract.js +0 -4
  117. package/src/runtime/outputContract.ts +0 -7
  118. package/src/session/runAgentTurn.js +3 -0
  119. package/src/session/runAgentTurn.ts +3 -0
  120. package/src/team/onlineToolContract.js +0 -1
  121. package/src/team/onlineToolContract.ts +0 -1
  122. package/src/tools/advisor/advisor.js +207 -365
  123. package/src/tools/advisor/advisor.ts +211 -383
  124. package/src/tools/agent/agentTypes.js +0 -1
  125. package/src/tools/agent/agentTypes.ts +0 -1
  126. package/src/tools/agent/coordinator.js +4 -1
  127. package/src/tools/agent/coordinator.ts +4 -1
  128. package/src/tools/bash/semantics.js +4 -5
  129. package/src/tools/bash/semantics.ts +4 -5
  130. package/src/tools/capabilities.js +2 -3
  131. package/src/tools/capabilities.ts +2 -4
  132. package/src/tools/index.js +0 -16
  133. package/src/tools/index.ts +0 -16
  134. package/src/tools/memory/maintenance.js +8 -9
  135. package/src/tools/memory/maintenance.ts +13 -11
  136. package/src/tools/memory/memory.js +5 -1
  137. package/src/tools/memory/memory.ts +5 -0
  138. package/src/tools/optimizeConversation/README.md +4 -6
  139. package/src/tools/toolPromptGuides.js +2 -9
  140. package/src/tools/toolPromptGuides.ts +2 -9
  141. package/src/types.ts +28 -105
  142. package/src/ui/App.js +2 -0
  143. package/src/ui/App.tsx +1 -0
  144. package/src/ui/MessageList.js +8 -44
  145. package/src/ui/MessageList.tsx +3 -81
  146. package/src/ui/hooks/useChat.js +2 -0
  147. package/src/ui/hooks/useChat.ts +2 -0
  148. package/src/ui/toolProgress.js +1 -1
  149. package/src/ui/toolProgress.ts +1 -1
  150. package/src/web/protocol.ts +10 -3
  151. package/src/advisor/advisorCoordinator.js +0 -734
  152. package/src/advisor/advisorCoordinator.ts +0 -825
  153. package/src/cli/wizards/sshHostWizard.js +0 -156
  154. package/src/cli/wizards/sshHostWizard.tsx +0 -214
  155. package/src/context/sshHosts.js +0 -290
  156. package/src/context/sshHosts.ts +0 -312
  157. package/src/loop/publication.js +0 -94
  158. package/src/loop/publication.ts +0 -124
  159. package/src/runtime/finalAnswer.js +0 -9
  160. package/src/runtime/finalAnswer.ts +0 -13
  161. package/src/tools/advisor/advisorDecision.js +0 -79
  162. package/src/tools/advisor/advisorDecision.ts +0 -92
  163. package/src/tools/ssh/ssh.js +0 -152
  164. package/src/tools/ssh/ssh.ts +0 -167
  165. package/src/tools/ssh/sshAvailability.js +0 -35
  166. package/src/tools/ssh/sshAvailability.ts +0 -39
  167. package/src/tools/ssh/sshClient.js +0 -177
  168. package/src/tools/ssh/sshClient.ts +0 -189
  169. package/src/tools/ssh/sshDownload.js +0 -77
  170. package/src/tools/ssh/sshDownload.ts +0 -92
  171. package/src/tools/ssh/sshHostsTool.js +0 -36
  172. package/src/tools/ssh/sshHostsTool.ts +0 -48
  173. package/src/tools/ssh/sshUpload.js +0 -72
  174. package/src/tools/ssh/sshUpload.ts +0 -85
package/src/loop.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { DELIVERY_CORRECTION_REMINDER, expectsFormalDelivery } from './loop/deliveryIntent.js';
2
- import { buildModelMessages, evictPriorVisionShots, injectVisionFollowups, mergeToolCallDelta, pushManualInterruptMarker, pushQueuedInputMarker, } from './loop/messages.js';
3
- import { publishFinalAnswer } from './loop/publication.js';
2
+ import { buildModelMessages, evictPriorVisionShots, injectVisionFollowups, mergeToolCallDelta, pushManualInterruptMarker, pushAdvisorFollowUpMarker, pushQueuedInputMarker, } from './loop/messages.js';
4
3
  import { prepareModelRequest } from './loop/requestPreparation.js';
5
4
  import { executeToolBatch } from './loop/toolBatch.js';
6
5
  import { createToolExecutor } from './loop/toolExecutor.js';
@@ -11,14 +10,16 @@ export { friendlyToolDescription } from './loop/toolPresentation.js';
11
10
  export { advisorSemanticsFor } from './loop/toolSemantics.js';
12
11
  /**
13
12
  * Agent turn orchestrator. Owns conversation state and continuation decisions.
14
- * Request preparation, tool execution/scheduling and final publication live in
15
- * ./loop/. Host options and LoopEvent remain the public compatibility boundary.
13
+ * Request preparation and tool execution/scheduling live in ./loop/; the
14
+ * final answer is published here, verbatim, once every delivery gate passes.
15
+ * Host options and LoopEvent remain the public compatibility boundary.
16
16
  * See ./loop/README.md for phase ownership and invariants.
17
17
  */
18
18
  import crypto from 'node:crypto';
19
19
  import { resolve } from 'node:path';
20
- import { AdvisorCoordinator } from './advisor/advisorCoordinator.js';
21
- import { collectReviewContext } from './advisor/reviewContext.js';
20
+ import { AdvisorNudge } from './advisor/advisorNudge.js';
21
+ import { collectAdvisorBackground } from './advisor/reviewContext.js';
22
+ import { previousUserRequest } from './memory/recallQuery.js';
22
23
  import { configService } from './config/configService.js';
23
24
  import { buildContextBudgetPlan } from './context/budgetPlan.js';
24
25
  import { getCompactThreshold } from './context/compact.js';
@@ -36,12 +37,13 @@ import { buildUserContent } from './multimodal.js';
36
37
  import { delegationRuntimeGuidance } from './prompts/delegation.js';
37
38
  import { capabilitiesForTools, enabledPolicyIds } from './prompts/policyRegistry.js';
38
39
  import { loadProjectPromptInstructions } from './prompts/projectInstructions.js';
39
- import { needsDiagramPreparation, visualPresentation } from './prompts/visualPresentation.js';
40
+ import { needsDiagramPreparation, requestKeepsTextDiagrams, visualPresentation } from './prompts/visualPresentation.js';
41
+ import { LiveDiagramGate } from './loop/liveDiagramGate.js';
40
42
  import { createAgentRuntimeContext } from './runtime/agentRuntimeContext.js';
41
43
  import { answerPreparationNotice } from './runtime/answerPreparation.js';
42
44
  import { invocationInstructions, invocationToolAllowed } from './runtime/invocationPolicy.js';
43
45
  import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
44
- import { resolveOutputContract, toolsForResponsePhase } from './runtime/outputContract.js';
46
+ import { resolveOutputContract } from './runtime/outputContract.js';
45
47
  import { filterToolsForProvider } from './team/toolPolicy.js';
46
48
  import { agentCoordinationContext, takeAgentNotifications } from './tools/agent/agentTaskService.js';
47
49
  import { needsInteractiveQuestion } from './tools/ask/routing.js';
@@ -55,6 +57,26 @@ import { hydrateSessionWorktree } from './tools/worktree/sessionWorktree.js';
55
57
  import { stripThinkBlocks } from './utils/stripThink.js';
56
58
  /** 主工具循环的默认轮数上限。 */
57
59
  export const DEFAULT_MAX_TURNS = 200;
60
+ /** Live streaming starts once real answer text exists. A leading inline
61
+ * <think> block (MiniMax and similar), even a tag still arriving in pieces,
62
+ * is not the answer and must not be shown as if it were. */
63
+ /** 显式选项优先;否则读配置 `advisor.enforcement`(只在需要补问时才读一次盘,读失败当作关闭)。 */
64
+ async function advisorFollowUpEnabled(options) {
65
+ if (options.advisorFollowUp !== undefined)
66
+ return options.advisorFollowUp;
67
+ try {
68
+ return (await configService.advisor())?.enforcement === 'followup';
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ function answerTextStarted(text) {
75
+ const lead = text.trimStart();
76
+ if (lead.length < '<think>'.length && '<think>'.startsWith(lead.toLowerCase()))
77
+ return false;
78
+ return stripThinkBlocks(text).length > 0;
79
+ }
58
80
  /**
59
81
  * 执行一次"用户输入 → 模型回答完成"的完整流程。
60
82
  *
@@ -128,7 +150,13 @@ export async function* runQuery(userInput, options) {
128
150
  let emptyResponseRecoveryAttempted = false;
129
151
  let emptyResponseRecoveryReason = '';
130
152
  let askCorrectionAttempted = false;
131
- let diagramPreparationAttempted = false;
153
+ let diagramCorrectionAttempted = false;
154
+ // The retired draft rides along with the next request only, as private
155
+ // revision material: never shown to the user, never stored in history.
156
+ let diagramCorrectionDraft = '';
157
+ // Part of that draft already streamed live (the text before the box): the
158
+ // model continues from there instead of rewriting what the user has read.
159
+ let diagramContinuation = false;
132
160
  const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
133
161
  const subagents = await configService.subagents();
134
162
  await hydrateSessionWorktree({
@@ -148,35 +176,12 @@ export async function* runQuery(userInput, options) {
148
176
  },
149
177
  history,
150
178
  sessionState,
151
- advisorReviewContext: () => ({
152
- ...collectReviewContext(advisorContextBundle),
153
- constraints: [
154
- ...collectReviewContext(advisorContextBundle).constraints,
155
- {
156
- id: 'execution.boundary',
157
- authority: 'host',
158
- content: JSON.stringify({
159
- cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
160
- conversationMode: toolCallContext.conversationMode,
161
- profile: runtimeContext.profile,
162
- availableTools: [...currentEffectiveToolSet.names],
163
- invocation: invocationInstructions(options.invocationPolicy),
164
- }),
165
- },
166
- ],
167
- omittedIds: [
168
- ...new Set([
169
- ...(currentTransientBundle?.diagnostics.omittedIds ?? []),
170
- ...(advisorContextBundle?.diagnostics.omittedIds ?? []),
171
- ]),
172
- ...(options.transientContext ? ['legacy.transient-context:not-forwarded'] : []),
173
- ],
174
- clippedIds: [
175
- ...new Set([
176
- ...(currentTransientBundle?.diagnostics.clippedIds ?? []),
177
- ...(advisorContextBundle?.diagnostics.clippedIds ?? []),
178
- ]),
179
- ],
179
+ // Project constraints and memory are not in the conversation history, so the
180
+ // advisor gets them as one background block (see tools/advisor/advisor.ts).
181
+ advisorBackground: () => collectAdvisorBackground(advisorContextBundle, {
182
+ cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
183
+ availableTools: [...currentEffectiveToolSet.names],
184
+ invocation: invocationInstructions(options.invocationPolicy),
180
185
  }),
181
186
  // Statistics only; consultations have no per-turn quota.
182
187
  advisorCalls: { used: 0 },
@@ -222,20 +227,20 @@ export async function* runQuery(userInput, options) {
222
227
  options.invocationPolicy?.delivery !== 'caller' &&
223
228
  effectiveToolSet.names.has('PresentResult');
224
229
  let currentEffectiveToolSet = effectiveToolSet;
225
- const advisorCoordinator = new AdvisorCoordinator({
226
- history,
230
+ // The host only reminds; whether to consult the advisor is the model's call
231
+ // (see advisor/advisorNudge.ts). Nothing here blocks a tool or holds an answer.
232
+ const advisorNudge = new AdvisorNudge({
227
233
  currentRequest: userInput,
228
- currentRequestMessageId: currentUserMessageId,
229
- advisorAvailable: effectiveToolSet.names.has('Advisor'),
234
+ previousRequest: previousUserRequest(history),
235
+ advisorAvailable: effectiveToolSet.names.has('Advisor') && options.advisorReminders !== false,
230
236
  });
231
- toolCallContext.advisorRuntime = advisorCoordinator;
232
237
  let coordinationText = '';
233
238
  let delegationCorrectionAttempted = false;
234
239
  // 组装每轮请求的易变上下文。三个构建点(首轮 / autoCompact 后 / snip 后)共用,
235
240
  // 避免 Advisor 相关提醒在复制粘贴中三处漂移。提醒均为 transient:只进当轮请求副本,
236
241
  // 不进会话历史(与 DELIVERY_CORRECTION_REMINDER 同构,见 buildModelMessages)。
237
242
  const buildRequestTransientContext = () => {
238
- const advisorAvailable = currentEffectiveToolSet.names.has('Advisor');
243
+ const advisorAvailable = currentEffectiveToolSet.names.has('Advisor') && options.advisorReminders !== false;
239
244
  const parts = [
240
245
  transientFor(activeTools),
241
246
  coordinationText,
@@ -245,7 +250,11 @@ export async function* runQuery(userInput, options) {
245
250
  callsUsed: toolCallContext.agentCalls?.used ?? 0,
246
251
  maxCalls: toolCallContext.agentCalls?.max ?? 0,
247
252
  }),
248
- diagramPreparationAttempted ? visualPresentation(output) : '',
253
+ diagramCorrectionDraft
254
+ ? `${visualPresentation(output)}\n${diagramContinuation
255
+ ? `你这版回答已向用户显示到字符框图之前(已保存为上一条消息,不能修改或撤回),框图及其后内容用户尚未看到。请紧接着已显示的内容继续写:先用 ${output.diagrams.join('/')} 重画那张框图,再按下方草稿写完其余部分,保留结论、数字、限定条件和链接;不要重复已显示的内容。`
256
+ : `你上一版回答(下方内部草稿,用户尚未看到)用字符画了框图,而当前载体可渲染 ${output.diagrams.join('/')}。请在这版草稿的基础上修订:只把字符框图改成这些格式,保留其余结论、数字、限定条件和链接。`}技能目录里有图形技能时可先加载。\n<internal-draft>\n${diagramCorrectionDraft}\n</internal-draft>`
257
+ : '',
249
258
  delegationCorrectionAttempted
250
259
  ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。'
251
260
  : '',
@@ -256,8 +265,7 @@ export async function* runQuery(userInput, options) {
256
265
  ? `上一次模型响应没有可交付正文(${emptyResponseRecoveryReason})。请基于已有历史和工具结果继续完成任务:信息充分则给出实质结论,不充分则继续必要操作或明确缺口。不要重复已经完成的调查或有副作用的操作;不要把未完成的任务说成完成。已有顾问意见继续有效,无需仅因恢复而再次咨询。`
257
266
  : '',
258
267
  deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
259
- advisorAvailable ? advisorCoordinator.transientReminder() : '',
260
- advisorAvailable ? advisorCoordinator.usageLine(toolCallContext.advisorCalls.used) : '',
268
+ advisorAvailable ? advisorNudge.reminder() : '',
261
269
  ];
262
270
  return parts.filter(Boolean).join('\n\n');
263
271
  };
@@ -524,7 +532,7 @@ export async function* runQuery(userInput, options) {
524
532
  replaceHistory,
525
533
  buildRequestTransientContext: (tools) => {
526
534
  currentEffectiveToolSet = tools;
527
- advisorCoordinator.setAvailable(tools.names.has('Advisor'));
535
+ advisorNudge.setAvailable(tools.names.has('Advisor') && options.advisorReminders !== false);
528
536
  lastRequestTransientContext = buildRequestTransientContext();
529
537
  return lastRequestTransientContext;
530
538
  },
@@ -545,7 +553,7 @@ export async function* runQuery(userInput, options) {
545
553
  let assistantText = '';
546
554
  // Candidate text is private until the delivery gates allow publication.
547
555
  // Keep the compatibility event as a phase notice, never as a text retraction.
548
- function* retireDraft(reason) {
556
+ function* preparationNotice(reason) {
549
557
  const message = {
550
558
  role: 'system',
551
559
  content: answerPreparationNotice(reason),
@@ -556,6 +564,9 @@ export async function* runQuery(userInput, options) {
556
564
  };
557
565
  history.push(message);
558
566
  yield { type: 'injected_message', message };
567
+ }
568
+ function* retireDraft(reason) {
569
+ yield* preparationNotice(reason);
559
570
  if (reason !== 'interrupted')
560
571
  yield { type: 'assistant_draft_discarded', reason, textLength: 0 };
561
572
  }
@@ -568,22 +579,80 @@ export async function* runQuery(userInput, options) {
568
579
  let reasoningContent = '';
569
580
  let reasoningString = '';
570
581
  const reasoningDetails = [];
582
+ /** Visible text is never retracted: whatever already streamed live is
583
+ * committed before the turn moves on (interruption, error, new messages). */
584
+ function* commitLiveAnswer(presentation) {
585
+ const message = {
586
+ role: 'assistant',
587
+ // Only what the user actually saw: a box held back by the diagram gate
588
+ // must not slip into history through an interruption.
589
+ content: assistantText.slice(0, shownLength),
590
+ ...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
591
+ ...(reasoningString ? { reasoning: reasoningString } : {}),
592
+ ...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}),
593
+ timestamp: Date.now(),
594
+ id: crypto.randomUUID(),
595
+ presentation,
596
+ ...(options.messageProvenance ? { provenance: options.messageProvenance } : {}),
597
+ };
598
+ history.push(message);
599
+ yield { type: 'assistant_message', message };
600
+ }
571
601
  /**
572
602
  * R2:本轮 LLM 流的终止原因(来自 client 的 done.stopReason)。
573
603
  * 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
574
604
  * 避免「半截答案」被静默当成正常 end_turn。
575
605
  */
576
606
  let lastFinishReason = 'unknown';
607
+ // Decided before the model writes anything: can a check still hold this
608
+ // answer back? If not, it is irrevocable by construction and streams live
609
+ // (visible text is never retracted). If one can — a required formal delivery
610
+ // or an unresolved delegation — the candidate stays private until it passes
611
+ // and is then published verbatim. Streaming everything and retracting on a
612
+ // later check showed users drafts that vanished; buffering everything made
613
+ // every answer appear as one block. The advisor is deliberately not a holder
614
+ // any more: it is a reminder, so an answer never waits for it.
615
+ const liveEligible = !options.responseFormat &&
616
+ runtimeContext.profile === 'main' &&
617
+ output.finalDelivery === 'stream' &&
618
+ !(formalDeliveryRequired && !resultPresented && !deliveryCorrectionAttempted);
619
+ // Only decides how the answer is shown: unreadable delegation state means
620
+ // "hold it back", never a failed turn (the final gate re-reads it anyway).
621
+ const delegationMayHold = liveEligible &&
622
+ !delegationCorrectionAttempted &&
623
+ currentEffectiveToolSet.names.has('Agent') &&
624
+ (await agentCoordinationContext(toolCallContext).then((coordination) => coordination.unresolved.some((record) => record.parentMessageId === currentUserMessageId), () => true));
625
+ const liveFinal = liveEligible && !delegationMayHold;
626
+ let liveStarted = false;
627
+ let shownLength = 0;
628
+ // Live answers on a diagram-rendering surface hold back a fence that could
629
+ // be a text drawing until it closes, so a box can be corrected without
630
+ // retracting anything (see loop/liveDiagramGate.ts).
631
+ const diagramGate = liveFinal && output.diagrams.length > 0 && !diagramCorrectionAttempted && !requestKeepsTextDiagrams(userInput)
632
+ ? new LiveDiagramGate()
633
+ : undefined;
634
+ function* releaseLive(final = false) {
635
+ const end = diagramGate ? diagramGate.releasable(assistantText, final) : assistantText.length;
636
+ if (!liveStarted) {
637
+ if (!answerTextStarted(assistantText.slice(0, end)))
638
+ return;
639
+ liveStarted = true;
640
+ yield { type: 'stage_change', stage: 'streaming' };
641
+ }
642
+ if (end > shownLength) {
643
+ yield { type: 'text', delta: assistantText.slice(shownLength, end) };
644
+ shownLength = end;
645
+ }
646
+ }
577
647
  // 进入 LLM 流前:等首 token / reasoning,本质是 thinking
578
648
  yield { type: 'stage_change', stage: 'thinking' };
579
649
  workStarted = true;
580
650
  try {
581
651
  const modelMessages = buildModelMessages(history, requestTransientContext, userMessage.id);
582
- advisorCoordinator.markTransientDelivered();
583
652
  for await (const ev of chatFn({
584
653
  provider,
585
654
  messages: modelMessages,
586
- tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'preparation'),
655
+ tools: currentEffectiveToolSet.tools,
587
656
  signal,
588
657
  conversationMode: runtimeContext.conversationMode ?? 'private',
589
658
  promptCache: options.promptCache,
@@ -594,6 +663,8 @@ export async function* runQuery(userInput, options) {
594
663
  })) {
595
664
  if (ev.type === 'text_delta') {
596
665
  assistantText += ev.delta;
666
+ if (liveFinal)
667
+ yield* releaseLive();
597
668
  }
598
669
  else if (ev.type === 'tool_call_delta') {
599
670
  mergeToolCallDelta(toolCallsByIndex, ev);
@@ -648,14 +719,19 @@ export async function* runQuery(userInput, options) {
648
719
  catch (e) {
649
720
  trace('model_error');
650
721
  if (e.name === 'AbortError') {
651
- yield* retireDraft('interrupted');
722
+ if (liveStarted) {
723
+ yield* commitLiveAnswer('final');
724
+ yield { type: 'warning', message: '回答未完整生成,已保留已显示的内容;未撤回或重新生成。' };
725
+ }
726
+ else
727
+ yield* retireDraft('interrupted');
652
728
  yield interruption();
653
729
  return;
654
730
  }
655
731
  // prompt_too_long 反应式自救:压缩历史 → 同 turn 重发 LLM
656
732
  // LLM 看到压缩后的 9 段摘要 + 近期 verbatim 消息,能接着干活,
657
- // 不会丢失中途的工具调用上下文。
658
- if (isPromptTooLongError(e) && reactiveCount < MAX_REACTIVE_PER_QUERY) {
733
+ // 不会丢失中途的工具调用上下文。已实时显示过文字时不重发(会重复输出)。
734
+ if (!liveStarted && isPromptTooLongError(e) && reactiveCount < MAX_REACTIVE_PER_QUERY) {
659
735
  reactiveCount++;
660
736
  yield { type: 'compact_start' };
661
737
  yield { type: 'stage_change', stage: 'compacting' };
@@ -695,7 +771,12 @@ export async function* runQuery(userInput, options) {
695
771
  // reactive 没压动或未恢复 → 退回正常错误路径
696
772
  }
697
773
  // code='llm_error':终结性错误,stop_reason='error'
698
- yield* retireDraft('interrupted');
774
+ if (liveStarted) {
775
+ yield* commitLiveAnswer('final');
776
+ yield { type: 'warning', message: '回答未完整生成,已保留已显示的内容;未撤回或重新生成。' };
777
+ }
778
+ else
779
+ yield* retireDraft('interrupted');
699
780
  yield {
700
781
  type: 'error',
701
782
  error: `LLM 调用失败:${e.message}`,
@@ -704,8 +785,20 @@ export async function* runQuery(userInput, options) {
704
785
  return;
705
786
  }
706
787
  trace('model_end');
788
+ // A tool round (e.g. loading a graphics skill) is part of the revision;
789
+ // the draft is dropped only once the revised answer itself has arrived.
790
+ if (toolCallsByIndex.length === 0) {
791
+ diagramCorrectionDraft = '';
792
+ diagramContinuation = false;
793
+ }
707
794
  if (lastFinishReason === 'aborted' || signal?.aborted || options.hasQueuedInput?.()) {
708
- yield* retireDraft('interrupted');
795
+ if (liveStarted) {
796
+ yield* commitLiveAnswer('final');
797
+ if (lastFinishReason !== 'stop')
798
+ yield { type: 'warning', message: '回答未完整生成,已保留已显示的内容;未撤回或重新生成。' };
799
+ }
800
+ else
801
+ yield* retireDraft('interrupted');
709
802
  if (options.hasQueuedInput?.() && !signal?.aborted) {
710
803
  pushQueuedInputMarker(history);
711
804
  yield { type: 'interrupted', reason: 'queued_input' };
@@ -725,35 +818,11 @@ export async function* runQuery(userInput, options) {
725
818
  toolCallsByIndex.push(...dense);
726
819
  }
727
820
  }
728
- // A pending review must run before another solving/publishing attempt.
729
- // Read-only diagnosis and human questions remain available while stalled.
730
- const pendingReview = options.advisorCheckpoints === false ? undefined : advisorCoordinator.pendingReview();
731
- if (pendingReview &&
732
- turn < maxTurns &&
733
- toolCallsByIndex.length > 0 &&
734
- !toolCallsByIndex.some((tc) => ['Advisor', 'AskUserQuestion'].includes(tc.function.name)) &&
735
- toolCallsByIndex.some((tc) => tc.function.name === 'PresentResult' ||
736
- advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((t) => t.name === tc.function.name), { treatVerificationAsReadOnly: true }).mutatesWorkspace ||
737
- stallGuard.shouldReject(tc))) {
738
- yield* retireDraft('advisor_checkpoint');
739
- toolCallsByIndex.splice(0, toolCallsByIndex.length, {
740
- id: crypto.randomUUID(),
741
- type: 'function',
742
- function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) },
743
- });
744
- assistantText = '';
745
- }
746
- // Count the calls that survived review scheduling, including failed loads.
747
- for (const call of toolCallsByIndex) {
748
- if (call.function.name !== 'Skill')
749
- continue;
750
- try {
751
- if (JSON.parse(call.function.arguments).name === 'dynamic-ui')
752
- diagramPreparationAttempted = true;
753
- }
754
- catch {
755
- /* Ordinary tool validation reports malformed arguments. */
756
- }
821
+ if (liveFinal) {
822
+ // Narration before a tool round, or no turn left to revise: show it all.
823
+ if (diagramGate?.hit !== undefined && (toolCallsByIndex.length > 0 || turn >= maxTurns))
824
+ diagramGate.release();
825
+ yield* releaseLive(true);
757
826
  }
758
827
  const assistantMsg = {
759
828
  role: 'assistant',
@@ -768,29 +837,41 @@ export async function* runQuery(userInput, options) {
768
837
  };
769
838
  // 4. 没有工具调用 → 整轮交互结束
770
839
  finalResponse: if (toolCallsByIndex.length === 0) {
771
- // Repair only private preparation, through the ordinary tool pipeline.
772
- // One attempt per turn, including missing/failed skills; never rewind a reply.
773
- if (!diagramPreparationAttempted &&
840
+ if (diagramGate?.hit !== undefined) {
841
+ diagramCorrectionAttempted = true;
842
+ diagramCorrectionDraft = assistantText;
843
+ if (liveStarted) {
844
+ // The text before the box stays exactly as shown, as its own message.
845
+ diagramContinuation = true;
846
+ yield* commitLiveAnswer('progress');
847
+ yield* preparationNotice('diagram_preparation');
848
+ }
849
+ else
850
+ yield* retireDraft('diagram_preparation');
851
+ continue;
852
+ }
853
+ // A boxed ASCII diagram on a surface that renders real diagrams: retire the
854
+ // private draft once and let the model revise it, with the draft passed
855
+ // back as internal material (asking it to "keep the rest" without showing
856
+ // the rest made it answer from scratch). The kernel names no skill and
857
+ // never writes a tool call the model did not make — it used to inject
858
+ // Skill("dynamic-ui") into history under the model's name. Content checks
859
+ // like this one and the ask correction apply only to private drafts; an
860
+ // answer that already streamed live is never retracted.
861
+ if (!liveStarted &&
862
+ !diagramCorrectionAttempted &&
774
863
  !options.responseFormat &&
775
864
  runtimeContext.profile === 'main' &&
776
865
  output.diagrams.length > 0 &&
777
- currentEffectiveToolSet.names.has('Skill') &&
778
866
  turn < maxTurns &&
779
867
  needsDiagramPreparation(assistantText, userInput)) {
780
- diagramPreparationAttempted = true;
781
- toolCallsByIndex.push({
782
- id: crypto.randomUUID(),
783
- type: 'function',
784
- function: {
785
- name: 'Skill',
786
- arguments: JSON.stringify({ name: 'dynamic-ui' }),
787
- },
788
- });
789
- assistantMsg.content = null;
790
- assistantMsg.tool_calls = toolCallsByIndex;
791
- break finalResponse;
868
+ diagramCorrectionAttempted = true;
869
+ diagramCorrectionDraft = assistantText;
870
+ yield* retireDraft('diagram_preparation');
871
+ continue;
792
872
  }
793
- if (!askCorrectionAttempted &&
873
+ if (!liveStarted &&
874
+ !askCorrectionAttempted &&
794
875
  runtimeContext.interactiveAsk &&
795
876
  currentEffectiveToolSet.names.has('AskUserQuestion') &&
796
877
  needsInteractiveQuestion(stripThinkBlocks(assistantText)) &&
@@ -802,7 +883,7 @@ export async function* runQuery(userInput, options) {
802
883
  const emptyResponse = stripThinkBlocks(assistantText).length === 0;
803
884
  const coordination = runtimeContext.profile === 'main' ? await agentCoordinationContext(toolCallContext) : undefined;
804
885
  const pendingDelegations = coordination?.unresolved.filter((record) => record.parentMessageId === currentUserMessageId) ?? [];
805
- if (pendingDelegations.length && !delegationCorrectionAttempted && turn < maxTurns) {
886
+ if (!liveStarted && pendingDelegations.length && !delegationCorrectionAttempted && turn < maxTurns) {
806
887
  delegationCorrectionAttempted = true;
807
888
  coordinationText = coordination.text;
808
889
  yield* retireDraft('formal_delivery');
@@ -813,13 +894,18 @@ export async function* runQuery(userInput, options) {
813
894
  ...(await takeAgentNotifications(toolCallContext)),
814
895
  ];
815
896
  if (pendingMessages.length) {
897
+ // A private draft is simply superseded; a live one was already read,
898
+ // so it stays in the conversation as this step's progress.
899
+ if (liveStarted)
900
+ yield* commitLiveAnswer('progress');
816
901
  history.push(...pendingMessages);
817
902
  continue;
818
903
  }
819
904
  // A model occasionally writes a path/link in prose and stops without formally
820
905
  // presenting it. Give it one model-only corrective pass; the reminder is
821
906
  // transient and therefore never pollutes persisted conversation history.
822
- if (!emptyResponse &&
907
+ if (!liveStarted &&
908
+ !emptyResponse &&
823
909
  formalDeliveryRequired &&
824
910
  !resultPresented &&
825
911
  !deliveryCorrectionAttempted &&
@@ -828,37 +914,21 @@ export async function* runQuery(userInput, options) {
828
914
  yield* retireDraft('formal_delivery');
829
915
  continue;
830
916
  }
831
- const deliveryCheckpoint = emptyResponse || options.advisorCheckpoints === false
832
- ? { allow: true }
833
- : advisorCoordinator.beforeDelivery(assistantText);
834
- const fallbackReview = !emptyResponse && options.advisorCheckpoints !== false
835
- ? advisorCoordinator.pendingReview(assistantText)
836
- : undefined;
837
- if (fallbackReview && turn < maxTurns) {
838
- yield* retireDraft('advisor_checkpoint');
839
- // Reuse the effective tool boundary, permissions, cancellation, events and
840
- // persistence. Never call a provider behind the host's tool pipeline.
841
- toolCallsByIndex.push({
842
- id: crypto.randomUUID(),
843
- type: 'function',
844
- function: {
845
- name: 'Advisor',
846
- arguments: JSON.stringify(fallbackReview),
847
- },
848
- });
849
- assistantMsg.content = null;
850
- assistantMsg.tool_calls = toolCallsByIndex;
851
- break finalResponse;
852
- }
853
- if (!deliveryCheckpoint.allow && turn < maxTurns) {
854
- yield* retireDraft('advisor_checkpoint');
855
- continue;
856
- }
917
+ let advisorFollowUp = false;
857
918
  if (!emptyResponse) {
858
- for (const message of advisorCoordinator.deliveryWarnings())
859
- yield { type: 'warning', message };
860
- if (!deliveryCheckpoint.allow)
861
- yield { type: 'warning', message: '已达到本轮执行上限,顾问交付检查尚未完成;不能把此次输出视为已复核。' };
919
+ // A reminder, never a gate: the answer is already on its way to the user.
920
+ // With enforcement 'followup' (weak models that never ask on their own) an
921
+ // unreviewed substantive answer earns one more request in which the model is
922
+ // asked to call Advisor itself; the warning is then left for that pass.
923
+ if (turn < maxTurns && advisorNudge.followUpDue(assistantText) && (await advisorFollowUpEnabled(options))) {
924
+ advisorNudge.markFollowedUp();
925
+ advisorFollowUp = true;
926
+ }
927
+ else {
928
+ const unreviewed = advisorNudge.unreviewedWarning(assistantText);
929
+ if (unreviewed)
930
+ yield { type: 'warning', message: unreviewed };
931
+ }
862
932
  }
863
933
  if (emptyResponse) {
864
934
  // Recover once within the existing turn budget, retaining tool receipts and
@@ -886,86 +956,26 @@ export async function* runQuery(userInput, options) {
886
956
  yield* retireDraft('empty_response');
887
957
  }
888
958
  else {
889
- // Publication is a separate tool-free request. Nothing from preparation
890
- // has been streamed, and no gate may retract text after this boundary.
891
- let publicationError;
892
- let publicationInterrupted = false;
893
- let streamed = false;
894
- if (!options.responseFormat && runtimeContext.profile === 'main' && output.finalDelivery === 'stream') {
895
- const publication = yield* publishFinalAnswer({
896
- provider,
897
- messages: buildModelMessages(history, requestTransientContext, userMessage.id),
898
- candidate: assistantText,
899
- candidateFinishReason: lastFinishReason,
900
- output,
901
- chatFn,
902
- options,
903
- signal,
904
- conversationMode: runtimeContext.conversationMode ?? 'private',
905
- turnUsage,
906
- });
907
- const publishedText = publication.text;
908
- ({ publicationError, publicationInterrupted, streamed, lastFinishReason } = publication);
909
- assistantText = publishedText;
910
- assistantMsg.content = publishedText || null;
911
- if (!publishedText) {
912
- if (publicationInterrupted)
913
- yield {
914
- ...interruption(),
915
- usage: {
916
- ...turnUsage,
917
- trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
918
- },
919
- };
920
- else
921
- yield {
922
- type: 'error',
923
- code: 'llm_error',
924
- error: `最终答案生成失败:${String(publicationError)}`,
925
- usage: {
926
- ...turnUsage,
927
- trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
928
- },
929
- };
930
- return;
931
- }
932
- }
959
+ // Publication is a one-way boundary: the text that passed every gate is
960
+ // exactly what the user sees and what history keeps — streamed live
961
+ // when nothing could hold it back, otherwise published verbatim now.
962
+ // It used to be regenerated by a second tool-free request: a third
963
+ // text no gate had seen, at the cost of a full-context call.
964
+ if (!liveStarted && !options.responseFormat && runtimeContext.profile === 'main' && output.finalDelivery === 'stream')
965
+ yield { type: 'stage_change', stage: 'streaming' };
933
966
  assistantMsg.presentation = 'final';
934
967
  trace('final_published');
935
- if (assistantMsg.role === 'assistant')
936
- assistantMsg.advisorTaskVersion = advisorCoordinator.versions.taskVersion;
937
- assistantMsg.advisorCheckpoints = advisorCoordinator.drainDegradations();
938
968
  history.push(assistantMsg);
939
- // Non-interactive consumers retain atomic output. Interactive hosts
940
- // already received real deltas; commit the identical text only once.
941
- if (!streamed)
969
+ if (!liveStarted)
942
970
  yield { type: 'text', delta: assistantText };
943
971
  yield {
944
972
  type: 'assistant_message',
945
973
  message: assistantMsg,
946
974
  };
947
- if (publicationInterrupted || publicationError) {
948
- yield { type: 'warning', message: '最终答案未完整生成,已保留已输出内容;未撤回或重新生成。' };
949
- if (publicationInterrupted)
950
- yield {
951
- ...interruption(),
952
- usage: {
953
- ...turnUsage,
954
- trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
955
- },
956
- };
957
- else
958
- yield {
959
- type: 'error',
960
- code: 'llm_error',
961
- error: `最终答案流中断:${String(publicationError)}`,
962
- usage: {
963
- ...turnUsage,
964
- trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
965
- },
966
- };
967
- return;
968
- }
975
+ }
976
+ if (advisorFollowUp) {
977
+ pushAdvisorFollowUpMarker(history);
978
+ continue;
969
979
  }
970
980
  // R2:最终回答被 provider 因 max_tokens 截断(finish_reason='length')时,
971
981
  // 不能静默当正常 end_turn 收尾 —— 否则 -p 宿主会拿到半截答案却以为成功。
@@ -1077,7 +1087,7 @@ export async function* runQuery(userInput, options) {
1077
1087
  runtimeContext,
1078
1088
  toolCallContext,
1079
1089
  currentEffectiveToolSet,
1080
- advisorCoordinator,
1090
+ advisorNudge,
1081
1091
  stallGuard,
1082
1092
  modelEnvironmentCwd,
1083
1093
  observedRuleScopes,
@@ -1090,9 +1100,7 @@ export async function* runQuery(userInput, options) {
1090
1100
  // latest evidence and versions. Parallel batches commit in call order once
1091
1101
  // settled. Both paths use this single writer, avoiding duplicate observation.
1092
1102
  const commitOutcome = async function* ({ tc, result }) {
1093
- if (tc.function.name !== 'Advisor' && tc.function.name !== 'AdvisorDecision') {
1094
- advisorCoordinator.observeToolResult(tc, result, advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name), { treatVerificationAsReadOnly: true }));
1095
- }
1103
+ advisorNudge.observeToolResult(tc, result, advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name), { treatVerificationAsReadOnly: true }));
1096
1104
  if (result.ok || result.disposition !== 'deferred') {
1097
1105
  evidenceLedger.record(tc.function.name, tc.function.arguments, result);
1098
1106
  }
@@ -1108,6 +1116,7 @@ export async function* runQuery(userInput, options) {
1108
1116
  const manifest = result.metadata?.kind === 'result_manifest' ? result.metadata.manifest : browserArtifact;
1109
1117
  presentedResultUnverified ||= manifest?.files.some((file) => file.verification === 'failed') ?? false;
1110
1118
  }
1119
+ const actionHint = advisorNudge.takeActionHint();
1111
1120
  const rawContent = result.ok ? result.content : `Error: ${result.error}`;
1112
1121
  const basePolicy = result.policy ?? getToolResultPolicy(tc.function.name);
1113
1122
  // 超大结果先归档再压缩:归档失败时保留原文,绝不因为磁盘问题破坏上下文。
@@ -1129,9 +1138,10 @@ export async function* runQuery(userInput, options) {
1129
1138
  ? { ...basePolicy, archiveRef: archived.archiveRef, originalChars: archived.originalChars }
1130
1139
  : basePolicy;
1131
1140
  // 微压缩仅对成功结果走;policy 会保护高价值结果,避免不可逆中段丢失。
1132
- const content = result.ok
1141
+ const compacted = result.ok
1133
1142
  ? microCompact(tc.function.name, archived.content, sessionState?.microCompact, policy)
1134
1143
  : rawContent;
1144
+ const content = actionHint ? `${compacted}\n\n${actionHint}` : compacted;
1135
1145
  history.push({
1136
1146
  role: 'tool',
1137
1147
  display_ok: result.ok,
@@ -1139,8 +1149,6 @@ export async function* runQuery(userInput, options) {
1139
1149
  tool_call_id: tc.id,
1140
1150
  tool_result_policy: policy,
1141
1151
  ...(!result.ok && result.disposition === 'deferred' ? { toolResultDisposition: 'deferred' } : {}),
1142
- ...(tc === toolCallsByIndex.at(-1) ? { advisorCheckpoints: advisorCoordinator.drainDegradations() } : {}),
1143
- advisorMutatesWorkspace: advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find((tool) => tool.name === tc.function.name), { treatVerificationAsReadOnly: true }).mutatesWorkspace,
1144
1152
  ...(result.metadata ? { display_metadata: result.metadata } : {}),
1145
1153
  timestamp: Date.now(),
1146
1154
  id: crypto.randomUUID(),
@@ -1182,7 +1190,7 @@ export async function* runQuery(userInput, options) {
1182
1190
  ? '同一问题两次尝试失败,请停止原样重试;顾问可用时,继续解决或交付前先咨询,由主 Agent 自行核验和决策。只读诊断仍可继续。'
1183
1191
  : '检测到连续工具轮次没有产生新文本或新结果;请切换路径或咨询 Advisor。';
1184
1192
  if (stall.reason !== 'repeated_failure')
1185
- advisorCoordinator.notifyStalled(stall.signature ?? stall.reason);
1193
+ advisorNudge.notifyStalled(stall.signature ?? stall.reason ?? 'no_progress');
1186
1194
  yield { type: 'warning', message };
1187
1195
  }
1188
1196
  // 关键节点截图回填:把工具附带的实况截图作为一条带图 user 消息喂给(视觉)模型自我核验,