minimal-agent 0.18.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.18.7",
3
+ "version": "0.18.8",
4
4
  "description": "Bun-first 的本地 Agent 内核,支持工具、插件、工作流、上下文压缩、长期记忆与 OpenAI 兼容接口;Tauri 桌面宿主和 Node.js npm 发布可复用同一内核",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Bill Wang <leiwang0359@gmail.com>",
@@ -91,7 +91,7 @@ export function filterEphemeralMessages(messages) {
91
91
  }
92
92
  /**
93
93
  * 保存当前 messages 为"上下文文件",覆盖之前内容。
94
- * 写失败不抛——丢一次落盘比把进程崩掉划算。
94
+ * 自动保存默认尽力写入;显式用户操作使用 strict,在写失败时抛错。
95
95
  *
96
96
  * v2.x 新增:写盘前过滤掉 _ephemeral=true 的 system 消息(UI 临时提示,
97
97
  * 不应进入持久化历史)。
@@ -116,6 +116,10 @@ export async function saveContext(messages, file, options) {
116
116
  await rename(temp, target);
117
117
  }
118
118
  catch (e) {
119
+ // Explicit user mutations must not report success when persistence failed.
120
+ // Ordinary turn autosave retains its existing best-effort behavior.
121
+ if (options?.strict)
122
+ throw e;
119
123
  // 不抛(持久化失败不该影响对话本身),但要出声——磁盘满/权限问题时
120
124
  // 用户整段会话会静默丢失;与 clearContext 的 archive 失败处理风格对齐。
121
125
  try {
@@ -101,12 +101,12 @@ export function filterEphemeralMessages(messages: Message[]): Message[] {
101
101
 
102
102
  /**
103
103
  * 保存当前 messages 为"上下文文件",覆盖之前内容。
104
- * 写失败不抛——丢一次落盘比把进程崩掉划算。
104
+ * 自动保存默认尽力写入;显式用户操作使用 strict,在写失败时抛错。
105
105
  *
106
106
  * v2.x 新增:写盘前过滤掉 _ephemeral=true 的 system 消息(UI 临时提示,
107
107
  * 不应进入持久化历史)。
108
108
  */
