minimal-agent 0.18.6 → 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 (61) 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/tokenCounter.js +3 -0
  26. package/src/context/tokenCounter.ts +2 -0
  27. package/src/llm/messageProvenance.js +1 -1
  28. package/src/llm/messageProvenance.ts +1 -1
  29. package/src/loop.js +203 -30
  30. package/src/loop.ts +183 -30
  31. package/src/prompts/policyRegistry.js +17 -9
  32. package/src/prompts/policyRegistry.ts +17 -9
  33. package/src/runtime/answerPreparation.js +9 -0
  34. package/src/runtime/answerPreparation.ts +11 -0
  35. package/src/runtime/eventSemantics.js +2 -0
  36. package/src/runtime/eventSemantics.ts +2 -0
  37. package/src/runtime/finalAnswer.js +7 -0
  38. package/src/runtime/finalAnswer.ts +9 -0
  39. package/src/runtime/lifecycleTrace.js +16 -0
  40. package/src/runtime/lifecycleTrace.ts +22 -0
  41. package/src/session/runTurn.js +15 -1
  42. package/src/session/runTurn.ts +13 -1
  43. package/src/tools/advisor/advisor.js +56 -40
  44. package/src/tools/advisor/advisor.ts +57 -39
  45. package/src/tools/advisor/advisorDecision.js +6 -3
  46. package/src/tools/advisor/advisorDecision.ts +6 -3
  47. package/src/tools/agent/agent.js +7 -1
  48. package/src/tools/agent/agent.ts +7 -1
  49. package/src/tools/bash/semantics.js +91 -26
  50. package/src/tools/bash/semantics.ts +63 -33
  51. package/src/tools/index.js +4 -1
  52. package/src/tools/index.ts +4 -1
  53. package/src/types.ts +15 -6
  54. package/src/ui/LiveArea.js +2 -1
  55. package/src/ui/LiveArea.tsx +2 -1
  56. package/src/ui/MessageList.js +18 -5
  57. package/src/ui/MessageList.tsx +25 -3
  58. package/src/ui/draftNotice.js +1 -8
  59. package/src/ui/draftNotice.ts +1 -10
  60. package/src/ui/hooks/useChat.js +25 -1
  61. package/src/ui/hooks/useChat.ts +22 -1
package/src/loop.ts CHANGED
@@ -28,6 +28,9 @@
28
28
  */
29
29
 
30
30
  import crypto from 'node:crypto';
31
+ import { answerPreparationNotice } from './runtime/answerPreparation.js';
32
+ import { finalAnswerMessages } from './runtime/finalAnswer.js';
33
+ import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
31
34
  import { conciseToolPath } from './runtime/toolDisplay.js';
32
35
  import { dirname, resolve } from 'node:path';
33
36
  import { loadProjectPromptInstructions } from './prompts/projectInstructions.js';
@@ -100,6 +103,7 @@ import {
100
103
  } from './advisor/advisorCoordinator.js';
101
104
  import { toolEffectFor } from './tools/capabilities.js';
102
105
  import { isReadOnlyShellCommand, isVerificationShellCommand } from './tools/bash/semantics.js';
