minimal-agent 0.18.6 → 0.18.8

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 (84) 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/persistContext.js +5 -1
  26. package/src/context/persistContext.ts +5 -2
  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 +226 -31
  32. package/src/loop.ts +205 -31
  33. package/src/memory/fileStore.js +2 -34
  34. package/src/memory/fileStore.ts +2 -26
  35. package/src/prompts/policyRegistry.js +13 -9
  36. package/src/prompts/policyRegistry.ts +15 -9
  37. package/src/prompts/promptAssembly.js +16 -23
  38. package/src/prompts/promptAssembly.ts +22 -27
  39. package/src/prompts/visualPresentation.js +28 -0
  40. package/src/prompts/visualPresentation.ts +26 -0
  41. package/src/runtime/agentRuntimeContext.js +3 -0
  42. package/src/runtime/agentRuntimeContext.ts +4 -0
  43. package/src/runtime/answerPreparation.js +9 -0
  44. package/src/runtime/answerPreparation.ts +11 -0
  45. package/src/runtime/eventSemantics.js +2 -0
  46. package/src/runtime/eventSemantics.ts +2 -0
  47. package/src/runtime/fileLock.js +75 -0
  48. package/src/runtime/fileLock.ts +73 -0
  49. package/src/runtime/finalAnswer.js +9 -0
  50. package/src/runtime/finalAnswer.ts +13 -0
  51. package/src/runtime/lifecycleTrace.js +16 -0
  52. package/src/runtime/lifecycleTrace.ts +22 -0
  53. package/src/runtime/outputContract.js +17 -0
  54. package/src/runtime/outputContract.ts +34 -0
  55. package/src/session/runAgentTurn.js +7 -2
  56. package/src/session/runAgentTurn.ts +7 -2
  57. package/src/session/runTurn.js +15 -1
  58. package/src/session/runTurn.ts +13 -1
  59. package/src/session/runtimeStore.js +11 -28
  60. package/src/session/runtimeStore.ts +9 -23
  61. package/src/tools/advisor/advisor.js +56 -40
  62. package/src/tools/advisor/advisor.ts +57 -39
  63. package/src/tools/advisor/advisorDecision.js +6 -3
  64. package/src/tools/advisor/advisorDecision.ts +6 -3
  65. package/src/tools/agent/agent.js +14 -6
  66. package/src/tools/agent/agent.ts +14 -5
  67. package/src/tools/bash/semantics.js +91 -26
  68. package/src/tools/bash/semantics.ts +63 -33
  69. package/src/tools/index.js +4 -1
  70. package/src/tools/index.ts +4 -1
  71. package/src/tools/skill/searchSkills.js +2 -2
  72. package/src/tools/skill/searchSkills.ts +2 -2
  73. package/src/tools/skill/skill.js +4 -0
  74. package/src/tools/skill/skill.ts +3 -0
  75. package/src/types.ts +19 -6
  76. package/src/ui/LiveArea.js +2 -1
  77. package/src/ui/LiveArea.tsx +2 -1
  78. package/src/ui/MessageList.js +18 -5
  79. package/src/ui/MessageList.tsx +25 -3
  80. package/src/ui/draftNotice.js +1 -8
  81. package/src/ui/draftNotice.ts +1 -10
  82. package/src/ui/hooks/useChat.js +25 -1
  83. package/src/ui/hooks/useChat.ts +22 -1
  84. package/src/web/protocol.ts +3 -1
package/src/loop.js CHANGED
@@ -27,6 +27,11 @@
27
27
  * ============================================================
28
28
  */
29
29
  import crypto from 'node:crypto';
30
+ import { answerPreparationNotice } from './runtime/answerPreparation.js';
31
+ import { finalAnswerMessages } from './runtime/finalAnswer.js';
32
+ import { resolveOutputContract, toolsForResponsePhase } from './runtime/outputContract.js';
33
+ import { needsDiagramPreparation, visualPresentation } from './prompts/visualPresentation.js';
34
+ import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
30
35
  import { conciseToolPath } from './runtime/toolDisplay.js';