109
- export async function saveContext(messages: Message[], file?: string, options?: { replaceDisplayHistory?: boolean }): Promise<void> {
109
+ export async function saveContext(messages: Message[], file?: string, options?: { replaceDisplayHistory?: boolean; strict?: boolean }): Promise<void> {
110
110
  const target = file ?? getContextPath();
111
111
  const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
112
112
  try {
@@ -122,6 +122,9 @@ export async function saveContext(messages: Message[], file?: string, options?:
122
122
  await writeFile(temp, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 });
123
123
  await rename(temp, target);
124
124
  } catch (e) {
125
+ // Explicit user mutations must not report success when persistence failed.
126
+ // Ordinary turn autosave retains its existing best-effort behavior.
127
+ if (options?.strict) throw e;
125
128
  // 不抛(持久化失败不该影响对话本身),但要出声——磁盘满/权限问题时
126
129
  // 用户整段会话会静默丢失;与 clearContext 的 archive 失败处理风格对齐。
127
130
  try {
package/src/loop.js CHANGED
@@ -29,6 +29,8 @@
29
29
  import crypto from 'node:crypto';
30
30
  import { answerPreparationNotice } from './runtime/answerPreparation.js';
31
31
  import { finalAnswerMessages } from './runtime/finalAnswer.js';
32
+ import { resolveOutputContract, toolsForResponsePhase } from './runtime/outputContract.js';
33
+ import { needsDiagramPreparation, visualPresentation } from './prompts/visualPresentation.js';
32
34
  import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
33
35
  import { conciseToolPath } from './runtime/toolDisplay.js';
34
36
  import { dirname, resolve } from 'node:path';
@@ -238,6 +240,8 @@ export async function* runQuery(userInput, options) {
238
240
  let presentedResultUnverified = false;
239
241
  let deliveryCorrectionAttempted = false;
240
242
  let askCorrectionAttempted = false;
243
+ let diagramPreparationAttempted = false;
244
+ const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
241
245
  const subagents = await configService.subagents();
242
246
  await hydrateSessionWorktree({
243
247
  sessionState,
@@ -246,6 +250,8 @@ export async function* runQuery(userInput, options) {
246
250
  cwd: runtimeContext.cwd,
247
251
  });
248
252
  const toolCallContext = {
253
+ output,
254
+ availableToolNames: () => currentEffectiveToolSet.names,
249
255
  executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
250
256
  history,
251
257
  sessionState,
@@ -320,6 +326,7 @@ export async function* runQuery(userInput, options) {
320
326
  const parts = [
321
327
  transientFor(activeTools),
322
328
  coordinationText,
329
+ diagramPreparationAttempted ? visualPresentation(output) : '',
323
330
  delegationCorrectionAttempted ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。' : '',
324
331
  askCorrectionAttempted ? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。' : '',
325
332
  deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
@@ -737,7 +744,7 @@ export async function* runQuery(userInput, options) {
737
744
  for await (const ev of chatFn({
738
745
  provider,
739
746
  messages: modelMessages,
740
- tools: currentEffectiveToolSet.tools,
747
+ tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'preparation'),
741
748
  signal,
742
749
  conversationMode: runtimeContext.conversationMode ?? 'private',
743
750
  promptCache: options.promptCache,
@@ -896,6 +903,16 @@ export async function* runQuery(userInput, options) {
896
903
  function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
897
904
  assistantText = '';
898
905
  }
906
+ // Count the calls that survived review scheduling, including failed loads.
907
+ for (const call of toolCallsByIndex) {
908
+ if (call.function.name !== 'Skill')
909
+ continue;
910
+ try {
911
+ if (JSON.parse(call.function.arguments).name === 'dynamic-ui')
912
+ diagramPreparationAttempted = true;
913
+ }
914
+ catch { /* Ordinary tool validation reports malformed arguments. */ }
915
+ }
899
916
  const assistantMsg = {
900
917
  role: 'assistant',
901
918
  content: assistantText.length > 0 ? assistantText : null,
@@ -909,6 +926,20 @@ export async function* runQuery(userInput, options) {
909
926
  };
910
927
  // 4. 没有工具调用 → 整轮交互结束
911
928
  finalResponse: if (toolCallsByIndex.length === 0) {
929
+ // Repair only private preparation, through the ordinary tool pipeline.
930
+ // One attempt per turn, including missing/failed skills; never rewind a reply.
931
+ if (!diagramPreparationAttempted && !options.responseFormat
932
+ && runtimeContext.profile === 'main' && output.diagrams.length > 0
933
+ && currentEffectiveToolSet.names.has('Skill') && turn < maxTurns
934
+ && needsDiagramPreparation(assistantText, userInput)) {
935
+ diagramPreparationAttempted = true;
936
+ toolCallsByIndex.push({ id: crypto.randomUUID(), type: 'function', function: {
937
+ name: 'Skill', arguments: JSON.stringify({ name: 'dynamic-ui' }),
938
+ } });
939
+ assistantMsg.content = null;
940
+ assistantMsg.tool_calls = toolCallsByIndex;
941
+ break finalResponse;
942
+ }
912
943
  if (!askCorrectionAttempted && runtimeContext.interactiveAsk
913
944
  && currentEffectiveToolSet.names.has('AskUserQuestion')
914
945
  && needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
@@ -979,14 +1010,14 @@ export async function* runQuery(userInput, options) {
979
1010
  let publicationInterrupted = false;
980
1011
  let streamed = false;
981
1012
  if (!options.responseFormat && runtimeContext.profile === 'main'
982
- && (runtimeContext.surface === 'desktop' || runtimeContext.surface === 'tui')) {
1013
+ && output.finalDelivery === 'stream') {
983
1014
  const candidate = assistantText;
984
1015
  const candidateFinishReason = lastFinishReason;
985
1016
  let publishedText = '';
986
1017
  let publicationDone = false;
987
1018
  yield { type: 'stage_change', stage: 'streaming' };
988
1019
  try {
989
- const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText);
1020
+ const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText, output);
990
1021
  const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
991
1022
  if (publicationPlan.overBudget)
992
1023
  throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
@@ -998,7 +1029,7 @@ export async function* runQuery(userInput, options) {
998
1029
  for await (const event of chatFn({
999
1030
  provider,
1000
1031
  messages: publicationMessages,
1001
- tools: [], signal,
1032
+ tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'publication'), signal,
1002
1033
  conversationMode: runtimeContext.conversationMode ?? 'private',
1003
1034
  promptCache: options.promptCache,
1004
1035
  })) {
@@ -1015,11 +1046,8 @@ export async function* runQuery(userInput, options) {
1015
1046
  break;
1016
1047
  }
1017
1048
  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
- }
1049
+ // Final expression is live generation, not an exact-copy replay.
1050
+ // Once visible, text is never replaced or joined with a different draft.
1023
1051
  publishedText += event.delta;
1024
1052
  streamed = true;
1025
1053
  yield { type: 'text', delta: event.delta };
@@ -1040,26 +1068,20 @@ export async function* runQuery(userInput, options) {
1040
1068
  throw new Error('最终答案流缺少有效的结束状态。');
1041
1069
  if (!publishedText.trim() && !publicationInterrupted)
1042
1070
  throw new Error('最终表达未返回正文。');
1043
- if (!publicationInterrupted && publishedText !== candidate)
1044
- throw new Error('最终表达未完整传递定稿。');
1045
1071
  }
1046
1072
  catch (error) {
1047
1073
  publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || error?.name === 'AbortError';
1048
1074
  publicationError = error;
1049
1075
  }
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
- }
1076
+ if (publicationError && !publicationInterrupted && !publishedText) {
1077
+ yield { type: 'warning', message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。' };
1078
+ yield { type: 'text', delta: candidate };
1079
+ streamed = true;
1057
1080
  publishedText = candidate;
1081
+ lastFinishReason = candidateFinishReason;
1058
1082
  publicationError = undefined;
1059
1083
  }
1060
1084
  assistantText = publishedText;
1061
- if (!publicationInterrupted)
1062
- lastFinishReason = candidateFinishReason;
1063
1085
  assistantMsg.content = publishedText || null;
1064
1086
  if (!publishedText) {
1065
1087
  if (publicationInterrupted)
package/src/loop.ts CHANGED
@@ -30,6 +30,8 @@
30
30
  import crypto from 'node:crypto';
31
31
  import { answerPreparationNotice } from './runtime/answerPreparation.js';
32
32
  import { finalAnswerMessages } from './runtime/finalAnswer.js';
33
+ import { resolveOutputContract, toolsForResponsePhase } from './runtime/outputContract.js';
34
+ import { needsDiagramPreparation, visualPresentation } from './prompts/visualPresentation.js';
33
35
  import { createLifecycleTrace } from './runtime/lifecycleTrace.js';
34
36
  import { conciseToolPath } from './runtime/toolDisplay.js';
35
37
  import { dirname, resolve } from 'node:path';
@@ -448,6 +450,8 @@ export async function* runQuery(
448
450
  let presentedResultUnverified = false;
449
451
  let deliveryCorrectionAttempted = false;
450
452
  let askCorrectionAttempted = false;
453
+ let diagramPreparationAttempted = false;
454
+ const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
451
455
  const subagents = await configService.subagents();
452
456
  await hydrateSessionWorktree({
453
457
  sessionState,
@@ -456,6 +460,8 @@ export async function* runQuery(
456
460
  cwd: runtimeContext.cwd,
457
461
  });
458
462
  const toolCallContext: ToolCallContext = {
463
+ output,
464
+ availableToolNames: () => currentEffectiveToolSet.names,
459
465
  executionServices: { ...options.executionServices, executeTool: executeToolFn, chat: chatFn, invocationPolicy: options.invocationPolicy },
460
466
  history,
461
467
  sessionState,
@@ -533,6 +539,7 @@ export async function* runQuery(
533
539
  const parts = [
534
540
  transientFor(activeTools),
535
541
  coordinationText,
542
+ diagramPreparationAttempted ? visualPresentation(output) : '',
536
543
  delegationCorrectionAttempted ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。' : '',
537
544
  askCorrectionAttempted ? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。' : '',
538
545
  deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
@@ -966,7 +973,7 @@ export async function* runQuery(
966
973
  for await (const ev of chatFn({
967
974
  provider,
968
975
  messages: modelMessages,
969
- tools: currentEffectiveToolSet.tools,
976
+ tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'preparation'),
970
977
  signal,
971
978
  conversationMode: runtimeContext.conversationMode ?? 'private',
972
979
  promptCache: options.promptCache,
@@ -1124,6 +1131,13 @@ export async function* runQuery(
1124
1131
  function: { name: 'Advisor', arguments: JSON.stringify(pendingReview) } });
1125
1132
  assistantText = '';
1126
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
+ }
1127
1141
  const assistantMsg: Message = {
1128
1142
  role: 'assistant',
1129
1143
  content: assistantText.length > 0 ? assistantText : null,
@@ -1137,6 +1151,20 @@ export async function* runQuery(
1137
1151
  };
1138
1152
  // 4. 没有工具调用 → 整轮交互结束
1139
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
+ }
1140
1168
  if (!askCorrectionAttempted && runtimeContext.interactiveAsk
1141
1169
  && currentEffectiveToolSet.names.has('AskUserQuestion')
1142
1170
  && needsInteractiveQuestion(stripThinkBlocks(assistantText)) && turn < maxTurns) {
@@ -1206,14 +1234,14 @@ export async function* runQuery(
1206
1234
  let publicationInterrupted = false;
1207
1235
  let streamed = false;
1208
1236
  if (!options.responseFormat && runtimeContext.profile === 'main'
1209
- && (runtimeContext.surface === 'desktop' || runtimeContext.surface === 'tui')) {
1237
+ && output.finalDelivery === 'stream') {
1210
1238
  const candidate = assistantText;
1211
1239
  const candidateFinishReason = lastFinishReason;
1212
1240
  let publishedText = '';
1213
1241
  let publicationDone = false;
1214
1242
  yield { type: 'stage_change', stage: 'streaming' };
1215
1243
  try {
1216
- const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText);
1244
+ const publicationMessages = finalAnswerMessages(buildModelMessages(history, requestTransientContext, userMessage.id), assistantText, output);
1217
1245
  const publicationPlan = buildContextBudgetPlan({ provider, history: publicationMessages, tools: [] });
1218
1246
  if (publicationPlan.overBudget) throw new Error('最终表达上下文超过模型输入上限,未发送请求。');
1219
1247
  if (signal?.aborted || options.hasQueuedInput?.()) {
@@ -1224,7 +1252,7 @@ export async function* runQuery(
1224
1252
  for await (const event of chatFn({
1225
1253
  provider,
1226
1254
  messages: publicationMessages,
1227
- tools: [], signal,
1255
+ tools: toolsForResponsePhase(currentEffectiveToolSet.tools, 'publication'), signal,
1228
1256
  conversationMode: runtimeContext.conversationMode ?? 'private',
1229
1257
  promptCache: options.promptCache,
1230
1258
  })) {
@@ -1241,11 +1269,8 @@ export async function* runQuery(
1241
1269
  break;
1242
1270
  }
1243
1271
  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
- }
1272
+ // Final expression is live generation, not an exact-copy replay.
1273
+ // Once visible, text is never replaced or joined with a different draft.
1249
1274
  publishedText += event.delta;
1250
1275
  streamed = true;
1251
1276
  yield { type: 'text', delta: event.delta };
@@ -1260,23 +1285,19 @@ export async function* runQuery(
1260
1285
  if (!publicationDone && !publicationInterrupted) throw new Error('最终答案流未正常结束。');
1261
1286
  if (!publicationInterrupted && lastFinishReason !== 'stop' && lastFinishReason !== 'length') throw new Error('最终答案流缺少有效的结束状态。');
1262
1287
  if (!publishedText.trim() && !publicationInterrupted) throw new Error('最终表达未返回正文。');
1263
- if (!publicationInterrupted && publishedText !== candidate) throw new Error('最终表达未完整传递定稿。');
1264
1288
  } catch (error) {
1265
1289
  publicationInterrupted ||= signal?.aborted === true || options.hasQueuedInput?.() === true || (error as Error)?.name === 'AbortError';
1266
1290
  publicationError = error;
1267
1291
  }
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
- }
1292
+ if (publicationError && !publicationInterrupted && !publishedText) {
1293
+ yield { type: 'warning', message: '最终回答流未能开始,已一次性显示内部准备的答案;未进行新的事实核验。' };
1294
+ yield { type: 'text', delta: candidate };
1295
+ streamed = true;
1275
1296
  publishedText = candidate;
1297
+ lastFinishReason = candidateFinishReason;
1276
1298
  publicationError = undefined;
1277
1299
  }
1278
1300
  assistantText = publishedText;
1279
- if (!publicationInterrupted) lastFinishReason = candidateFinishReason;
1280
1301
  assistantMsg.content = publishedText || null;
1281
1302
  if (!publishedText) {
1282
1303
  if (publicationInterrupted) yield { ...interruption(), usage: { ...turnUsage, trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls } };
@@ -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) {
@@ -194,15 +195,10 @@ const CAPABILITY_POLICIES = [
194
195
  },
195
196
  {
196
197
  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 围栏;保持协议契约,未知不假定可渲染。',
198
+ render: ({ surface, output, toolNames }) => [
199
+ visualPresentation(output ?? surface),
204
200
  toolNames.has('Skill') || toolNames.has('Read')
205
- ? '- 加载可用的 dynamic-ui。'
201
+ ? '- 配图前加载可用的 dynamic-ui。'
206
202
  : '',
207
203
  ].filter(Boolean).join('\n'),
208
204
  },
@@ -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>;
@@ -241,15 +244,10 @@ const CAPABILITY_POLICIES: PromptPolicySpec[] = [
241
244
  },
242
245
  {
243
246
  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 围栏;保持协议契约,未知不假定可渲染。',
247
+ render: ({ surface, output, toolNames }) => [
248
+ visualPresentation(output ?? surface),
251
249
  toolNames.has('Skill') || toolNames.has('Read')
252
- ? '- 加载可用的 dynamic-ui。'
250
+ ? '- 配图前加载可用的 dynamic-ui。'
253
251
  : '',
254
252
  ].filter(Boolean).join('\n'),
255
253
  },
@@ -235,17 +235,24 @@ async function buildAdvisorTransient(profile, tools) {
235
235
  return '';
236
236
  return buildAdvisorStatusLine();
237
237
  }
238
- export async function assemblePromptEnvelope(options) {
239
- const { cwd, tools, profile = 'main', includeProjectInstructions = true, includePlugins = profile === 'main', } = options;
240
- const runtime = {
241
- surface: profile === 'main' ? 'cli' : 'subagent',
238
+ /** One default contract for stable, transient and combined prompt entry points.
239
+ * Preserve explicit legacy fields at this compatibility boundary; omission never
240
+ * grants memory, browser or vision capabilities.
241
+ */
242
+ function resolvePromptRuntime(options) {
243
+ return {
244
+ surface: (options.profile ?? 'main') === 'main' ? 'cli' : 'subagent',
242
245
  conversationMode: 'private',
243
- cwd,
246
+ cwd: options.cwd,
244
247
  interactiveAsk: true,
245
248
  supervisedBrowser: false,
246
249
  ownerLocalSession: false,
247
250
  ...options.runtime,
248
251
  };
252
+ }
253
+ export async function assemblePromptEnvelope(options) {
254
+ const { cwd, tools, profile = 'main', includeProjectInstructions = true, includePlugins = profile === 'main', } = options;
255
+ const runtime = resolvePromptRuntime(options);
249
256
  const { base, capabilities, stableContext } = await buildStableBase(cwd, tools, profile, runtime);
250
257
  const contextBundle = await buildTransientBundle(runtime, profile, tools, {
251
258
  includeProjectInstructions,
@@ -273,6 +280,7 @@ async function buildStableBase(cwd, tools, profile, runtime) {
273
280
  const stableContext = {
274
281
  profile,
275
282
  surface: runtime.surface,
283
+ output: runtime.output,
276
284
  cwd,
277
285
  capabilities,
278
286
  toolNames: new Set(tools.map((tool) => tool.name)),
@@ -289,19 +297,12 @@ async function buildStableBase(cwd, tools, profile, runtime) {
289
297
  /** Build only the cache-stable layer; performs no memory or project scans. */
290
298
  export async function assembleStablePrompt(options) {
291
299
  const profile = options.profile ?? 'main';
292
- const runtime = {
293
- surface: profile === 'main' ? 'cli' : 'subagent',
294
- conversationMode: 'private',
295
- cwd: options.cwd,
296
- interactiveAsk: true,
297
- supervisedBrowser: false,
298
- ownerLocalSession: false,
299
- ...options.runtime,
300
- };
300
+ const runtime = resolvePromptRuntime(options);
301
301
  const key = [
302
302
  options.cwd,
303
303
  profile,
304
304
  runtime.surface,
305
+ JSON.stringify(runtime.output ?? null),
305
306
  runtime.conversationMode,
306
307
  createHash('sha256')
307
308
  .update(JSON.stringify(options.tools.map((tool) => ({
@@ -544,15 +545,7 @@ export async function buildTransientContext(options) {
544
545
  /** Structured counterpart used by the Harness for request-time budget reduction. */
545
546
  export async function buildTransientPromptBundle(options) {
546
547
  const profile = options.profile ?? 'main';
547
- const runtime = {
548
- surface: profile === 'main' ? 'cli' : 'subagent',
549
- conversationMode: 'private',
550
- cwd: options.cwd,
551
- interactiveAsk: true,
552
- supervisedBrowser: false,
553
- ownerLocalSession: false,
554
- ...options.runtime,
555
- };
548
+ const runtime = resolvePromptRuntime(options);
556
549
  return buildTransientBundle(runtime, profile, options.tools, {
557
550
  includeProjectInstructions: options.includeProjectInstructions ?? true,
558
551
  includePlugins: options.includePlugins ?? profile === 'main',