106
+ import { collectReviewContext } from './advisor/reviewContext.js';
103
107
  import {
104
108
  createAgentRuntimeContext,
105
109
  type AgentRuntimeContext,
@@ -410,11 +414,13 @@ export async function* runQuery(
410
414
  if (!computerIntent.needed && sessionState) delete sessionState.computerAutomation;
411
415
  // DI fallback:测试可传 stub,生产走真实模块单例
412
416
  const chatFn = options.chat ?? defaultChat;
417
+ const trace = createLifecycleTrace('loop');
413
418
  const executeToolFn = options.executeTool ?? defaultExecuteTool;
414
419
  const explicitFileDeliveryRequested = expectsFormalDelivery(userInput);
415
420
  const currentUserMessageId = options.userMessageId ?? crypto.randomUUID();
416
421
 
417
422
  let currentTransientBundle = options.transientBundle;
423
+ let advisorContextBundle = currentTransientBundle;
418
424
  let modelEnvironmentCwd = runtimeContext.cwd;
419
425
  const observedRuleScopes = new Map<string, string>();
420
426
  const pendingRuleScopes = new Set<string>();
@@ -435,6 +441,7 @@ export async function* runQuery(
435
441
  const selected = availableForBundle >= bundle.diagnostics.usedTokens
436
442
  ? bundle
437
443
  : resizeContextBundle(bundle, availableForBundle);
444
+ advisorContextBundle = selected;
438
445
  return [selected.text, opaque].filter(Boolean).join('\n\n');
439
446
  };
440
447
  let resultPresented = false;
@@ -452,6 +459,16 @@ export async function* runQuery(
452
459
  executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
453
460
  history,
454
461
  sessionState,
462
+ advisorReviewContext: () => ({
463
+ ...collectReviewContext(advisorContextBundle),
464
+ constraints: [...collectReviewContext(advisorContextBundle).constraints,
465
+ { id: 'execution.boundary', authority: 'host', content: JSON.stringify({ cwd: sessionState?.activeWorktree?.path ?? toolCallContext.cwd,
466
+ conversationMode: toolCallContext.conversationMode, profile: runtimeContext.profile,
467
+ availableTools: [...currentEffectiveToolSet.names], invocation: invocationInstructions(options.invocationPolicy) }) }],
468
+ omittedIds: [...new Set([...(currentTransientBundle?.diagnostics.omittedIds ?? []), ...(advisorContextBundle?.diagnostics.omittedIds ?? [])]),
469
+ ...(options.transientContext ? ['legacy.transient-context:not-forwarded'] : [])],
470
+ clippedIds: [...new Set([...(currentTransientBundle?.diagnostics.clippedIds ?? []), ...(advisorContextBundle?.diagnostics.clippedIds ?? [])])],
471
+ }),
455
472
  // Statistics only; consultations have no per-turn quota.
456
473
  advisorCalls: { used: 0 },
457
474
  advisorInvocation: 'agent',
@@ -910,7 +927,17 @@ export async function* runQuery(
910
927
  }
911
928
 
912
929
  // 3. 调 LLM 并组装 assistant 消息
930
+ trace('model_start');
913
931
  let assistantText = '';
932
+ // Candidate text is private until the delivery gates allow publication.
933
+ // Keep the compatibility event as a phase notice, never as a text retraction.
934
+ function* retireDraft(reason: Extract<LoopEvent, { type: 'assistant_draft_discarded' }>['reason'] | 'interrupted'): Generator<LoopEvent> {
935
+ const message: Message = { role: 'system', content: answerPreparationNotice(reason), id: crypto.randomUUID(),
936
+ timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
937
+ history.push(message);
938
+ yield { type: 'injected_message', message };
939
+ if (reason !== 'interrupted') yield { type: 'assistant_draft_discarded', reason, textLength: 0 };
940
+ }
914
941
  /** 按 index 累积 tool_calls(OpenAI 协议下 tool_calls 是分片返回的) */
915
942
  const toolCallsByIndex: ToolCall[] = [];
916
943
  /**
@@ -921,9 +948,6 @@ export async function* runQuery(
921
948
  let reasoningString = '';
922
949
  const reasoningDetails: unknown[] = [];
923
950
 
924
- /** 首个 text_delta 时切到 streaming,再来不重复 yield */
925
- let stageStreamingYielded = false;
926
-
927
951
  /**
928
952
  * R2:本轮 LLM 流的终止原因(来自 client 的 done.stopReason)。
929
953
  * 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
@@ -956,12 +980,7 @@ export async function* runQuery(
956
980
  : {}),
957
981
  })) {
958
982
  if (ev.type === 'text_delta') {
959
- if (!stageStreamingYielded) {
960
- stageStreamingYielded = true;
961
- yield { type: 'stage_change', stage: 'streaming' };
962
- }
963
983
  assistantText += ev.delta;
964
- yield { type: 'text', delta: ev.delta };
965
984
  } else if (ev.type === 'tool_call_delta') {
966
985
  mergeToolCallDelta(toolCallsByIndex, ev);
967
986
  } else if (ev.type === 'reasoning_delta') {
@@ -1002,7 +1021,9 @@ export async function* runQuery(
1002
1021
  }
1003
1022
  }
1004
1023
  } catch (e) {
1024
+ trace('model_error');
1005
1025
  if ((e as Error).name === 'AbortError') {
1026
+ yield* retireDraft('interrupted');
1006
1027
  yield interruption();
1007
1028
  return;
1008
1029
  }
@@ -1026,6 +1047,7 @@ export async function* runQuery(
1026
1047
  options.promptCache,
1027
1048
  );
1028
1049
  } catch (error) {
1050
+ yield* retireDraft('interrupted');
1029
1051
  if (signal?.aborted || (error as Error).name === 'AbortError') {
1030
1052
  yield interruption();
1031
1053
  return;
@@ -1057,6 +1079,7 @@ export async function* runQuery(
1057
1079
  }
1058
1080
 
1059
1081
  // code='llm_error':终结性错误,stop_reason='error'
1082
+ yield* retireDraft('interrupted');
1060
1083
  yield {
1061
1084
  type: 'error',
1062
1085
  error: `LLM 调用失败:${(e as Error).message}`,
@@ -1065,7 +1088,9 @@ export async function* runQuery(
1065
1088
  return;
1066
1089
  }
1067
1090
 
1091
+ trace('model_end');
1068
1092
  if (lastFinishReason === 'aborted' || signal?.aborted || options.hasQueuedInput?.()) {
1093
+ yield* retireDraft('interrupted');
1069
1094
  if (options.hasQueuedInput?.() && !signal?.aborted) {
1070
1095
  pushQueuedInputMarker(history);
1071
1096
  yield { type: 'interrupted', reason: 'queued_input' };
@@ -1086,6 +1111,19 @@ export async function* runQuery(
1086
1111
  }
1087
1112
  }
1088
1113
 
1114
+ // A pending review must run before another solving/publishing attempt.
1115
+ // Read-only diagnosis and human questions remain available while stalled.
1116
+ const pendingReview = options.advisorCheckpoints === false ? undefined : advisorCoordinator.pendingReview();
1117
+ if (pendingReview && turn < maxTurns && toolCallsByIndex.length > 0
1118
+ && !toolCallsByIndex.some(tc => ['Advisor', 'AskUserQuestion'].includes(tc.function.name))
1119
+ && toolCallsByIndex.some(tc => tc.function.name === 'PresentResult'
1120
+ || advisorSemanticsFor(tc, toolCallContext, currentEffectiveToolSet.tools.find(t => t.name === tc.function.name), { treatVerificationAsReadOnly: true }).mutatesWorkspace
1121
+ || stallGuard.shouldReject(tc))) {
1122
+ yield* retireDraft('advisor_checkpoint');
1123
+ toolCallsByIndex.splice(0, toolCallsByIndex.length, { id: crypto.randomUUID(), type: 'function',
1124
+ function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
1125
+ assistantText = '';
1126
+ }
1089
1127
  const assistantMsg: Message = {
1090
1128
  role: 'assistant',
1091
1129
  content: assistantText.length > 0 ? assistantText : null,
@@ -1098,12 +1136,12 @@ export async function* runQuery(
1098
1136
  ...(options.messageProvenance ? { provenance: options.messageProvenance } : {}),
1099
1137
  };
1100
1138
  // 4. 没有工具调用 → 整轮交互结束
1101
- if (toolCallsByIndex.length === 0) {
1139
+ finalResponse: if (toolCallsByIndex.length === 0) {
1102
1140
  if (!askCorrectionAttempted && runtimeContext.interactiveAsk
1103
1141
  && currentEffectiveToolSet.names.has('AskUserQuestion')
1104
1142
  && needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
1105
1143
  askCorrectionAttempted = true;
1106
- yield { type: 'assistant_draft_discarded', reason: 'interactive_question', textLength: assistantText.length };
1144
+ yield* retireDraft('interactive_question');
1107
1145
  continue;
1108
1146
  }
1109
1147
  const emptyResponse = stripThinkBlocks(assistantText).length === 0;
@@ -1112,12 +1150,11 @@ export async function* runQuery(
1112
1150
  if (pendingDelegations.length && !delegationCorrectionAttempted && turn < maxTurns) {
1113
1151
  delegationCorrectionAttempted = true;
1114
1152
  coordinationText = coordination!.text;
1115
- yield { type: 'assistant_draft_discarded', reason: 'formal_delivery', textLength: assistantText.length };
1153
+ yield* retireDraft('formal_delivery');
1116
1154
  continue;
1117
1155
  }
1118
1156
  const pendingMessages = [...(options.takePendingMessages?.() ?? []), ...await takeAgentNotifications(toolCallContext)];
1119
1157
  if (pendingMessages.length) {
1120
- if (!emptyResponse) history.push(assistantMsg);
1121
1158
  history.push(...pendingMessages);
1122
1159
  continue;
1123
1160
  }
@@ -1131,22 +1168,27 @@ export async function* runQuery(
1131
1168
  && turn < maxTurns
1132
1169
  ) {
1133
1170
  deliveryCorrectionAttempted = true;
1134
- yield {
1135
- type: 'assistant_draft_discarded',
1136
- reason: 'formal_delivery',
1137
- textLength: assistantText.length,
1138
- };
1171
+ yield* retireDraft('formal_delivery');
1139
1172
  continue;
1140
1173
  }
1141
- const deliveryCheckpoint = emptyResponse
1174
+ const deliveryCheckpoint = emptyResponse || options.advisorCheckpoints === false
1142
1175
  ? { allow: true as const }
1143
1176
  : advisorCoordinator.beforeDelivery(assistantText);
1177
+ const fallbackReview = !emptyResponse && options.advisorCheckpoints !== false
1178
+ ? advisorCoordinator.pendingReview(assistantText) : undefined;
1179
+ if (fallbackReview && turn < maxTurns) {
1180
+ yield* retireDraft('advisor_checkpoint');
1181
+ // Reuse the effective tool boundary, permissions, cancellation, events and
1182
+ // persistence. Never call a provider behind the host's tool pipeline.
1183
+ toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
1184
+ name: 'Advisor', arguments: JSON.stringify(fallbackReview),
1185
+ } });
1186
+ assistantMsg.content = null;
1187
+ assistantMsg.tool_calls = toolCallsByIndex;
1188
+ break finalResponse;
1189
+ }
1144
1190
  if (!deliveryCheckpoint.allow && turn < maxTurns) {
1145
- yield {
1146
- type: 'assistant_draft_discarded',
1147
- reason: 'advisor_checkpoint',
1148
- textLength: assistantText.length,
1149
- };
1191
+ yield* retireDraft('advisor_checkpoint');
1150
1192
  continue;
1151
1193
  }
1152
1194
  if (!emptyResponse) {
@@ -1156,19 +1198,110 @@ export async function* runQuery(
1156
1198
  if (emptyResponse) {
1157
1199
  // A no-tool response is already the model's terminal signal. Do not turn it
1158
1200
  // into another model round, and do not persist/broadcast an empty bubble.
1159
- yield {
1160
- type: 'assistant_draft_discarded',
1161
- reason: 'empty_response',
1162
- textLength: assistantText.length,
1163
- };
1201
+ yield* retireDraft('empty_response');
1164
1202
  } else {
1203
+ // Publication is a separate tool-free request. Nothing from preparation
1204
+ // has been streamed, and no gate may retract text after this boundary.
1205
+ let publicationError: unknown;
1206
+ let publicationInterrupted = false;
1207
+ let streamed = false;
1208
+ if (!options.responseFormat && runtimeContext.profile === 'main'
1209
+ && (runtimeContext.surface === 'desktop' || runtimeContext.surface === 'tui')) {
1210
+ const candidate = assistantText;
1211
+ const candidateFinishReason = lastFinishReason;
1212
+ let publishedText = '';
1213
+ let publicationDone = false;
1214
+ yield { type: 'stage_change', stage: 'streaming' };
1215
+ try {
1216
+ const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText);
1217
+ const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
1218
+ if (publicationPlan.overBudget) throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
1219
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1220
+ publicationInterrupted = true;
1221
+ throw new Error('最终发布已取消。');
1222
+ }
1223
+ turnUsage.totalCalls += 1;
1224
+ for await (const event of chatFn({
1225
+ provider,
1226
+ messages: publicationMessages,
1227
+ tools: [], signal,
1228
+ conversationMode: runtimeContext.conversationMode ?? 'private',
1229
+ promptCache: options.promptCache,
1230
+ })) {
1231
+ if (event.type === 'done' && !publicationDone) {
1232
+ publicationDone = true;
1233
+ if (typeof event.promptTokens === 'number' || typeof event.outputTokens === 'number') {
1234
+ turnUsage.reportedCalls += 1;
1235
+ turnUsage.inputTokens += event.promptTokens ?? 0;
1236
+ turnUsage.outputTokens += event.outputTokens ?? 0;
1237
+ }
1238
+ }
1239
+ if (signal?.aborted || options.hasQueuedInput?.()) {
1240
+ publicationInterrupted = true;
1241
+ break;
1242
+ }
1243
+ if (event.type === 'text_delta') {
1244
+ // Never expose a rewrite of the reviewed candidate. Previously
1245
+ // published chunks are necessarily an exact prefix of it.
1246
+ if (!candidate.startsWith(publishedText + event.delta)) {
1247
+ throw new Error('发布内容偏离定稿,使用原定稿。');
1248
+ }
1249
+ publishedText += event.delta;
1250
+ streamed = true;
1251
+ yield { type: 'text', delta: event.delta };
1252
+ } else if (event.type === 'tool_call_delta') {
1253
+ throw new Error('最终表达阶段返回了非预期工具调用,未执行该工具。');
1254
+ } else if (event.type === 'done') {
1255
+ publicationDone = true;
1256
+ lastFinishReason = event.stopReason;
1257
+ if (event.stopReason === 'aborted') publicationInterrupted = true;
1258
+ }
1259
+ }
1260
+ if (!publicationDone && !publicationInterrupted) throw new Error('最终答案流未正常结束。');
1261
+ if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length') throw new Error('最终答案流缺少有效的结束状态。');
1262
+ if (!publishedText.trim() && !publicationInterrupted) throw new Error('最终表达未返回正文。');
1263
+ if (!publicationInterrupted && publishedText !== candidate) throw new Error('最终表达未完整传递定稿。');
1264
+ } catch (error) {
1265
+ publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || (error as Error)?.name === 'AbortError';
1266
+ publicationError = error;
1267
+ }
1268
+ if (publicationError && !publicationInterrupted) {
1269
+ yield { type: 'warning', message: '流式发布未完成,已使用原定稿补齐;未重新生成或改写答案。' };
1270
+ const remaining = candidate.slice(publishedText.length);
1271
+ if (remaining) {
1272
+ yield { type: 'text', delta: remaining };
1273
+ streamed = true;
1274
+ }
1275
+ publishedText = candidate;
1276
+ publicationError = undefined;
1277
+ }
1278
+ assistantText = publishedText;
1279
+ if (!publicationInterrupted) lastFinishReason = candidateFinishReason;
1280
+ assistantMsg.content = publishedText || null;
1281
+ if (!publishedText) {
1282
+ if (publicationInterrupted) yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1283
+ else yield { type: 'error', code: 'llm_error', error: `最终答案生成失败:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1284
+ return;
1285
+ }
1286
+ }
1287
+ assistantMsg.presentation = 'final';
1288
+ trace('final_published');
1165
1289
  if (assistantMsg.role === 'assistant') assistantMsg.advisorTaskVersion = advisorCoordinator.versions.taskVersion;
1166
1290
  assistantMsg.advisorCheckpoints = advisorCoordinator.drainDegradations();
1167
1291
  history.push(assistantMsg);
1292
+ // Non-interactive consumers retain atomic output. Interactive hosts
1293
+ // already received real deltas; commit the identical text only once.
1294
+ if (!streamed) yield { type: 'text', delta: assistantText };
1168
1295
  yield {
1169
1296
  type: 'assistant_message',
1170
1297
  message: assistantMsg as Extract<Message, { role: 'assistant' }>,
1171
1298
  };
1299
+ if (publicationInterrupted || publicationError) {
1300
+ yield { type: 'warning', message: '最终答案未完整生成,已保留已输出内容;未撤回或重新生成。' };
1301
+ if (publicationInterrupted) yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1302
+ else yield { type: 'error', code: 'llm_error', error: `最终答案流中断:${String(publicationError)}`, usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
1303
+ return;
1304
+ }
1172
1305
  }
1173
1306
  // R2:最终回答被 provider 因 max_tokens 截断(finish_reason='length')时,
1174
1307
  // 不能静默当正常 end_turn 收尾 —— 否则 -p 宿主会拿到半截答案却以为成功。
@@ -1220,7 +1353,11 @@ export async function* runQuery(
1220
1353
  ...turnUsage,
1221
1354
  trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
1222
1355
  };
1223
- if (pendingDelegations.length) {
1356
+ if (emptyResponse && !resultPresented) {
1357
+ const warning = '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1358
+ yield { type: 'warning', message: warning };
1359
+ yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
1360
+ } else if (pendingDelegations.length) {
1224
1361
  const warning = `必要子任务尚未收口:${pendingDelegations.map((r) => r.id).join(', ')}。本轮不能视为全部完成。`;
1225
1362
  yield { type: 'warning', message: warning };
1226
1363
  yield { type: 'turn_done', completionStatus: 'delivery_incomplete', warning, usage };
@@ -1240,11 +1377,14 @@ export async function* runQuery(
1240
1377
  usage,
1241
1378
  };
1242
1379
  } else {
1243
- yield { type: 'turn_done', completionStatus: 'completed', usage };
1380
+ yield { type: 'turn_done', completionStatus: 'completed', usage,
1381
+ ...(emptyResponse ? { warning: '成果已提交,未生成文字总结;请查看已提交的成果。' } : {}) };
1244
1382
  }
1245
1383
  return;
1246
1384
  }
1247
1385
 
1386
+ assistantMsg.presentation = 'progress';
1387
+ trace('progress_published');
1248
1388
  history.push(assistantMsg);
1249
1389
  yield {
1250
1390
  type: 'assistant_message',
@@ -1471,7 +1611,19 @@ export async function* runQuery(
1471
1611
  };
1472
1612
 
1473
1613
  const runOne: typeof runOneImpl = async (tc, reportProgress) => {
1614
+ // Also settle denied/invalid/unavailable calls that never reached the
1615
+ // provider adapter. This records a real attempt, not an ignored reminder.
1616
+ let consultation;
1617
+ if (tc.function.name === 'Advisor') {
1618
+ let reason: import('./types.js').AdvisorReason | undefined;
1619
+ try { reason = JSON.parse(tc.function.arguments).reason; } catch { /* validation handles malformed input */ }
1620
+ consultation = advisorCoordinator.beginConsultation(reason);
1621
+ }
1474
1622
  const outcome = await runOneImpl(tc, reportProgress);
1623
+ if (consultation && outcome.result.metadata?.kind !== 'advisor_consultation'
1624
+ && !( !outcome.result.ok && outcome.result.error === 'aborted')) {
1625
+ outcome.result = { ...outcome.result, metadata: advisorCoordinator.finishConsultation(consultation, outcome.result.ok) };
1626
+ }
1475
1627
  const metadata = outcome.result.metadata;
1476
1628
  if (metadata?.kind === 'advisor_decision' && outcome.result.ok
1477
1629
  || metadata?.kind === 'advisor_consultation') {
@@ -1788,6 +1940,7 @@ export function buildModelMessages(
1788
1940
  transientContext?: string,
1789
1941
  currentUserMessageId?: string,
1790
1942
  ): Message[] {
1943
+ history = history.filter(message => !(message.role === 'system' && message._ephemeral));
1791
1944
  const transient = transientContext?.trim();
1792
1945
  if (!transient) return history;
1793
1946
  let index = currentUserMessageId
@@ -193,10 +193,18 @@ const CAPABILITY_POLICIES = [
193
193
  render: ({ toolNames }) => `- 需求匹配已安装 Skill 或用户点名时,${skillInvokeInstruction(toolNames.has('Skill'))};前端开发与交互修复也要主动匹配设计技能,不等用户说“设计”。引用资源按需读取,多阶段按需组合,不预读整个目录或加载无关技能。`,
194
194
  },
195
195
  {
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 源码,改用文字或表格。',
196
+ id: 'execution.desktop-diagrams', layer: 'execution', profiles: ALL_PROFILES, source: 'native',
197
+ render: ({ surface, toolNames }) => [
198
+ '- 按需配图,优先 Mermaid/SVG;用户指定 ASCII、代码/目录树除外。',
199
+ surface === 'desktop'
200
+ ? '- Desktop/Web 聊天支持 mermaid 和 svg 围栏,不用裸 HTML。'
201
+ : surface === 'cli' || surface === 'tui'
202
+ ? '- CLI/TUI 直接回复用文字/表格/紧凑 ASCII,生成文件仍优先 Mermaid/SVG。'
203
+ : '- 跟随最终阅读载体,不等同于终端:Web 用 mermaid/svg 围栏;保持协议契约,未知不假定可渲染。',
204
+ toolNames.has('Skill') || toolNames.has('Read')
205
+ ? '- 加载可用的 dynamic-ui。'
206
+ : '',
207
+ ].filter(Boolean).join('\n'),
200
208
  },
201
209
  {
202
210
  id: 'capability.session-worktree', layer: 'capability', profiles: MAIN_PROFILE, source: '06',
@@ -246,12 +254,12 @@ const CAPABILITY_POLICIES = [
246
254
  requiresTools: ['Advisor'],
247
255
  // 注意:文本内不得硬编码其他工具名(如 PresentResult),否则该工具缺席时这里
248
256
  // 成为唯一残留的「幽灵引用」——capability 规则交叉引用必须在 toolNames 条件下拼接。
249
- render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:Advisor 是第二意见,主 Agent 自行核实并决定采纳、部分采纳或拒绝;顾问不授予权限,也不替用户验收。
250
- 咨询条件只有:计划准备执行前(reason=plan_review)、实质结果提交前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、自身非常不确定或无法解决(uncertainty)。只读调查不等于计划执行;按问题与阶段咨询,不因命令字符串、工具切换或每次写入重新咨询。
251
- 同阶段同问题已有咨询结果就继续执行;仅新出现的实质问题或两次失败触发再咨询,换工具不清零失败次数,无关失败不合并。
257
+ render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:提供第二意见,由主 Agent 核实取舍;不授予权限、不替用户验收。
258
+ 咨询时机:实质计划执行前(plan_review)、实质交付前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、非常不确定或无法解决(uncertainty)、关键证据冲突无法消解(evidence_conflict)。换工具不清零失败次数,无关失败不合并。
259
+ 文字调研、计算、比较和推荐也是交付;自信、没文件不豁免。自动咨询不再问用户;寒暄、进度、拼写修正无需咨询。
260
+ 同阶段不重复问;成果、假设实质变化或新冲突才重开。questions/note 指明疑点与证据 ID,历史由宿主附带。缺材料先补查,不算验证通过。读完意见再操作;暂缓操作未执行,提醒不算咨询。
252
261
  不设咨询配额,每次咨询只尝试一次;失败、空答或超时自行降级继续,不转成人工审批,也不宣称获得验证。
253
- 依据代码、测试和用户要求判断意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}证据冲突以实测为准,仅无法判断时复核咨询。
254
- 提供关键疑点与必要现场,返回后先读结果再决定后续行动;同批暂缓调用不是执行证据,不因此重复咨询。`,
262
+ 以代码、测试和用户要求核实意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}顾问不是事实来源,冲突以实测为准。`,
255
263
  },
256
264
  {
257
265
  id: 'capability.subagent', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
@@ -240,10 +240,18 @@ const CAPABILITY_POLICIES: PromptPolicySpec[] = [
240
240
  render: ({ toolNames }) => `- 需求匹配已安装 Skill 或用户点名时,${skillInvokeInstruction(toolNames.has('Skill'))};前端开发与交互修复也要主动匹配设计技能,不等用户说“设计”。引用资源按需读取,多阶段按需组合,不预读整个目录或加载无关技能。`,
241
241
  },
242
242
  {
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 源码,改用文字或表格。',
243
+ id: 'execution.desktop-diagrams', layer: 'execution', profiles: ALL_PROFILES, source: 'native',
244
+ render: ({ surface, toolNames }) => [
245
+ '- 按需配图,优先 Mermaid/SVG;用户指定 ASCII、代码/目录树除外。',
246
+ surface === 'desktop'
247
+ ? '- Desktop/Web 聊天支持 mermaid 和 svg 围栏,不用裸 HTML。'
248
+ : surface === 'cli' || surface === 'tui'
249
+ ? '- CLI/TUI 直接回复用文字/表格/紧凑 ASCII,生成文件仍优先 Mermaid/SVG。'
250
+ : '- 跟随最终阅读载体,不等同于终端:Web 用 mermaid/svg 围栏;保持协议契约,未知不假定可渲染。',
251
+ toolNames.has('Skill') || toolNames.has('Read')
252
+ ? '- 加载可用的 dynamic-ui。'
253
+ : '',
254
+ ].filter(Boolean).join('\n'),
247
255
  },
248
256
  {
249
257
  id: 'capability.session-worktree', layer: 'capability', profiles: MAIN_PROFILE, source: '06',
@@ -293,12 +301,12 @@ const CAPABILITY_POLICIES: PromptPolicySpec[] = [
293
301
  requiresTools: ['Advisor'],
294
302
  // 注意:文本内不得硬编码其他工具名(如 PresentResult),否则该工具缺席时这里
295
303
  // 成为唯一残留的「幽灵引用」——capability 规则交叉引用必须在 toolNames 条件下拼接。
296
- render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:Advisor 是第二意见,主 Agent 自行核实并决定采纳、部分采纳或拒绝;顾问不授予权限,也不替用户验收。
297
- 咨询条件只有:计划准备执行前(reason=plan_review)、实质结果提交前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、自身非常不确定或无法解决(uncertainty)。只读调查不等于计划执行;按问题与阶段咨询,不因命令字符串、工具切换或每次写入重新咨询。
298
- 同阶段同问题已有咨询结果就继续执行;仅新出现的实质问题或两次失败触发再咨询,换工具不清零失败次数,无关失败不合并。
304
+ render: ({ toolNames }) => `- Advisor(顾问模型)使用规则:提供第二意见,由主 Agent 核实取舍;不授予权限、不替用户验收。
305
+ 咨询时机:实质计划执行前(plan_review)、实质交付前(delivery_review)、同一问题两次或以上处理失败后(stalled)、用户明显不满意(user_dissatisfaction)、非常不确定或无法解决(uncertainty)、关键证据冲突无法消解(evidence_conflict)。换工具不清零失败次数,无关失败不合并。
306
+ 文字调研、计算、比较和推荐也是交付;自信、没文件不豁免。自动咨询不再问用户;寒暄、进度、拼写修正无需咨询。
307
+ 同阶段不重复问;成果、假设实质变化或新冲突才重开。questions/note 指明疑点与证据 ID,历史由宿主附带。缺材料先补查,不算验证通过。读完意见再操作;暂缓操作未执行,提醒不算咨询。
299
308
  不设咨询配额,每次咨询只尝试一次;失败、空答或超时自行降级继续,不转成人工审批,也不宣称获得验证。
300
- 依据代码、测试和用户要求判断意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}证据冲突以实测为准,仅无法判断时复核咨询。
301
- 提供关键疑点与必要现场,返回后先读结果再决定后续行动;同批暂缓调用不是执行证据,不因此重复咨询。`,
309
+ 以代码、测试和用户要求核实意见,${toolNames.has('AdvisorDecision') ? '可用 AdvisorDecision 记录 accept/partial/reject;登记失败不阻止执行。' : '直接说明采纳判断。'}顾问不是事实来源,冲突以实测为准。`,
302
310
  },
303
311
  {
304
312
  id: 'capability.subagent', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
@@ -0,0 +1,9 @@
1
+ export function answerPreparationNotice(reason) {
2
+ switch (reason) {
3
+ case 'advisor_checkpoint': return '交付前复核:正在核查内部候选结论,完成后发布最终答案。';
4
+ case 'formal_delivery': return '整理交付:正在核对委派结果或补齐正式成果提交。';
5
+ case 'interactive_question': return '等待关键决策:接下来通过交互问题确认,请在输入区作答。';
6
+ case 'empty_response': return '本轮未产生可显示的正文,未生成新的最终答复。';
7
+ case 'interrupted': return '生成未完成,未发布最终答案。';
8
+ }
9
+ }
@@ -0,0 +1,11 @@
1
+ import type { LoopEvent } from '../types.js';
2
+
3
+ export function answerPreparationNotice(reason: Extract<LoopEvent, { type: 'assistant_draft_discarded' }>['reason'] | 'interrupted'): string {
4
+ switch (reason) {
5
+ case 'advisor_checkpoint': return '交付前复核:正在核查内部候选结论,完成后发布最终答案。';
6
+ case 'formal_delivery': return '整理交付:正在核对委派结果或补齐正式成果提交。';
7
+ case 'interactive_question': return '等待关键决策:接下来通过交互问题确认,请在输入区作答。';
8
+ case 'empty_response': return '本轮未产生可显示的正文,未生成新的最终答复。';
9
+ case 'interrupted': return '生成未完成,未发布最终答案。';
10
+ }
11
+ }
@@ -4,6 +4,8 @@ export function isRecoverableLoopError(event) {
4
4
  }
5
5
  export function completionWarning(event) {
6
6
  switch (event.completionStatus) {
7
+ case 'no_answer':
8
+ return event.warning || '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
7
9
  case 'delivery_incomplete':
8
10
  return event.warning || '交付未完成,请检查保留的结果。';
9
11
  case 'delivery_unverified':
@@ -11,6 +11,8 @@ export function completionWarning(
11
11
  event: Extract<LoopEvent, { type: 'turn_done' }>,
12
12
  ): string | undefined {
13
13
  switch (event.completionStatus) {
14
+ case 'no_answer':
15
+ return event.warning || '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
14
16
  case 'delivery_incomplete':
15
17
  return event.warning || '交付未完成,请检查保留的结果。';
16
18
  case 'delivery_unverified':
@@ -0,0 +1,7 @@
1
+ /** Ephemeral publication request; never append this private candidate to history. */
2
+ export function finalAnswerMessages(messages, candidate) {
3
+ return [...messages,
4
+ { role: 'assistant', content: candidate },
5
+ { role: 'user', content: '内部核查与工具阶段已结束。上一条 assistant 消息是待发布的定稿。请逐字输出该消息,保留全部文字、空白、代码、链接与格式;不要改写、补充、删减,不加开场白或外层代码围栏,不调用工具或顾问。不要输出本条指令。' },
6
+ ];
7
+ }
@@ -0,0 +1,9 @@
1
+ import type { Message } from '../types.js';
2
+
3
+ /** Ephemeral publication request; never append this private candidate to history. */
4
+ export function finalAnswerMessages(messages: Message[], candidate: string): Message[] {
5
+ return [...messages,
6
+ { role: 'assistant', content: candidate },
7
+ { role: 'user', content: '内部核查与工具阶段已结束。上一条 assistant 消息是待发布的定稿。请逐字输出该消息,保留全部文字、空白、代码、链接与格式;不要改写、补充、删减,不加开场白或外层代码围栏,不调用工具或顾问。不要输出本条指令。' },
8
+ ];
9
+ }
@@ -0,0 +1,16 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Opt-in diagnostics: no prompts, paths, session IDs, tool arguments or model output. */
3
+ export function createLifecycleTrace(scope, enabled = process.env.MINIMAL_AGENT_TRACE_LIFECYCLE === '1', write = line => { process.stderr.write(line); }) {
4
+ if (!enabled)
5
+ return () => { };
6
+ const traceId = randomUUID();
7
+ const start = performance.now();
8
+ let sequence = 0;
9
+ return phase => {
10
+ try {
11
+ write(`${JSON.stringify({ kind: 'agent_lifecycle', scope, traceId, sequence: ++sequence,
12
+ phase, timestamp: Date.now(), elapsedMs: Math.round(performance.now() - start) })}\n`);
13
+ }
14
+ catch { /* Diagnostics must never interrupt execution. */ }
15
+ };
16
+ }
@@ -0,0 +1,22 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ type Phase = 'model_start' | 'model_end' | 'model_error' | 'progress_published' | 'final_published'
4
+ | 'save_start' | 'save_end' | 'save_error' | 'turn_done' | 'memory_start' | 'memory_end';
5
+
6
+ /** Opt-in diagnostics: no prompts, paths, session IDs, tool arguments or model output. */
7
+ export function createLifecycleTrace(
8
+ scope: 'loop' | 'host',
9
+ enabled = process.env.MINIMAL_AGENT_TRACE_LIFECYCLE === '1',
10
+ write: (line: string) => void = line => { process.stderr.write(line); },
11
+ ): (phase: Phase) => void {
12
+ if (!enabled) return () => {};
13
+ const traceId = randomUUID();
14
+ const start = performance.now();
15
+ let sequence = 0;
16
+ return phase => {
17
+ try {
18
+ write(`${JSON.stringify({ kind: 'agent_lifecycle', scope, traceId, sequence: ++sequence,
19
+ phase, timestamp: Date.now(), elapsedMs: Math.round(performance.now() - start) })}\n`);
20
+ } catch { /* Diagnostics must never interrupt execution. */ }
21
+ };
22
+ }
@@ -19,6 +19,7 @@
19
19
  * ============================================================
20
20
  */
21
21
  import { acquireContextLock } from '../context/contextLock.js';
22
+ import { createLifecycleTrace } from '../runtime/lifecycleTrace.js';
22
23
  import { loadContext, saveContext } from '../context/persistContext.js';
23
24
  import { startTurnJournal } from '../memory/turnJournal.js';
24
25
  import { createLearningModel } from '../memory/learningModel.js';
@@ -69,6 +70,7 @@ export async function* runTurn(opts) {
69
70
  return null;
70
71
  });
71
72
  let completedNormally = false;
73
+ const trace = createLifecycleTrace('host');
72
74
  let hostFailed = false;
73
75
  try {
74
76
  for await (const event of runWithPlugins(opts.userInput, {
@@ -107,8 +109,18 @@ export async function* runTurn(opts) {
107
109
  // at stage boundaries so a Runtime restart does not lose the whole workflow.
108
110
  if (persist && (['user_message_committed', 'assistant_message', 'compact_start', 'turn_done'].includes(event.type)
109
111
  || (event.type === 'injected_message' && event.message && typeof event.message === 'object' && 'role' in event.message && event.message.role === 'system' && '_uiProgress' in event.message && event.message._uiProgress === true))) {
110
- await saveContext(history, opts.sessionFile);
112
+ trace('save_start');
113
+ try {
114
+ await saveContext(history, opts.sessionFile);
115
+ trace('save_end');
116
+ }
117
+ catch (error) {
118
+ trace('save_error');
119
+ throw error;
120
+ }
111
121
  }
122
+ if (event.type === 'turn_done')
123
+ trace('turn_done');
112
124
  yield event;
113
125
  }
114
126
  completedNormally = true;
@@ -124,9 +136,11 @@ export async function* runTurn(opts) {
124
136
  try {
125
137
  if (!completedNormally && !hostFailed)
126
138
  journal?.observe({ type: 'interrupted' });
139
+ trace('memory_start');
127
140
  await journal?.finish(history, historyStart).catch(error => {
128
141
  process.stderr.write(`[minimal-agent memory] 本轮记忆收尾失败,不影响会话落盘:${error.message}\n`);
129
142
  });
143
+ trace('memory_end');
130
144
  if (persist)
131
145
  await saveContext(history, opts.sessionFile);
132
146
  }