31
36
  import { dirname, resolve } from 'node:path';
32
37
  import { loadProjectPromptInstructions } from './prompts/projectInstructions.js';
@@ -59,6 +64,7 @@ import { validateFinalClaims } from './harness/claimValidation.js';
59
64
  import { AdvisorCoordinator, } from './advisor/advisorCoordinator.js';
60
65
  import { toolEffectFor } from './tools/capabilities.js';
61
66
  import { isReadOnlyShellCommand, isVerificationShellCommand } from './tools/bash/semantics.js';
67
+ import { collectReviewContext } from './advisor/reviewContext.js';
62
68
  import { createAgentRuntimeContext, } from './runtime/agentRuntimeContext.js';
63
69
  import { stripThinkBlocks } from './utils/stripThink.js';
64
70
  import { orderedPool } from './runtime/orderedPool.js';
@@ -203,10 +209,12 @@ export async function* runQuery(userInput, options) {
203
209
  delete sessionState.computerAutomation;
204
210
  // DI fallback:测试可传 stub,生产走真实模块单例
205
211
  const chatFn = options.chat ?? defaultChat;
212
+ const trace = createLifecycleTrace('loop');
206
213
  const executeToolFn = options.executeTool ?? defaultExecuteTool;
207
214
  const explicitFileDeliveryRequested = expectsFormalDelivery(userInput);
208
215
  const currentUserMessageId = options.userMessageId ?? crypto.randomUUID();
209
216
  let currentTransientBundle = options.transientBundle;
217
+ let advisorContextBundle = currentTransientBundle;
210
218
  let modelEnvironmentCwd = runtimeContext.cwd;
211
219
  const observedRuleScopes = new Map();
212
220
  const pendingRuleScopes = new Set();
@@ -225,12 +233,15 @@ export async function* runQuery(userInput, options) {
225
233
  const selected = availableForBundle >= bundle.diagnostics.usedTokens
226
234
  ? bundle
227
235
  : resizeContextBundle(bundle, availableForBundle);
236
+ advisorContextBundle = selected;
228
237
  return [selected.text, opaque].filter(Boolean).join('\n\n');
229
238
  };
230
239
  let resultPresented = false;
231
240
  let presentedResultUnverified = false;
232
241
  let deliveryCorrectionAttempted = false;
233
242
  let askCorrectionAttempted = false;
243
+ let diagramPreparationAttempted = false;
244
+ const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
234
245
  const subagents = await configService.subagents();
235
246
  await hydrateSessionWorktree({
236
247
  sessionState,
@@ -239,9 +250,21 @@ export async function* runQuery(userInput, options) {
239
250
  cwd: runtimeContext.cwd,
240
251
  });
241
252
  const toolCallContext = {
253
+ output,
254
+ availableToolNames: () => currentEffectiveToolSet.names,
242
255
  executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
243
256
  history,
244
257
  sessionState,
258
+ advisorReviewContext: () => ({
259
+ ...collectReviewContext(advisorContextBundle),
260
+ constraints: [...collectReviewContext(advisorContextBundle).constraints,
261
+ { id: 'execution.boundary', authority: 'host', content: JSON.stringify({ cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
262
+ conversationMode: toolCallContext.conversationMode, profile: runtimeContext.profile,
263
+ availableTools: [...currentEffectiveToolSet.names], invocation: invocationInstructions(options.invocationPolicy) }) }],
264
+ omittedIds: [...new Set([...(currentTransientBundle?.diagnostics.omittedIds ?? []), ...(advisorContextBundle?.diagnostics.omittedIds ?? [])]),
265
+ ...(options.transientContext ? ['legacy.transient-context:not-forwarded'] : [])],
266
+ clippedIds: [...new Set([...(currentTransientBundle?.diagnostics.clippedIds ?? []), ...(advisorContextBundle?.diagnostics.clippedIds ?? [])])],
267
+ }),
245
268
  // Statistics only; consultations have no per-turn quota.
246
269
  advisorCalls: { used: 0 },
247
270
  advisorInvocation: 'agent',
@@ -303,6 +326,7 @@ export async function* runQuery(userInput, options) {
303
326
  const parts = [
304
327
  transientFor(activeTools),
305
328
  coordinationText,
329
+ diagramPreparationAttempted ? visualPresentation(output) : '',
306
330
  delegationCorrectionAttempted ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。' : '',
307
331
  askCorrectionAttempted ? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。' : '',
308
332
  deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
@@ -684,7 +708,18 @@ export async function* runQuery(userInput, options) {
684
708
  return;
685
709
  }
686
710
  // 3. 调 LLM 并组装 assistant 消息
711
+ trace('model_start');
687
712
  let assistantText = '';
713
+ // Candidate text is private until the delivery gates allow publication.
714
+ // Keep the compatibility event as a phase notice, never as a text retraction.
715
+ function* retireDraft(reason) {
716
+ const message = { role: 'system', content: answerPreparationNotice(reason), id: crypto.randomUUID(),
717
+ timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
718
+ history.push(message);
719
+ yield { type: 'injected_message', message };
720
+ if (reason !== 'interrupted')
721
+ yield { type: 'assistant_draft_discarded', reason, textLength: 0 };
722
+ }
688
723
  /** 按 index 累积 tool_calls(OpenAI 协议下 tool_calls 是分片返回的) */
689
724
  const toolCallsByIndex = [];
690
725
  /**
@@ -694,8 +729,6 @@ export async function* runQuery(userInput, options) {
694
729
  let reasoningContent = '';
695
730
  let reasoningString = '';
696
731
  const reasoningDetails = [];
697
- /** 首个 text_delta 时切到 streaming,再来不重复 yield */
698
- let stageStreamingYielded = false;
699
732
  /**
700
733
  * R2:本轮 LLM 流的终止原因(来自 client 的 done.stopReason)。
701
734
  * 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
@@ -711,7 +744,7 @@ export async function* runQuery(userInput, options) {
711
744
  for await (const ev of chatFn({
712
745
  provider,
713
746
  messages: modelMessages,
714
- tools: currentEffectiveToolSet.tools,
747
+ tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'preparation'),
715
748
  signal,
716
749
  conversationMode: runtimeContext.conversationMode ?? 'private',
717
750
  promptCache: options.promptCache,
@@ -725,12 +758,7 @@ export async function* runQuery(userInput, options) {
725
758
  : {}),
726
759
  })) {
727
760
  if (ev.type === 'text_delta') {
728
- if (!stageStreamingYielded) {
729
- stageStreamingYielded = true;
730
- yield { type: 'stage_change', stage: 'streaming' };
731
- }
732
761
  assistantText += ev.delta;
733
- yield { type: 'text', delta: ev.delta };
734
762
  }
735
763
  else if (ev.type === 'tool_call_delta') {
736
764
  mergeToolCallDelta(toolCallsByIndex, ev);
@@ -783,7 +811,9 @@ export async function* runQuery(userInput, options) {
783
811
  }
784
812
  }
785
813
  catch (e) {
814
+ trace('model_error');
786
815
  if (e.name === 'AbortError') {
816
+ yield* retireDraft('interrupted');
787
817
  yield interruption();
788
818
  return;
789
819
  }
@@ -799,6 +829,7 @@ export async function* runQuery(userInput, options) {
799
829
  result = await reactiveCompactIfApplicable(history, provider, e, sessionState?.reactive, signal, { chat: chatFn }, options.promptCache);
800
830
  }
801
831
  catch (error) {
832
+ yield* retireDraft('interrupted');
802
833
  if (signal?.aborted || error.name === 'AbortError') {
803
834
  yield interruption();
804
835
  return;
@@ -829,6 +860,7 @@ export async function* runQuery(userInput, options) {
829
860
  // reactive 没压动或未恢复 → 退回正常错误路径
830
861
  }
831
862
  // code='llm_error':终结性错误,stop_reason='error'
863
+ yield* retireDraft('interrupted');
832
864
  yield {
833
865
  type: 'error',
834
866
  error: `LLM 调用失败:${e.message}`,
@@ -836,7 +868,9 @@ export async function* runQuery(userInput, options) {
836
868
  };
837
869
  return;
838
870
  }
871
+ trace('model_end');
839
872
  if (lastFinishReason === 'aborted' || signal?.aborted || options.hasQueuedInput?.()) {
873
+ yield* retireDraft('interrupted');
840
874
  if (options.hasQueuedInput?.() && !signal?.aborted) {
841
875
  pushQueuedInputMarker(history);
842
876
  yield { type: 'interrupted', reason: 'queued_input' };
@@ -856,6 +890,29 @@ export async function* runQuery(userInput, options) {
856
890
  toolCallsByIndex.push(...dense);
857
891
  }
858
892
  }
893
+ // A pending review must run before another solving/publishing attempt.
894
+ // Read-only diagnosis and human questions remain available while stalled.
895
+ const pendingReview = options.advisorCheckpoints === false ? undefined : advisorCoordinator.pendingReview();
896
+ if (pendingReview && turn < maxTurns && toolCallsByIndex.length > 0
897
+ && !toolCallsByIndex.some(tc => ['Advisor', 'AskUserQuestion'].includes(tc.function.name))
898
+ && toolCallsByIndex.some(tc => tc.function.name === 'PresentResult'
899
+ || advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find(t => t.name === tc.function.name), { treatVerificationAsReadOnly: true }).mutatesWorkspace
900
+ || stallGuard.shouldReject(tc))) {
901
+ yield* retireDraft('advisor_checkpoint');
902
+ toolCallsByIndex.splice(0, toolCallsByIndex.length, { id: crypto.randomUUID(), type: 'function',
903
+ function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
904
+ assistantText = '';
905
+ }
906
+ // Count the calls that survived review scheduling, including failed loads.
907
+ for (const call of toolCallsByIndex) {
908
+ if (call.function.name !== 'Skill')
909
+ continue;
910
+ try {
911
+ if (JSON.parse(call.function.arguments).name === 'dynamic-ui')
912
+ diagramPreparationAttempted = true;
913
+ }
914
+ catch { /* Ordinary tool validation reports malformed arguments. */ }
915
+ }
859
916
  const assistantMsg = {
860
917
  role: 'assistant',
861
918
  content: assistantText.length > 0 ? assistantText : null,
@@ -868,12 +925,26 @@ export async function* runQuery(userInput, options) {
868
925
  ...(options.messageProvenance ? { provenance: options.messageProvenance } : {}),
869
926
  };
870
927
  // 4. 没有工具调用 → 整轮交互结束
871
- if (toolCallsByIndex.length === 0) {
928
+ finalResponse: if (toolCallsByIndex.length === 0) {
929
+ // Repair only private preparation, through the ordinary tool pipeline.
930
+ // One attempt per turn, including missing/failed skills; never rewind a reply.
931
+ if (!diagramPreparationAttempted && !options.responseFormat
932
+ && runtimeContext.profile === 'main' && output.diagrams.length > 0
933
+ && currentEffectiveToolSet.names.has('Skill') && turn < maxTurns
934
+ && needsDiagramPreparation(assistantText, userInput)) {
935
+ diagramPreparationAttempted = true;
936
+ toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
937
+ name: 'Skill', arguments: JSON.stringify({ name: 'dynamic-ui' }),
938
+ } });
939
+ assistantMsg.content = null;
940
+ assistantMsg.tool_calls = toolCallsByIndex;
941
+ break finalResponse;
942
+ }
872
943
  if (!askCorrectionAttempted && runtimeContext.interactiveAsk
873
944
  && currentEffectiveToolSet.names.has('AskUserQuestion')
874
945
  && needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
875
946
  askCorrectionAttempted = true;
876
- yield { type: 'assistant_draft_discarded', reason: 'interactive_question', textLength: assistantText.length };
947
+ yield* retireDraft('interactive_question');
877
948
  continue;
878
949
  }
879
950
  const emptyResponse = stripThinkBlocks(assistantText).length === 0;
@@ -882,13 +953,11 @@ export async function* runQuery(userInput, options) {
882
953
  if (pendingDelegations.length && !delegationCorrectionAttempted && turn < maxTurns) {
883
954
  delegationCorrectionAttempted = true;
884
955
  coordinationText = coordination.text;
885
- yield { type: 'assistant_draft_discarded', reason: 'formal_delivery', textLength: assistantText.length };
956
+ yield* retireDraft('formal_delivery');
886
957
  continue;
887
958
  }
888
959
  const pendingMessages = [...(options.takePendingMessages?.() ?? []), ...await takeAgentNotifications(toolCallContext)];
889
960
  if (pendingMessages.length) {
890
- if (!emptyResponse)
891
- history.push(assistantMsg);
892
961
  history.push(...pendingMessages);
893
962
  continue;
894
963
  }
@@ -900,22 +969,27 @@ export async function* runQuery(userInput, options) {
900
969
  && !deliveryCorrectionAttempted
901
970
  && turn < maxTurns) {
902
971
  deliveryCorrectionAttempted = true;
903
- yield {
904
- type: 'assistant_draft_discarded',
905
- reason: 'formal_delivery',
906
- textLength: assistantText.length,
907
- };
972
+ yield* retireDraft('formal_delivery');
908
973
  continue;
909
974
  }
910
- const deliveryCheckpoint = emptyResponse
975
+ const deliveryCheckpoint = emptyResponse || options.advisorCheckpoints === false
911
976
  ? { allow: true }
912
977
  : advisorCoordinator.beforeDelivery(assistantText);
978
+ const fallbackReview = !emptyResponse && options.advisorCheckpoints !== false
979
+ ? advisorCoordinator.pendingReview(assistantText) : undefined;
980
+ if (fallbackReview && turn < maxTurns) {
981
+ yield* retireDraft('advisor_checkpoint');
982
+ // Reuse the effective tool boundary, permissions, cancellation, events and
983
+ // persistence. Never call a provider behind the host's tool pipeline.
984
+ toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
985
+ name: 'Advisor', arguments: JSON.stringify(fallbackReview),
986
+ } });
987
+ assistantMsg.content = null;
988
+ assistantMsg.tool_calls = toolCallsByIndex;
989
+ break finalResponse;
990
+ }
913
991
  if (!deliveryCheckpoint.allow && turn < maxTurns) {
914
- yield {
915
- type: 'assistant_draft_discarded',
916
- reason: 'advisor_checkpoint',
917
- textLength: assistantText.length,
918
- };
992
+ yield* retireDraft('advisor_checkpoint');
919
993
  continue;
920
994
  }
921
995
  if (!emptyResponse) {
@@ -927,21 +1001,118 @@ export async function* runQuery(userInput, options) {
927
1001
  if (emptyResponse) {
928
1002
  // A no-tool response is already the model's terminal signal. Do not turn it
929
1003
  // into another model round, and do not persist/broadcast an empty bubble.
930
- yield {
931
- type: 'assistant_draft_discarded',
932
- reason: 'empty_response',
933
- textLength: assistantText.length,
934
- };
1004
+ yield* retireDraft('empty_response');
935
1005
  }
936
1006
  else {
1007
+ // Publication is a separate tool-free request. Nothing from preparation
1008
+ // has been streamed, and no gate may retract text after this boundary.
1009
+ let publicationError;
1010
+ let publicationInterrupted = false;
1011
+ let streamed = false;
1012
+ if (!options.responseFormat && runtimeContext.profile === 'main'
1013
+ && output.finalDelivery === 'stream') {
1014
+ const candidate = assistantText;
1015
+ const candidateFinishReason = lastFinishReason;
1016
+ let publishedText = '';
1017
+ let publicationDone = false;
1018
+ yield { type: 'stage_change', stage: 'streaming' };
1019
+ try {
1020
+ const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText, output);
1021
+ const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
1022
+ if (publicationPlan.overBudget)
1023
+ throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
1024
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1025
+ publicationInterrupted = true;
1026
+ throw new Error('最终发布已取消。');
1027
+ }
1028
+ turnUsage.totalCalls += 1;
1029
+ for await (const event of chatFn({
1030
+ provider,
1031
+ messages: publicationMessages,
1032
+ tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'publication'), signal,
1033
+ conversationMode: runtimeContext.conversationMode ?? 'private',
1034
+ promptCache: options.promptCache,
1035
+ })) {
1036
+ if (event.type === 'done' && !publicationDone) {
1037
+ publicationDone = true;
1038
+ if (typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number') {
1039
+ turnUsage.reportedCalls += 1;
1040
+ turnUsage.inputTokens += event.promptTokens ?? 0;
1041
+ turnUsage.outputTokens += event.outputTokens ?? 0;
1042
+ }
1043
+ }
1044
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1045
+ publicationInterrupted = true;
1046
+ break;
1047
+ }
1048
+ if (event.type === 'text_delta') {
1049
+ // Final expression is live generation, not an exact-copy replay.
1050
+ // Once visible, text is never replaced or joined with a different draft.
1051
+ publishedText += event.delta;
1052
+ streamed = true;
1053
+ yield { type: 'text', delta: event.delta };
1054
+ }
1055
+ else if (event.type === 'tool_call_delta') {
1056
+ throw new Error('最终表达阶段返回了非预期工具调用,未执行该工具。');
1057
+ }
1058
+ else if (event.type === 'done') {
1059
+ publicationDone = true;
1060
+ lastFinishReason = event.stopReason;
1061
+ if (event.stopReason === 'aborted')
1062
+ publicationInterrupted = true;
1063
+ }
1064
+ }
1065
+ if (!publicationDone && !publicationInterrupted)
1066
+ throw new Error('最终答案流未正常结束。');
1067
+ if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length')
1068
+ throw new Error('最终答案流缺少有效的结束状态。');
1069
+ if (!publishedText.trim() && !publicationInterrupted)
1070
+ throw new Error('最终表达未返回正文。');
1071
+ }
1072
+ catch (error) {
1073
+ publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || error?.name === 'AbortError';
1074
+ publicationError = error;
1075
+ }
1076
+ if (publicationError && !publicationInterrupted && !publishedText) {
1077
+ yield { type: 'warning', message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。' };
1078
+ yield { type: 'text', delta: candidate };
1079
+ streamed = true;
1080
+ publishedText = candidate;
1081
+ lastFinishReason = candidateFinishReason;
1082
+ publicationError = undefined;
1083
+ }
1084
+ assistantText = publishedText;
1085
+ assistantMsg.content = publishedText || null;
1086
+ if (!publishedText) {
1087
+ if (publicationInterrupted)
1088
+ yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1089
+ else
1090
+ yield { type: 'error', code: 'llm_error', error: `最终答案生成失败:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1091
+ return;
1092
+ }
1093
+ }
1094
+ assistantMsg.presentation = 'final';
1095
+ trace('final_published');
937
1096
  if (assistantMsg.role === 'assistant')
938
1097
  assistantMsg.advisorTaskVersion = advisorCoordinator.versions.taskVersion;
939
1098
  assistantMsg.advisorCheckpoints = advisorCoordinator.drainDegradations();
940
1099
  history.push(assistantMsg);
1100
+ // Non-interactive consumers retain atomic output. Interactive hosts
1101
+ // already received real deltas; commit the identical text only once.
1102
+ if (!streamed)
1103
+ yield { type: 'text', delta: assistantText };
941
1104
  yield {
942
1105
  type: 'assistant_message',
943
1106
  message: assistantMsg,
944
1107
  };
1108
+ if (publicationInterrupted || publicationError) {
1109
+ yield { type: 'warning', message: '最终答案未完整生成,已保留已输出内容;未撤回或重新生成。' };
1110
+ if (publicationInterrupted)
1111
+ yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1112
+ else
1113
+ yield { type: 'error', code: 'llm_error', error: `最终答案流中断:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1114
+ return;
1115
+ }
945
1116
  }
946
1117
  // R2:最终回答被 provider 因 max_tokens 截断(finish_reason='length')时,
947
1118
  // 不能静默当正常 end_turn 收尾 —— 否则 -p 宿主会拿到半截答案却以为成功。
@@ -990,7 +1161,12 @@ export async function* runQuery(userInput, options) {
990
1161
  ...turnUsage,
991
1162
  trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
992
1163
  };
993
- if (pendingDelegations.length) {
1164
+ if (emptyResponse && !resultPresented) {
1165
+ const warning = '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1166
+ yield { type: 'warning', message: warning };
1167
+ yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
1168
+ }
1169
+ else if (pendingDelegations.length) {
994
1170
  const warning = `必要子任务尚未收口:${pendingDelegations.map((r) => r.id).join(', ')}。本轮不能视为全部完成。`;
995
1171
  yield { type: 'warning', message: warning };
996
1172
  yield { type: 'turn_done', completionStatus: 'delivery_incomplete', warning, usage };
@@ -1014,10 +1190,13 @@ export async function* runQuery(userInput, options) {
1014
1190
  };
1015
1191
  }
1016
1192
  else {
1017
- yield { type: 'turn_done', completionStatus: 'completed', usage };
1193
+ yield { type: 'turn_done', completionStatus: 'completed', usage,
1194
+ ...(emptyResponse ? { warning: '成果已提交,未生成文字总结;请查看已提交的成果。' } : {}) };
1018
1195
  }
1019
1196
  return;
1020
1197
  }
1198
+ assistantMsg.presentation = 'progress';
1199
+ trace('progress_published');
1021
1200
  history.push(assistantMsg);
1022
1201
  yield {
1023
1202
  type: 'assistant_message',
@@ -1231,7 +1410,22 @@ export async function* runQuery(userInput, options) {
1231
1410
  }
1232
1411
  };
1233
1412
  const runOne = async (tc, reportProgress) => {
1413
+ // Also settle denied/invalid/unavailable calls that never reached the
1414
+ // provider adapter. This records a real attempt, not an ignored reminder.
1415
+ let consultation;
1416
+ if (tc.function.name === 'Advisor') {
1417
+ let reason;
1418
+ try {
1419
+ reason = JSON.parse(tc.function.arguments).reason;
1420
+ }
1421
+ catch { /* validation handles malformed input */ }
1422
+ consultation = advisorCoordinator.beginConsultation(reason);
1423
+ }
1234
1424
  const outcome = await runOneImpl(tc, reportProgress);
1425
+ if (consultation && outcome.result.metadata?.kind !== 'advisor_consultation'
1426
+ && !(!outcome.result.ok && outcome.result.error === 'aborted')) {
1427
+ outcome.result = { ...outcome.result, metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok) };
1428
+ }
1235
1429
  const metadata = outcome.result.metadata;
1236
1430
  if (metadata?.kind === 'advisor_decision' && outcome.result.ok
1237
1431
  || metadata?.kind === 'advisor_consultation') {
@@ -1521,6 +1715,7 @@ export const SYSTEM_REMINDER_CLOSE = '</system-reminder>';
1521
1715
  * serialized request a strict prefix extension of the previous one.
1522
1716
  */
1523
1717
  export function buildModelMessages(history, transientContext, currentUserMessageId) {
1718
+ history = history.filter(message => !(message.role === 'system' && message._ephemeral));
1524
1719
  const transient = transientContext?.trim();
1525
1720
  if (!transient)
1526
1721
  return history;