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.js CHANGED
@@ -27,6 +27,9 @@
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 { createLifecycleTrace } from './runtime/lifecycleTrace.js';
30
33
  import { conciseToolPath } from './runtime/toolDisplay.js';
31
34
  import { dirname, resolve } from 'node:path';
32
35
  import { loadProjectPromptInstructions } from './prompts/projectInstructions.js';
@@ -59,6 +62,7 @@ import { validateFinalClaims } from './harness/claimValidation.js';
59
62
  import { AdvisorCoordinator, } from './advisor/advisorCoordinator.js';
60
63
  import { toolEffectFor } from './tools/capabilities.js';
61
64
  import { isReadOnlyShellCommand, isVerificationShellCommand } from './tools/bash/semantics.js';
65
+ import { collectReviewContext } from './advisor/reviewContext.js';
62
66
  import { createAgentRuntimeContext, } from './runtime/agentRuntimeContext.js';
63
67
  import { stripThinkBlocks } from './utils/stripThink.js';
64
68
  import { orderedPool } from './runtime/orderedPool.js';
@@ -203,10 +207,12 @@ export async function* runQuery(userInput, options) {
203
207
  delete sessionState.computerAutomation;
204
208
  // DI fallback:测试可传 stub,生产走真实模块单例
205
209
  const chatFn = options.chat ?? defaultChat;
210
+ const trace = createLifecycleTrace('loop');
206
211
  const executeToolFn = options.executeTool ?? defaultExecuteTool;
207
212
  const explicitFileDeliveryRequested = expectsFormalDelivery(userInput);
208
213
  const currentUserMessageId = options.userMessageId ?? crypto.randomUUID();
209
214
  let currentTransientBundle = options.transientBundle;
215
+ let advisorContextBundle = currentTransientBundle;
210
216
  let modelEnvironmentCwd = runtimeContext.cwd;
211
217
  const observedRuleScopes = new Map();
212
218
  const pendingRuleScopes = new Set();
@@ -225,6 +231,7 @@ export async function* runQuery(userInput, options) {
225
231
  const selected = availableForBundle >= bundle.diagnostics.usedTokens
226
232
  ? bundle
227
233
  : resizeContextBundle(bundle, availableForBundle);
234
+ advisorContextBundle = selected;
228
235
  return [selected.text, opaque].filter(Boolean).join('\n\n');
229
236
  };
230
237
  let resultPresented = false;
@@ -242,6 +249,16 @@ export async function* runQuery(userInput, options) {
242
249
  executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
243
250
  history,
244
251
  sessionState,
252
+ advisorReviewContext: () => ({
253
+ ...collectReviewContext(advisorContextBundle),
254
+ constraints: [...collectReviewContext(advisorContextBundle).constraints,
255
+ { id: 'execution.boundary', authority: 'host', content: JSON.stringify({ cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
256
+ conversationMode: toolCallContext.conversationMode, profile: runtimeContext.profile,
257
+ availableTools: [...currentEffectiveToolSet.names], invocation: invocationInstructions(options.invocationPolicy) }) }],
258
+ omittedIds: [...new Set([...(currentTransientBundle?.diagnostics.omittedIds ?? []), ...(advisorContextBundle?.diagnostics.omittedIds ?? [])]),
259
+ ...(options.transientContext ? ['legacy.transient-context:not-forwarded'] : [])],
260
+ clippedIds: [...new Set([...(currentTransientBundle?.diagnostics.clippedIds ?? []), ...(advisorContextBundle?.diagnostics.clippedIds ?? [])])],
261
+ }),
245
262
  // Statistics only; consultations have no per-turn quota.
246
263
  advisorCalls: { used: 0 },
247
264
  advisorInvocation: 'agent',
@@ -684,7 +701,18 @@ export async function* runQuery(userInput, options) {
684
701
  return;
685
702
  }
686
703
  // 3. 调 LLM 并组装 assistant 消息
704
+ trace('model_start');
687
705
  let assistantText = '';
706
+ // Candidate text is private until the delivery gates allow publication.
707
+ // Keep the compatibility event as a phase notice, never as a text retraction.
708
+ function* retireDraft(reason) {
709
+ const message = { role: 'system', content: answerPreparationNotice(reason), id: crypto.randomUUID(),
710
+ timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
711
+ history.push(message);
712
+ yield { type: 'injected_message', message };
713
+ if (reason !== 'interrupted')
714
+ yield { type: 'assistant_draft_discarded', reason, textLength: 0 };
715
+ }
688
716
  /** 按 index 累积 tool_calls(OpenAI 协议下 tool_calls 是分片返回的) */
689
717
  const toolCallsByIndex = [];
690
718
  /**
@@ -694,8 +722,6 @@ export async function* runQuery(userInput, options) {
694
722
  let reasoningContent = '';
695
723
  let reasoningString = '';
696
724
  const reasoningDetails = [];
697
- /** 首个 text_delta 时切到 streaming,再来不重复 yield */
698
- let stageStreamingYielded = false;
699
725
  /**
700
726
  * R2:本轮 LLM 流的终止原因(来自 client 的 done.stopReason)。
701
727
  * 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
@@ -725,12 +751,7 @@ export async function* runQuery(userInput, options) {
725
751
  : {}),
726
752
  })) {
727
753
  if (ev.type === 'text_delta') {
728
- if (!stageStreamingYielded) {
729
- stageStreamingYielded = true;
730
- yield { type: 'stage_change', stage: 'streaming' };
731
- }
732
754
  assistantText += ev.delta;
733
- yield { type: 'text', delta: ev.delta };
734
755
  }
735
756
  else if (ev.type === 'tool_call_delta') {
736
757
  mergeToolCallDelta(toolCallsByIndex, ev);
@@ -783,7 +804,9 @@ export async function* runQuery(userInput, options) {
783
804
  }
784
805
  }
785
806
  catch (e) {
807
+ trace('model_error');
786
808
  if (e.name === 'AbortError') {
809
+ yield* retireDraft('interrupted');
787
810
  yield interruption();
788
811
  return;
789
812
  }
@@ -799,6 +822,7 @@ export async function* runQuery(userInput, options) {
799
822
  result = await reactiveCompactIfApplicable(history, provider, e, sessionState?.reactive, signal, { chat: chatFn }, options.promptCache);
800
823
  }
801
824
  catch (error) {
825
+ yield* retireDraft('interrupted');
802
826
  if (signal?.aborted || error.name === 'AbortError') {
803
827
  yield interruption();
804
828
  return;
@@ -829,6 +853,7 @@ export async function* runQuery(userInput, options) {
829
853
  // reactive 没压动或未恢复 → 退回正常错误路径
830
854
  }
831
855
  // code='llm_error':终结性错误,stop_reason='error'
856
+ yield* retireDraft('interrupted');
832
857
  yield {
833
858
  type: 'error',
834
859
  error: `LLM 调用失败:${e.message}`,
@@ -836,7 +861,9 @@ export async function* runQuery(userInput, options) {
836
861
  };
837
862
  return;
838
863
  }
864
+ trace('model_end');
839
865
  if (lastFinishReason === 'aborted' || signal?.aborted || options.hasQueuedInput?.()) {
866
+ yield* retireDraft('interrupted');
840
867
  if (options.hasQueuedInput?.() && !signal?.aborted) {
841
868
  pushQueuedInputMarker(history);
842
869
  yield { type: 'interrupted', reason: 'queued_input' };
@@ -856,6 +883,19 @@ export async function* runQuery(userInput, options) {
856
883
  toolCallsByIndex.push(...dense);
857
884
  }
858
885
  }
886
+ // A pending review must run before another solving/publishing attempt.
887
+ // Read-only diagnosis and human questions remain available while stalled.
888
+ const pendingReview = options.advisorCheckpoints === false ? undefined : advisorCoordinator.pendingReview();
889
+ if (pendingReview && turn < maxTurns && toolCallsByIndex.length > 0
890
+ && !toolCallsByIndex.some(tc => ['Advisor', 'AskUserQuestion'].includes(tc.function.name))
891
+ && toolCallsByIndex.some(tc => tc.function.name === 'PresentResult'
892
+ || advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find(t => t.name === tc.function.name), { treatVerificationAsReadOnly: true }).mutatesWorkspace
893
+ || stallGuard.shouldReject(tc))) {
894
+ yield* retireDraft('advisor_checkpoint');
895
+ toolCallsByIndex.splice(0, toolCallsByIndex.length, { id: crypto.randomUUID(), type: 'function',
896
+ function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
897
+ assistantText = '';
898
+ }
859
899
  const assistantMsg = {
860
900
  role: 'assistant',
861
901
  content: assistantText.length > 0 ? assistantText : null,
@@ -868,12 +908,12 @@ export async function* runQuery(userInput, options) {
868
908
  ...(options.messageProvenance ? { provenance: options.messageProvenance } : {}),
869
909
  };
870
910
  // 4. 没有工具调用 → 整轮交互结束
871
- if (toolCallsByIndex.length === 0) {
911
+ finalResponse: if (toolCallsByIndex.length === 0) {
872
912
  if (!askCorrectionAttempted && runtimeContext.interactiveAsk
873
913
  && currentEffectiveToolSet.names.has('AskUserQuestion')
874
914
  && needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
875
915
  askCorrectionAttempted = true;
876
- yield { type: 'assistant_draft_discarded', reason: 'interactive_question', textLength: assistantText.length };
916
+ yield* retireDraft('interactive_question');
877
917
  continue;
878
918
  }
879
919
  const emptyResponse = stripThinkBlocks(assistantText).length === 0;
@@ -882,13 +922,11 @@ export async function* runQuery(userInput, options) {
882
922
  if (pendingDelegations.length && !delegationCorrectionAttempted && turn < maxTurns) {
883
923
  delegationCorrectionAttempted = true;
884
924
  coordinationText = coordination.text;
885
- yield { type: 'assistant_draft_discarded', reason: 'formal_delivery', textLength: assistantText.length };
925
+ yield* retireDraft('formal_delivery');
886
926
  continue;
887
927
  }
888
928
  const pendingMessages = [...(options.takePendingMessages?.() ?? []), ...await takeAgentNotifications(toolCallContext)];
889
929
  if (pendingMessages.length) {
890
- if (!emptyResponse)
891
- history.push(assistantMsg);
892
930
  history.push(...pendingMessages);
893
931
  continue;
894
932
  }
@@ -900,22 +938,27 @@ export async function* runQuery(userInput, options) {
900
938
  && !deliveryCorrectionAttempted
901
939
  && turn < maxTurns) {
902
940
  deliveryCorrectionAttempted = true;
903
- yield {
904
- type: 'assistant_draft_discarded',
905
- reason: 'formal_delivery',
906
- textLength: assistantText.length,
907
- };
941
+ yield* retireDraft('formal_delivery');
908
942
  continue;
909
943
  }
910
- const deliveryCheckpoint = emptyResponse
944
+ const deliveryCheckpoint = emptyResponse || options.advisorCheckpoints === false
911
945
  ? { allow: true }
912
946
  : advisorCoordinator.beforeDelivery(assistantText);
947
+ const fallbackReview = !emptyResponse && options.advisorCheckpoints !== false
948
+ ? advisorCoordinator.pendingReview(assistantText) : undefined;
949
+ if (fallbackReview && turn < maxTurns) {
950
+ yield* retireDraft('advisor_checkpoint');
951
+ // Reuse the effective tool boundary, permissions, cancellation, events and
952
+ // persistence. Never call a provider behind the host's tool pipeline.
953
+ toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
954
+ name: 'Advisor', arguments: JSON.stringify(fallbackReview),
955
+ } });
956
+ assistantMsg.content = null;
957
+ assistantMsg.tool_calls = toolCallsByIndex;
958
+ break finalResponse;
959
+ }
913
960
  if (!deliveryCheckpoint.allow && turn < maxTurns) {
914
- yield {
915
- type: 'assistant_draft_discarded',
916
- reason: 'advisor_checkpoint',
917
- textLength: assistantText.length,
918
- };
961
+ yield* retireDraft('advisor_checkpoint');
919
962
  continue;
920
963
  }
921
964
  if (!emptyResponse) {
@@ -927,21 +970,127 @@ export async function* runQuery(userInput, options) {
927
970
  if (emptyResponse) {
928
971
  // A no-tool response is already the model's terminal signal. Do not turn it
929
972
  // 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
- };
973
+ yield* retireDraft('empty_response');
935
974
  }
936
975
  else {
976
+ // Publication is a separate tool-free request. Nothing from preparation
977
+ // has been streamed, and no gate may retract text after this boundary.
978
+ let publicationError;
979
+ let publicationInterrupted = false;
980
+ let streamed = false;
981
+ if (!options.responseFormat && runtimeContext.profile === 'main'
982
+ && (runtimeContext.surface === 'desktop' || runtimeContext.surface === 'tui')) {
983
+ const candidate = assistantText;
984
+ const candidateFinishReason = lastFinishReason;
985
+ let publishedText = '';
986
+ let publicationDone = false;
987
+ yield { type: 'stage_change', stage: 'streaming' };
988
+ try {
989
+ const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText);
990
+ const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
991
+ if (publicationPlan.overBudget)
992
+ throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
993
+ if (signal?.aborted || options.hasQueuedInput?.()) {
994
+ publicationInterrupted = true;
995
+ throw new Error('最终发布已取消。');
996
+ }
997
+ turnUsage.totalCalls += 1;
998
+ for await (const event of chatFn({
999
+ provider,
1000
+ messages: publicationMessages,
1001
+ tools: [], signal,
1002
+ conversationMode: runtimeContext.conversationMode ?? 'private',
1003
+ promptCache: options.promptCache,
1004
+ })) {
1005
+ if (event.type === 'done' && !publicationDone) {
1006
+ publicationDone = true;
1007
+ if (typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number') {
1008
+ turnUsage.reportedCalls += 1;
1009
+ turnUsage.inputTokens += event.promptTokens ?? 0;
1010
+ turnUsage.outputTokens += event.outputTokens ?? 0;
1011
+ }
1012
+ }
1013
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1014
+ publicationInterrupted = true;
1015
+ break;
1016
+ }
1017
+ if (event.type === 'text_delta') {
1018
+ // Never expose a rewrite of the reviewed candidate. Previously
1019
+ // published chunks are necessarily an exact prefix of it.
1020
+ if (!candidate.startsWith(publishedText + event.delta)) {
1021
+ throw new Error('发布内容偏离定稿,使用原定稿。');
1022
+ }
1023
+ publishedText += event.delta;
1024
+ streamed = true;
1025
+ yield { type: 'text', delta: event.delta };
1026
+ }
1027
+ else if (event.type === 'tool_call_delta') {
1028
+ throw new Error('最终表达阶段返回了非预期工具调用,未执行该工具。');
1029
+ }
1030
+ else if (event.type === 'done') {
1031
+ publicationDone = true;
1032
+ lastFinishReason = event.stopReason;
1033
+ if (event.stopReason === 'aborted')
1034
+ publicationInterrupted = true;
1035
+ }
1036
+ }
1037
+ if (!publicationDone && !publicationInterrupted)
1038
+ throw new Error('最终答案流未正常结束。');
1039
+ if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length')
1040
+ throw new Error('最终答案流缺少有效的结束状态。');
1041
+ if (!publishedText.trim() && !publicationInterrupted)
1042
+ throw new Error('最终表达未返回正文。');
1043
+ if (!publicationInterrupted && publishedText !== candidate)
1044
+ throw new Error('最终表达未完整传递定稿。');
1045
+ }
1046
+ catch (error) {
1047
+ publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || error?.name === 'AbortError';
1048
+ publicationError = error;
1049
+ }
1050
+ if (publicationError && !publicationInterrupted) {
1051
+ yield { type: 'warning', message: '流式发布未完成,已使用原定稿补齐;未重新生成或改写答案。' };
1052
+ const remaining = candidate.slice(publishedText.length);
1053
+ if (remaining) {
1054
+ yield { type: 'text', delta: remaining };
1055
+ streamed = true;
1056
+ }
1057
+ publishedText = candidate;
1058
+ publicationError = undefined;
1059
+ }
1060
+ assistantText = publishedText;
1061
+ if (!publicationInterrupted)
1062
+ lastFinishReason = candidateFinishReason;
1063
+ assistantMsg.content = publishedText || null;
1064
+ if (!publishedText) {
1065
+ if (publicationInterrupted)
1066
+ yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1067
+ else
1068
+ yield { type: 'error', code: 'llm_error', error: `最终答案生成失败:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1069
+ return;
1070
+ }
1071
+ }
1072
+ assistantMsg.presentation = 'final';
1073
+ trace('final_published');
937
1074
  if (assistantMsg.role === 'assistant')
938
1075
  assistantMsg.advisorTaskVersion = advisorCoordinator.versions.taskVersion;
939
1076
  assistantMsg.advisorCheckpoints = advisorCoordinator.drainDegradations();
940
1077
  history.push(assistantMsg);
1078
+ // Non-interactive consumers retain atomic output. Interactive hosts
1079
+ // already received real deltas; commit the identical text only once.
1080
+ if (!streamed)
1081
+ yield { type: 'text', delta: assistantText };
941
1082
  yield {
942
1083
  type: 'assistant_message',
943
1084
  message: assistantMsg,
944
1085
  };
1086
+ if (publicationInterrupted || publicationError) {
1087
+ yield { type: 'warning', message: '最终答案未完整生成,已保留已输出内容;未撤回或重新生成。' };
1088
+ if (publicationInterrupted)
1089
+ yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1090
+ else
1091
+ yield { type: 'error', code: 'llm_error', error: `最终答案流中断:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1092
+ return;
1093
+ }
945
1094
  }
946
1095
  // R2:最终回答被 provider 因 max_tokens 截断(finish_reason='length')时,
947
1096
  // 不能静默当正常 end_turn 收尾 —— 否则 -p 宿主会拿到半截答案却以为成功。
@@ -990,7 +1139,12 @@ export async function* runQuery(userInput, options) {
990
1139
  ...turnUsage,
991
1140
  trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
992
1141
  };
993
- if (pendingDelegations.length) {
1142
+ if (emptyResponse && !resultPresented) {
1143
+ const warning = '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1144
+ yield { type: 'warning', message: warning };
1145
+ yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
1146
+ }
1147
+ else if (pendingDelegations.length) {
994
1148
  const warning = `必要子任务尚未收口:${pendingDelegations.map((r) => r.id).join(', ')}。本轮不能视为全部完成。`;
995
1149
  yield { type: 'warning', message: warning };
996
1150
  yield { type: 'turn_done', completionStatus: 'delivery_incomplete', warning, usage };
@@ -1014,10 +1168,13 @@ export async function* runQuery(userInput, options) {
1014
1168
  };
1015
1169
  }
1016
1170
  else {
1017
- yield { type: 'turn_done', completionStatus: 'completed', usage };
1171
+ yield { type: 'turn_done', completionStatus: 'completed', usage,
1172
+ ...(emptyResponse ? { warning: '成果已提交,未生成文字总结;请查看已提交的成果。' } : {}) };
1018
1173
  }
1019
1174
  return;
1020
1175
  }
1176
+ assistantMsg.presentation = 'progress';
1177
+ trace('progress_published');
1021
1178
  history.push(assistantMsg);
1022
1179
  yield {
1023
1180
  type: 'assistant_message',
@@ -1231,7 +1388,22 @@ export async function* runQuery(userInput, options) {
1231
1388
  }
1232
1389
  };
1233
1390
  const runOne = async (tc, reportProgress) => {
1391
+ // Also settle denied/invalid/unavailable calls that never reached the
1392
+ // provider adapter. This records a real attempt, not an ignored reminder.
1393
+ let consultation;
1394
+ if (tc.function.name === 'Advisor') {
1395
+ let reason;
1396
+ try {
1397
+ reason = JSON.parse(tc.function.arguments).reason;
1398
+ }
1399
+ catch { /* validation handles malformed input */ }
1400
+ consultation = advisorCoordinator.beginConsultation(reason);
1401
+ }
1234
1402
  const outcome = await runOneImpl(tc, reportProgress);
1403
+ if (consultation && outcome.result.metadata?.kind !== 'advisor_consultation'
1404
+ && !(!outcome.result.ok && outcome.result.error === 'aborted')) {
1405
+ outcome.result = { ...outcome.result, metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok) };
1406
+ }
1235
1407
  const metadata = outcome.result.metadata;
1236
1408
  if (metadata?.kind === 'advisor_decision' && outcome.result.ok
1237
1409
  || metadata?.kind === 'advisor_consultation') {
@@ -1521,6 +1693,7 @@ export const SYSTEM_REMINDER_CLOSE = '</system-reminder>';
1521
1693
  * serialized request a strict prefix extension of the previous one.
1522
1694
  */
1523
1695
  export function buildModelMessages(history, transientContext, currentUserMessageId) {
1696
+ history = history.filter(message => !(message.role === 'system' && message._ephemeral));
1524
1697
  const transient = transientContext?.trim();
1525
1698
  if (!transient)
1526
1699
  return history;