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.ts CHANGED
@@ -28,6 +28,11 @@
28
28
  */
29
29
 
30
30
  import crypto from 'node:crypto';
31
+ import { answerPreparationNotice } from './runtime/answerPreparation.js';
32
+ import { finalAnswerMessages } from './runtime/finalAnswer.js';
33
+ import { resolveOutputContract, toolsForResponsePhase } from './runtime/outputContract.js';
34
+ import { needsDiagramPreparation, visualPresentation } from './prompts/visualPresentation.js';
35
+ import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
31
36
  import { conciseToolPath } from './runtime/toolDisplay.js';
32
37
  import { dirname, resolve } from 'node:path';
33
38
  import { loadProjectPromptInstructions } from './prompts/projectInstructions.js';
@@ -100,6 +105,7 @@ import {
100
105
  } from './advisor/advisorCoordinator.js';
101
106
  import { toolEffectFor } from './tools/capabilities.js';
102
107
  import { isReadOnlyShellCommand, isVerificationShellCommand } from './tools/bash/semantics.js';
108
+ import { collectReviewContext } from './advisor/reviewContext.js';
103
109
  import {
104
110
  createAgentRuntimeContext,
105
111
  type AgentRuntimeContext,
@@ -410,11 +416,13 @@ export async function* runQuery(
410
416
  if (!computerIntent.needed && sessionState) delete sessionState.computerAutomation;
411
417
  // DI fallback:测试可传 stub,生产走真实模块单例
412
418
  const chatFn = options.chat ?? defaultChat;
419
+ const trace = createLifecycleTrace('loop');
413
420
  const executeToolFn = options.executeTool ?? defaultExecuteTool;
414
421
  const explicitFileDeliveryRequested = expectsFormalDelivery(userInput);
415
422
  const currentUserMessageId = options.userMessageId ?? crypto.randomUUID();
416
423
 
417
424
  let currentTransientBundle = options.transientBundle;
425
+ let advisorContextBundle = currentTransientBundle;
418
426
  let modelEnvironmentCwd = runtimeContext.cwd;
419
427
  const observedRuleScopes = new Map<string, string>();
420
428
  const pendingRuleScopes = new Set<string>();
@@ -435,12 +443,15 @@ export async function* runQuery(
435
443
  const selected = availableForBundle >= bundle.diagnostics.usedTokens
436
444
  ? bundle
437
445
  : resizeContextBundle(bundle, availableForBundle);
446
+ advisorContextBundle = selected;
438
447
  return [selected.text, opaque].filter(Boolean).join('\n\n');
439
448
  };
440
449
  let resultPresented = false;
441
450
  let presentedResultUnverified = false;
442
451
  let deliveryCorrectionAttempted = false;
443
452
  let askCorrectionAttempted = false;
453
+ let diagramPreparationAttempted = false;
454
+ const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
444
455
  const subagents = await configService.subagents();
445
456
  await hydrateSessionWorktree({
446
457
  sessionState,
@@ -449,9 +460,21 @@ export async function* runQuery(
449
460
  cwd: runtimeContext.cwd,
450
461
  });
451
462
  const toolCallContext: ToolCallContext = {
463
+ output,
464
+ availableToolNames: () => currentEffectiveToolSet.names,
452
465
  executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
453
466
  history,
454
467
  sessionState,
468
+ advisorReviewContext: () => ({
469
+ ...collectReviewContext(advisorContextBundle),
470
+ constraints: [...collectReviewContext(advisorContextBundle).constraints,
471
+ { id: 'execution.boundary', authority: 'host', content: JSON.stringify({ cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
472
+ conversationMode: toolCallContext.conversationMode, profile: runtimeContext.profile,
473
+ availableTools: [...currentEffectiveToolSet.names], invocation: invocationInstructions(options.invocationPolicy) }) }],
474
+ omittedIds: [...new Set([...(currentTransientBundle?.diagnostics.omittedIds ?? []), ...(advisorContextBundle?.diagnostics.omittedIds ?? [])]),
475
+ ...(options.transientContext ? ['legacy.transient-context:not-forwarded'] : [])],
476
+ clippedIds: [...new Set([...(currentTransientBundle?.diagnostics.clippedIds ?? []), ...(advisorContextBundle?.diagnostics.clippedIds ?? [])])],
477
+ }),
455
478
  // Statistics only; consultations have no per-turn quota.
456
479
  advisorCalls: { used: 0 },
457
480
  advisorInvocation: 'agent',
@@ -516,6 +539,7 @@ export async function* runQuery(
516
539
  const parts = [
517
540
  transientFor(activeTools),
518
541
  coordinationText,
542
+ diagramPreparationAttempted ? visualPresentation(output) : '',
519
543
  delegationCorrectionAttempted ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。' : '',
520
544
  askCorrectionAttempted ? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。' : '',
521
545
  deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
@@ -910,7 +934,17 @@ export async function* runQuery(
910
934
  }
911
935
 
912
936
  // 3. 调 LLM 并组装 assistant 消息
937
+ trace('model_start');
913
938
  let assistantText = '';
939
+ // Candidate text is private until the delivery gates allow publication.
940
+ // Keep the compatibility event as a phase notice, never as a text retraction.
941
+ function* retireDraft(reason: Extract<LoopEvent, { type: 'assistant_draft_discarded' }>['reason'] | 'interrupted'): Generator<LoopEvent> {
942
+ const message: Message = { role: 'system', content: answerPreparationNotice(reason), id: crypto.randomUUID(),
943
+ timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
944
+ history.push(message);
945
+ yield { type: 'injected_message', message };
946
+ if (reason !== 'interrupted') yield { type: 'assistant_draft_discarded', reason, textLength: 0 };
947
+ }
914
948
  /** 按 index 累积 tool_calls(OpenAI 协议下 tool_calls 是分片返回的) */
915
949
  const toolCallsByIndex: ToolCall[] = [];
916
950
  /**
@@ -921,9 +955,6 @@ export async function* runQuery(
921
955
  let reasoningString = '';
922
956
  const reasoningDetails: unknown[] = [];
923
957
 
924
- /** 首个 text_delta 时切到 streaming,再来不重复 yield */
925
- let stageStreamingYielded = false;
926
-
927
958
  /**
928
959
  * R2:本轮 LLM 流的终止原因(来自 client 的 done.stopReason)。
929
960
  * 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
@@ -942,7 +973,7 @@ export async function* runQuery(
942
973
  for await (const ev of chatFn({
943
974
  provider,
944
975
  messages: modelMessages,
945
- tools: currentEffectiveToolSet.tools,
976
+ tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'preparation'),
946
977
  signal,
947
978
  conversationMode: runtimeContext.conversationMode ?? 'private',
948
979
  promptCache: options.promptCache,
@@ -956,12 +987,7 @@ export async function* runQuery(
956
987
  : {}),
957
988
  })) {
958
989
  if (ev.type === 'text_delta') {
959
- if (!stageStreamingYielded) {
960
- stageStreamingYielded = true;
961
- yield { type: 'stage_change', stage: 'streaming' };
962
- }
963
990
  assistantText += ev.delta;
964
- yield { type: 'text', delta: ev.delta };
965
991
  } else if (ev.type === 'tool_call_delta') {
966
992
  mergeToolCallDelta(toolCallsByIndex, ev);
967
993
  } else if (ev.type === 'reasoning_delta') {
@@ -1002,7 +1028,9 @@ export async function* runQuery(
1002
1028
  }
1003
1029
  }
1004
1030
  } catch (e) {
1031
+ trace('model_error');
1005
1032
  if ((e as Error).name === 'AbortError') {
1033
+ yield* retireDraft('interrupted');
1006
1034
  yield interruption();
1007
1035
  return;
1008
1036
  }
@@ -1026,6 +1054,7 @@ export async function* runQuery(
1026
1054
  options.promptCache,
1027
1055
  );
1028
1056
  } catch (error) {
1057
+ yield* retireDraft('interrupted');
1029
1058
  if (signal?.aborted || (error as Error).name === 'AbortError') {
1030
1059
  yield interruption();
1031
1060
  return;
@@ -1057,6 +1086,7 @@ export async function* runQuery(
1057
1086
  }
1058
1087
 
1059
1088
  // code='llm_error':终结性错误,stop_reason='error'
1089
+ yield* retireDraft('interrupted');
1060
1090
  yield {
1061
1091
  type: 'error',
1062
1092
  error: `LLM 调用失败:${(e as Error).message}`,
@@ -1065,7 +1095,9 @@ export async function* runQuery(
1065
1095
  return;
1066
1096
  }
1067
1097
 
1098
+ trace('model_end');
1068
1099
  if (lastFinishReason === 'aborted' || signal?.aborted || options.hasQueuedInput?.()) {
1100
+ yield* retireDraft('interrupted');
1069
1101
  if (options.hasQueuedInput?.() && !signal?.aborted) {
1070
1102
  pushQueuedInputMarker(history);
1071
1103
  yield { type: 'interrupted', reason: 'queued_input' };
@@ -1086,6 +1118,26 @@ export async function* runQuery(
1086
1118
  }
1087
1119
  }
1088
1120
 
1121
+ // A pending review must run before another solving/publishing attempt.
1122
+ // Read-only diagnosis and human questions remain available while stalled.
1123
+ const pendingReview = options.advisorCheckpoints === false ? undefined : advisorCoordinator.pendingReview();
1124
+ if (pendingReview && turn < maxTurns && toolCallsByIndex.length > 0
1125
+ && !toolCallsByIndex.some(tc => ['Advisor', 'AskUserQuestion'].includes(tc.function.name))
1126
+ && toolCallsByIndex.some(tc => tc.function.name === 'PresentResult'
1127
+ || advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find(t => t.name === tc.function.name), { treatVerificationAsReadOnly: true }).mutatesWorkspace
1128
+ || stallGuard.shouldReject(tc))) {
1129
+ yield* retireDraft('advisor_checkpoint');
1130
+ toolCallsByIndex.splice(0, toolCallsByIndex.length, { id: crypto.randomUUID(), type: 'function',
1131
+ function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
1132
+ assistantText = '';
1133
+ }
1134
+ // Count the calls that survived review scheduling, including failed loads.
1135
+ for (const call of toolCallsByIndex) {
1136
+ if (call.function.name !== 'Skill') continue;
1137
+ try {
1138
+ if (JSON.parse(call.function.arguments).name === 'dynamic-ui') diagramPreparationAttempted = true;
1139
+ } catch { /* Ordinary tool validation reports malformed arguments. */ }
1140
+ }
1089
1141
  const assistantMsg: Message = {
1090
1142
  role: 'assistant',
1091
1143
  content: assistantText.length > 0 ? assistantText : null,
@@ -1098,12 +1150,26 @@ export async function* runQuery(
1098
1150
  ...(options.messageProvenance ? { provenance: options.messageProvenance } : {}),
1099
1151
  };
1100
1152
  // 4. 没有工具调用 → 整轮交互结束
1101
- if (toolCallsByIndex.length === 0) {
1153
+ finalResponse: if (toolCallsByIndex.length === 0) {
1154
+ // Repair only private preparation, through the ordinary tool pipeline.
1155
+ // One attempt per turn, including missing/failed skills; never rewind a reply.
1156
+ if (!diagramPreparationAttempted && !options.responseFormat
1157
+ && runtimeContext.profile === 'main' && output.diagrams.length > 0
1158
+ && currentEffectiveToolSet.names.has('Skill') && turn < maxTurns
1159
+ && needsDiagramPreparation(assistantText, userInput)) {
1160
+ diagramPreparationAttempted = true;
1161
+ toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
1162
+ name: 'Skill', arguments: JSON.stringify({ name: 'dynamic-ui' }),
1163
+ } });
1164
+ assistantMsg.content = null;
1165
+ assistantMsg.tool_calls = toolCallsByIndex;
1166
+ break finalResponse;
1167
+ }
1102
1168
  if (!askCorrectionAttempted && runtimeContext.interactiveAsk
1103
1169
  && currentEffectiveToolSet.names.has('AskUserQuestion')
1104
1170
  && needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
1105
1171
  askCorrectionAttempted = true;
1106
- yield { type: 'assistant_draft_discarded', reason: 'interactive_question', textLength: assistantText.length };
1172
+ yield* retireDraft('interactive_question');
1107
1173
  continue;
1108
1174
  }
1109
1175
  const emptyResponse = stripThinkBlocks(assistantText).length === 0;
@@ -1112,12 +1178,11 @@ export async function* runQuery(
1112
1178
  if (pendingDelegations.length && !delegationCorrectionAttempted && turn < maxTurns) {
1113
1179
  delegationCorrectionAttempted = true;
1114
1180
  coordinationText = coordination!.text;
1115
- yield { type: 'assistant_draft_discarded', reason: 'formal_delivery', textLength: assistantText.length };
1181
+ yield* retireDraft('formal_delivery');
1116
1182
  continue;
1117
1183
  }
1118
1184
  const pendingMessages = [...(options.takePendingMessages?.() ?? []), ...await takeAgentNotifications(toolCallContext)];
1119
1185
  if (pendingMessages.length) {
1120
- if (!emptyResponse) history.push(assistantMsg);
1121
1186
  history.push(...pendingMessages);
1122
1187
  continue;
1123
1188
  }
@@ -1131,22 +1196,27 @@ export async function* runQuery(
1131
1196
  && turn < maxTurns
1132
1197
  ) {
1133
1198
  deliveryCorrectionAttempted = true;
1134
- yield {
1135
- type: 'assistant_draft_discarded',
1136
- reason: 'formal_delivery',
1137
- textLength: assistantText.length,
1138
- };
1199
+ yield* retireDraft('formal_delivery');
1139
1200
  continue;
1140
1201
  }
1141
- const deliveryCheckpoint = emptyResponse
1202
+ const deliveryCheckpoint = emptyResponse || options.advisorCheckpoints === false
1142
1203
  ? { allow: true as const }
1143
1204
  : advisorCoordinator.beforeDelivery(assistantText);
1205
+ const fallbackReview = !emptyResponse && options.advisorCheckpoints !== false
1206
+ ? advisorCoordinator.pendingReview(assistantText) : undefined;
1207
+ if (fallbackReview && turn < maxTurns) {
1208
+ yield* retireDraft('advisor_checkpoint');
1209
+ // Reuse the effective tool boundary, permissions, cancellation, events and
1210
+ // persistence. Never call a provider behind the host's tool pipeline.
1211
+ toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
1212
+ name: 'Advisor', arguments: JSON.stringify(fallbackReview),
1213
+ } });
1214
+ assistantMsg.content = null;
1215
+ assistantMsg.tool_calls = toolCallsByIndex;
1216
+ break finalResponse;
1217
+ }
1144
1218
  if (!deliveryCheckpoint.allow && turn < maxTurns) {
1145
- yield {
1146
- type: 'assistant_draft_discarded',
1147
- reason: 'advisor_checkpoint',
1148
- textLength: assistantText.length,
1149
- };
1219
+ yield* retireDraft('advisor_checkpoint');
1150
1220
  continue;
1151
1221
  }
1152
1222
  if (!emptyResponse) {
@@ -1156,19 +1226,103 @@ export async function* runQuery(
1156
1226
  if (emptyResponse) {
1157
1227
  // A no-tool response is already the model's terminal signal. Do not turn it
1158
1228
  // into another model round, and do not persist/broadcast an empty bubble.
1159
- yield {
1160
- type: 'assistant_draft_discarded',
1161
- reason: 'empty_response',
1162
- textLength: assistantText.length,
1163
- };
1229
+ yield* retireDraft('empty_response');
1164
1230
  } else {
1231
+ // Publication is a separate tool-free request. Nothing from preparation
1232
+ // has been streamed, and no gate may retract text after this boundary.
1233
+ let publicationError: unknown;
1234
+ let publicationInterrupted = false;
1235
+ let streamed = false;
1236
+ if (!options.responseFormat && runtimeContext.profile === 'main'
1237
+ && output.finalDelivery === 'stream') {
1238
+ const candidate = assistantText;
1239
+ const candidateFinishReason = lastFinishReason;
1240
+ let publishedText = '';
1241
+ let publicationDone = false;
1242
+ yield { type: 'stage_change', stage: 'streaming' };
1243
+ try {
1244
+ const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText, output);
1245
+ const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
1246
+ if (publicationPlan.overBudget) throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
1247
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1248
+ publicationInterrupted = true;
1249
+ throw new Error('最终发布已取消。');
1250
+ }
1251
+ turnUsage.totalCalls += 1;
1252
+ for await (const event of chatFn({
1253
+ provider,
1254
+ messages: publicationMessages,
1255
+ tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'publication'), signal,
1256
+ conversationMode: runtimeContext.conversationMode ?? 'private',
1257
+ promptCache: options.promptCache,
1258
+ })) {
1259
+ if (event.type === 'done' && !publicationDone) {
1260
+ publicationDone = true;
1261
+ if (typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number') {
1262
+ turnUsage.reportedCalls += 1;
1263
+ turnUsage.inputTokens += event.promptTokens ?? 0;
1264
+ turnUsage.outputTokens += event.outputTokens ?? 0;
1265
+ }
1266
+ }
1267
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1268
+ publicationInterrupted = true;
1269
+ break;
1270
+ }
1271
+ if (event.type === 'text_delta') {
1272
+ // Final expression is live generation, not an exact-copy replay.
1273
+ // Once visible, text is never replaced or joined with a different draft.
1274
+ publishedText += event.delta;
1275
+ streamed = true;
1276
+ yield { type: 'text', delta: event.delta };
1277
+ } else if (event.type === 'tool_call_delta') {
1278
+ throw new Error('最终表达阶段返回了非预期工具调用,未执行该工具。');
1279
+ } else if (event.type === 'done') {
1280
+ publicationDone = true;
1281
+ lastFinishReason = event.stopReason;
1282
+ if (event.stopReason === 'aborted') publicationInterrupted = true;
1283
+ }
1284
+ }
1285
+ if (!publicationDone && !publicationInterrupted) throw new Error('最终答案流未正常结束。');
1286
+ if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length') throw new Error('最终答案流缺少有效的结束状态。');
1287
+ if (!publishedText.trim() && !publicationInterrupted) throw new Error('最终表达未返回正文。');
1288
+ } catch (error) {
1289
+ publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || (error as Error)?.name === 'AbortError';
1290
+ publicationError = error;
1291
+ }
1292
+ if (publicationError && !publicationInterrupted && !publishedText) {
1293
+ yield { type: 'warning', message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。' };
1294
+ yield { type: 'text', delta: candidate };
1295
+ streamed = true;
1296
+ publishedText = candidate;
1297
+ lastFinishReason = candidateFinishReason;
1298
+ publicationError = undefined;
1299
+ }
1300
+ assistantText = publishedText;
1301
+ assistantMsg.content = publishedText || null;
1302
+ if (!publishedText) {
1303
+ if (publicationInterrupted) yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1304
+ else yield { type: 'error', code: 'llm_error', error: `最终答案生成失败:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1305
+ return;
1306
+ }
1307
+ }
1308
+ assistantMsg.presentation = 'final';
1309
+ trace('final_published');
1165
1310
  if (assistantMsg.role === 'assistant') assistantMsg.advisorTaskVersion = advisorCoordinator.versions.taskVersion;
1166
1311
  assistantMsg.advisorCheckpoints = advisorCoordinator.drainDegradations();
1167
1312
  history.push(assistantMsg);
1313
+ // Non-interactive consumers retain atomic output. Interactive hosts
1314
+ // already received real deltas; commit the identical text only once.
1315
+ if (!streamed) yield { type: 'text', delta: assistantText };
1168
1316
  yield {
1169
1317
  type: 'assistant_message',
1170
1318
  message: assistantMsg as Extract<Message, { role: 'assistant' }>,
1171
1319
  };
1320
+ if (publicationInterrupted || publicationError) {
1321
+ yield { type: 'warning', message: '最终答案未完整生成,已保留已输出内容;未撤回或重新生成。' };
1322
+ if (publicationInterrupted) yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1323
+ else yield { type: 'error', code: 'llm_error', error: `最终答案流中断:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1324
+ return;
1325
+ }
1172
1326
  }
1173
1327
  // R2:最终回答被 provider 因 max_tokens 截断(finish_reason='length')时,
1174
1328
  // 不能静默当正常 end_turn 收尾 —— 否则 -p 宿主会拿到半截答案却以为成功。
@@ -1220,7 +1374,11 @@ export async function* runQuery(
1220
1374
  ...turnUsage,
1221
1375
  trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
1222
1376
  };
1223
- if (pendingDelegations.length) {
1377
+ if (emptyResponse && !resultPresented) {
1378
+ const warning = '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1379
+ yield { type: 'warning', message: warning };
1380
+ yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
1381
+ } else if (pendingDelegations.length) {
1224
1382
  const warning = `必要子任务尚未收口:${pendingDelegations.map((r) => r.id).join(', ')}。本轮不能视为全部完成。`;
1225
1383
  yield { type: 'warning', message: warning };
1226
1384
  yield { type: 'turn_done', completionStatus: 'delivery_incomplete', warning, usage };
@@ -1240,11 +1398,14 @@ export async function* runQuery(
1240
1398
  usage,
1241
1399
  };
1242
1400
  } else {
1243
- yield { type: 'turn_done', completionStatus: 'completed', usage };
1401
+ yield { type: 'turn_done', completionStatus: 'completed', usage,
1402
+ ...(emptyResponse ? { warning: '成果已提交,未生成文字总结;请查看已提交的成果。' } : {}) };
1244
1403
  }
1245
1404
  return;
1246
1405
  }
1247
1406
 
1407
+ assistantMsg.presentation = 'progress';
1408
+ trace('progress_published');
1248
1409
  history.push(assistantMsg);
1249
1410
  yield {
1250
1411
  type: 'assistant_message',
@@ -1471,7 +1632,19 @@ export async function* runQuery(
1471
1632
  };
1472
1633
 
1473
1634
  const runOne: typeof runOneImpl = async (tc, reportProgress) => {
1635
+ // Also settle denied/invalid/unavailable calls that never reached the
1636
+ // provider adapter. This records a real attempt, not an ignored reminder.
1637
+ let consultation;
1638
+ if (tc.function.name === 'Advisor') {
1639
+ let reason: import('./types.js').AdvisorReason | undefined;
1640
+ try { reason = JSON.parse(tc.function.arguments).reason; } catch { /* validation handles malformed input */ }
1641
+ consultation = advisorCoordinator.beginConsultation(reason);
1642
+ }
1474
1643
  const outcome = await runOneImpl(tc, reportProgress);
1644
+ if (consultation && outcome.result.metadata?.kind !== 'advisor_consultation'
1645
+ && !( !outcome.result.ok && outcome.result.error === 'aborted')) {
1646
+ outcome.result = { ...outcome.result, metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok) };
1647
+ }
1475
1648
  const metadata = outcome.result.metadata;
1476
1649
  if (metadata?.kind === 'advisor_decision' && outcome.result.ok
1477
1650
  || metadata?.kind === 'advisor_consultation') {
@@ -1788,6 +1961,7 @@ export function buildModelMessages(
1788
1961
  transientContext?: string,
1789
1962
  currentUserMessageId?: string,
1790
1963
  ): Message[] {
1964
+ history = history.filter(message => !(message.role === 'system' && message._ephemeral));
1791
1965
  const transient = transientContext?.trim();
1792
1966
  if (!transient) return history;
1793
1967
  let index = currentUserMessageId
@@ -1,12 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import lockfile from 'proper-lockfile';
3
2
  import { chmod, mkdir, open, readFile, rename, rm, stat, } from 'node:fs/promises';
4
3
  import { dirname, join } from 'node:path';
5
- import { setTimeout as delay } from 'node:timers/promises';
4
+ import { acquireStateFileLock } from '../runtime/fileLock.js';
6
5
  import { agentStateDir } from '../context/agentHome.js';
7
6
  import { CATEGORY_DIR, MEMORY_ID_PATTERN } from './constants.js';
8
- const LOCK_STALE_MS = 120_000;
9
- const LOCK_WAIT_MS = 15_000;
10
7
  let mutationQueue = Promise.resolve();
11
8
  export function memoryRootDir() {
12
9
  return join(agentStateDir(), 'memory');
@@ -54,36 +51,7 @@ export async function pathExists(path) {
54
51
  export async function acquireFileLock(path, signal) {
55
52
  signal?.throwIfAborted();
56
53
  await ensurePrivateDir(dirname(path));
57
- const deadline = Date.now() + LOCK_WAIT_MS;
58
- while (true) {
59
- signal?.throwIfAborted();
60
- try {
61
- // Do not steal old-version file locks. Restart old hosts before upgrading;
62
- // their locks must be reviewed rather than aged out under a live writer.
63
- const legacy = await stat(`${path}.lock`).catch(error => {
64
- if (error.code === 'ENOENT')
65
- return null;
66
- throw error;
67
- });
68
- if (legacy?.isFile())
69
- throw new Error('MEMORY_LEGACY_LOCK:请停止旧版本宿主并检查遗留记忆锁');
70
- const release = await lockfile.lock(path, {
71
- realpath: false, retries: 0, stale: LOCK_STALE_MS, update: 10_000,
72
- });
73
- if (signal?.aborted) {
74
- await release();
75
- signal.throwIfAborted();
76
- }
77
- return release;
78
- }
79
- catch (error) {
80
- if (error.code !== 'ELOCKED')
81
- throw error;
82
- if (Date.now() >= deadline)
83
- throw new Error('等待记忆文件锁超时');
84
- await delay(40, undefined, { signal });
85
- }
86
- }
54
+ return acquireStateFileLock(path, { signal, label: 'MEMORY' });
87
55
  }
88
56
  export async function atomicWriteFile(path, content) {
89
57
  await ensurePrivateDir(dirname(path));
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import lockfile from 'proper-lockfile';
3
2
  import {
4
3
  chmod,
5
4
  mkdir,
@@ -10,14 +9,12 @@ import {
10
9
  stat,
11
10
  } from 'node:fs/promises';
12
11
  import { dirname, join } from 'node:path';
13
- import { setTimeout as delay } from 'node:timers/promises';
12
+ import { acquireStateFileLock } from '../runtime/fileLock.js';
14
13
 
15
14
  import { agentStateDir } from '../context/agentHome.js';
16
15
  import { CATEGORY_DIR, MEMORY_ID_PATTERN } from './constants.js';
17
16
  import type { MemoryRecord, MemoryScope } from './types.js';
18
17
 
19
- const LOCK_STALE_MS = 120_000;
20
- const LOCK_WAIT_MS = 15_000;
21
18
  let mutationQueue: Promise<void> = Promise.resolve();
22
19
 
23
20
  export function memoryRootDir(): string {
@@ -84,28 +81,7 @@ export async function acquireFileLock(
84
81
  ): Promise<() => Promise<void>> {
85
82
  signal?.throwIfAborted();
86
83
  await ensurePrivateDir(dirname(path));
87
- const deadline = Date.now() + LOCK_WAIT_MS;
88
- while (true) {
89
- signal?.throwIfAborted();
90
- try {
91
- // Do not steal old-version file locks. Restart old hosts before upgrading;
92
- // their locks must be reviewed rather than aged out under a live writer.
93
- const legacy = await stat(`${path}.lock`).catch(error => {
94
- if (error.code === 'ENOENT') return null;
95
- throw error;
96
- });
97
- if (legacy?.isFile()) throw new Error('MEMORY_LEGACY_LOCK:请停止旧版本宿主并检查遗留记忆锁');
98
- const release = await lockfile.lock(path, {
99
- realpath: false, retries: 0, stale: LOCK_STALE_MS, update: 10_000,
100
- });
101
- if (signal?.aborted) { await release(); signal.throwIfAborted(); }
102
- return release;
103
- } catch (error) {
104
- if ((error as NodeJS.ErrnoException).code !== 'ELOCKED') throw error;
105
- if (Date.now() >= deadline) throw new Error('等待记忆文件锁超时');
106
- await delay(40, undefined, { signal });
107
- }
108
- }
84
+ return acquireStateFileLock(path, { signal, label: 'MEMORY' });
109
85
  }
110
86
 
111
87
  export async function atomicWriteFile(
@@ -1,6 +1,7 @@
1
1
  import { CODE_REVIEW_RUBRIC } from './codeReview.js';
2
2
  import { capabilityForToolName, } from '../tools/capabilities.js';
3
3
  import { skillInvokeInstruction } from './skillList.js';
4
+ import { visualPresentation } from './visualPresentation.js';
4
5
  export function capabilitiesForTools(tools) {
5
6
  const result = new Set();
6
7
  for (const tool of tools) {
@@ -193,10 +194,13 @@ const CAPABILITY_POLICIES = [
193
194
  render: ({ toolNames }) => `- 需求匹配已安装 Skill 或用户点名时,${skillInvokeInstruction(toolNames.has('Skill'))};前端开发与交互修复也要主动匹配设计技能,不等用户说“设计”。引用资源按需读取,多阶段按需组合,不预读整个目录或加载无关技能。`,
194
195
  },
195
196
  {
196
- id: 'execution.desktop-diagrams', layer: 'execution', profiles: MAIN_PROFILE, source: 'native',
197
- render: ({ surface, toolNames }) => surface === 'desktop'
198
- ? `- Desktop 需要架构、流程或关系图时,${toolNames.has('Skill') || toolNames.has('Read') ? '优先加载可用的 dynamic-ui,按规范输出 svg 围栏;' : ''}少用大段 ASCII 框图。简单文字、清晰表格不画图;用户指定 ASCII 或原样代码/目录树除外。图不可用时用文字/表格,保留关键结论,不伪造控件或成果。`
199
- : '- 纯文本回复不输出 SVG 源码,改用文字或表格。',
197
+ id: 'execution.desktop-diagrams', layer: 'execution', profiles: ALL_PROFILES, source: 'native',
198
+ render: ({ surface, output, toolNames }) => [
199
+ visualPresentation(output ?? surface),
200
+ toolNames.has('Skill') || toolNames.has('Read')
201
+ ? '- 配图前加载可用的 dynamic-ui。'
202
+ : '',
203
+ ].filter(Boolean).join('\n'),
200
204
  },
201
205
  {
202
206
  id: 'capability.session-worktree', layer: 'capability', profiles: MAIN_PROFILE, source: '06',
@@ -246,12 +250,12 @@ const CAPABILITY_POLICIES = [
246
250
  requiresTools: ['Advisor'],
247
251
  // 注意:文本内不得硬编码其他工具名(如 PresentResult),否则该工具缺席时这里
248
252
  // 成为唯一残留的「幽灵引用」——capability 规则交叉引用必须在 toolNames 条件下拼接。
249
- render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:Advisor 是第二意见,主 Agent 自行核实并决定采纳、部分采纳或拒绝;顾问不授予权限,也不替用户验收。
250
- 咨询条件只有:计划准备执行前(reason=plan_review)、实质结果提交前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、自身非常不确定或无法解决(uncertainty)。只读调查不等于计划执行;按问题与阶段咨询,不因命令字符串、工具切换或每次写入重新咨询。
251
- 同阶段同问题已有咨询结果就继续执行;仅新出现的实质问题或两次失败触发再咨询,换工具不清零失败次数,无关失败不合并。
253
+ render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:提供第二意见,由主 Agent 核实取舍;不授予权限、不替用户验收。
254
+ 咨询时机:实质计划执行前(plan_review)、实质交付前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、非常不确定或无法解决(uncertainty)、关键证据冲突无法消解(evidence_conflict)。换工具不清零失败次数,无关失败不合并。
255
+ 文字调研、计算、比较和推荐也是交付;自信、没文件不豁免。自动咨询不再问用户;寒暄、进度、拼写修正无需咨询。
256
+ 同阶段不重复问;成果、假设实质变化或新冲突才重开。questions/note 指明疑点与证据 ID,历史由宿主附带。缺材料先补查,不算验证通过。读完意见再操作;暂缓操作未执行,提醒不算咨询。
252
257
  不设咨询配额,每次咨询只尝试一次;失败、空答或超时自行降级继续,不转成人工审批,也不宣称获得验证。
253
- 依据代码、测试和用户要求判断意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}证据冲突以实测为准,仅无法判断时复核咨询。
254
- 提供关键疑点与必要现场,返回后先读结果再决定后续行动;同批暂缓调用不是执行证据,不因此重复咨询。`,
258
+ 以代码、测试和用户要求核实意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}顾问不是事实来源,冲突以实测为准。`,
255
259
  },
256
260
  {
257
261
  id: 'capability.subagent', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
@@ -6,6 +6,8 @@ import {
6
6
  type CapabilityId,
7
7
  } from '../tools/capabilities.js';
8
8
  import { skillInvokeInstruction } from './skillList.js';
9
+ import { visualPresentation } from './visualPresentation.js';
10
+ import type { OutputContract } from '../runtime/outputContract.js';
9
11
 
10
12
  export type PromptPolicyLayer =
11
13
  | 'authority'
@@ -31,6 +33,7 @@ export type { CapabilityId } from '../tools/capabilities.js';
31
33
  export interface StablePromptContext {
32
34
  profile: AgentProfile;
33
35
  surface: AgentSurface;
36
+ output?: OutputContract;
34
37
  cwd: string;
35
38
  capabilities: ReadonlySet<CapabilityId>;
36
39
  toolNames: ReadonlySet<string>;
@@ -240,10 +243,13 @@ const CAPABILITY_POLICIES: PromptPolicySpec[] = [
240
243
  render: ({ toolNames }) => `- 需求匹配已安装 Skill 或用户点名时,${skillInvokeInstruction(toolNames.has('Skill'))};前端开发与交互修复也要主动匹配设计技能,不等用户说“设计”。引用资源按需读取,多阶段按需组合,不预读整个目录或加载无关技能。`,
241
244
  },
242
245
  {
243
- id: 'execution.desktop-diagrams', layer: 'execution', profiles: MAIN_PROFILE, source: 'native',
244
- render: ({ surface, toolNames }) => surface === 'desktop'
245
- ? `- Desktop 需要架构、流程或关系图时,${toolNames.has('Skill') || toolNames.has('Read') ? '优先加载可用的 dynamic-ui,按规范输出 svg 围栏;' : ''}少用大段 ASCII 框图。简单文字、清晰表格不画图;用户指定 ASCII 或原样代码/目录树除外。图不可用时用文字/表格,保留关键结论,不伪造控件或成果。`
246
- : '- 纯文本回复不输出 SVG 源码,改用文字或表格。',
246
+ id: 'execution.desktop-diagrams', layer: 'execution', profiles: ALL_PROFILES, source: 'native',
247
+ render: ({ surface, output, toolNames }) => [
248
+ visualPresentation(output ?? surface),
249
+ toolNames.has('Skill') || toolNames.has('Read')
250
+ ? '- 配图前加载可用的 dynamic-ui。'
251
+ : '',
252
+ ].filter(Boolean).join('\n'),
247
253
  },
248
254
  {
249
255
  id: 'capability.session-worktree', layer: 'capability', profiles: MAIN_PROFILE, source: '06',
@@ -293,12 +299,12 @@ const CAPABILITY_POLICIES: PromptPolicySpec[] = [
293
299
  requiresTools: ['Advisor'],
294
300
  // 注意:文本内不得硬编码其他工具名(如 PresentResult),否则该工具缺席时这里
295
301
  // 成为唯一残留的「幽灵引用」——capability 规则交叉引用必须在 toolNames 条件下拼接。
296
- render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:Advisor 是第二意见,主 Agent 自行核实并决定采纳、部分采纳或拒绝;顾问不授予权限,也不替用户验收。
297
- 咨询条件只有:计划准备执行前(reason=plan_review)、实质结果提交前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、自身非常不确定或无法解决(uncertainty)。只读调查不等于计划执行;按问题与阶段咨询,不因命令字符串、工具切换或每次写入重新咨询。
298
- 同阶段同问题已有咨询结果就继续执行;仅新出现的实质问题或两次失败触发再咨询,换工具不清零失败次数,无关失败不合并。
302
+ render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:提供第二意见,由主 Agent 核实取舍;不授予权限、不替用户验收。
303
+ 咨询时机:实质计划执行前(plan_review)、实质交付前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、非常不确定或无法解决(uncertainty)、关键证据冲突无法消解(evidence_conflict)。换工具不清零失败次数,无关失败不合并。
304
+ 文字调研、计算、比较和推荐也是交付;自信、没文件不豁免。自动咨询不再问用户;寒暄、进度、拼写修正无需咨询。
305
+ 同阶段不重复问;成果、假设实质变化或新冲突才重开。questions/note 指明疑点与证据 ID,历史由宿主附带。缺材料先补查,不算验证通过。读完意见再操作;暂缓操作未执行,提醒不算咨询。
299
306
  不设咨询配额,每次咨询只尝试一次;失败、空答或超时自行降级继续,不转成人工审批,也不宣称获得验证。
300
- 依据代码、测试和用户要求判断意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}证据冲突以实测为准,仅无法判断时复核咨询。
301
- 提供关键疑点与必要现场,返回后先读结果再决定后续行动;同批暂缓调用不是执行证据,不因此重复咨询。`,
307
+ 以代码、测试和用户要求核实意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}顾问不是事实来源,冲突以实测为准。`,
302
308
  },
303
309
  {
304
310
  id: 'capability.subagent', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',