minimal-agent 0.18.8 → 0.18.10

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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/loop.js +35 -5
  3. package/src/loop.ts +35 -5
  4. package/src/memory/factLearning.js +3 -3
  5. package/src/memory/factLearning.ts +3 -7
  6. package/src/memory/jobPolicy.js +21 -13
  7. package/src/memory/jobPolicy.ts +22 -11
  8. package/src/memory/learning.js +3 -3
  9. package/src/memory/learning.ts +3 -3
  10. package/src/memory/learningModel.js +4 -2
  11. package/src/memory/learningModel.ts +4 -2
  12. package/src/prompts/delegation.js +15 -0
  13. package/src/prompts/delegation.ts +20 -0
  14. package/src/prompts/policyRegistry.js +3 -2
  15. package/src/prompts/policyRegistry.ts +3 -2
  16. package/src/tools/agent/agent.js +2 -1
  17. package/src/tools/agent/agent.ts +2 -1
  18. package/src/tools/applypatch/applyPatch.js +1 -1
  19. package/src/tools/applypatch/applyPatch.ts +1 -1
  20. package/src/tools/edit/edit.js +11 -15
  21. package/src/tools/edit/edit.ts +10 -15
  22. package/src/tools/edit/multi-edit.js +14 -4
  23. package/src/tools/edit/multi-edit.ts +10 -4
  24. package/src/tools/memory/maintenance.js +2 -0
  25. package/src/tools/memory/maintenance.ts +2 -0
  26. package/src/tools/memory/memory.js +58 -14
  27. package/src/tools/memory/memory.ts +57 -13
  28. package/src/tools/memory/policy.js +16 -1
  29. package/src/tools/memory/policy.ts +16 -1
  30. package/src/tools/read/read.js +91 -47
  31. package/src/tools/read/read.ts +77 -46
  32. package/src/tools/shared/diffSummary.js +25 -3
  33. package/src/tools/shared/diffSummary.ts +23 -3
  34. package/src/tools/shared/editTransaction.js +45 -0
  35. package/src/tools/shared/editTransaction.ts +39 -0
  36. package/src/tools/shared/fileActivity.js +10 -2
  37. package/src/tools/shared/fileActivity.ts +11 -2
  38. package/src/tools/shared/fileState.js +2 -0
  39. package/src/tools/shared/fileState.ts +4 -0
  40. package/src/tools/shared/readBudget.js +12 -0
  41. package/src/tools/shared/readBudget.ts +12 -0
  42. package/src/tools/toolPromptGuides.js +6 -5
  43. package/src/tools/toolPromptGuides.ts +6 -5
  44. package/src/tools/types.js +2 -6
  45. package/src/tools/types.ts +2 -6
  46. package/src/types.ts +4 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.18.8",
3
+ "version": "0.18.10",
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>",
package/src/loop.js CHANGED
@@ -50,6 +50,7 @@ import { assembleContextBundle, contextFragment, resizeContextBundle } from './c
50
50
  import { chat as defaultChat } from './llm/client.js';
51
51
  import { buildUserContent } from './multimodal.js';
52
52
  import { ALL_TOOLS, executeTool as defaultExecuteTool, getToolByName, getToolGateDetails, } from './tools/index.js';
53
+ import { createReadBudget } from './tools/shared/readBudget.js';
53
54
  import { configService } from './config/configService.js';
54
55
  import { capabilityForToolName } from './tools/capabilities.js';
55
56
  import { createEffectiveToolSet } from './tools/effectiveToolSet.js';
@@ -69,6 +70,7 @@ import { createAgentRuntimeContext, } from './runtime/agentRuntimeContext.js';
69
70
  import { stripThinkBlocks } from './utils/stripThink.js';
70
71
  import { orderedPool } from './runtime/orderedPool.js';
71
72
  import { takeAgentNotifications, agentCoordinationContext } from './tools/agent/agentTaskService.js';
73
+ import { delegationRuntimeGuidance } from './prompts/delegation.js';
72
74
  import { executionSegments } from './tools/executionSegments.js';
73
75
  import { acquireSubagentLease, normalizeWriteScopes } from './tools/agent/coordinator.js';
74
76
  const DELIVERY_ARTIFACT = '(?:文件|合同|协议|方案|文档|表格|报告|图片|图像|海报|幻灯片|PPT|PDF|代码|脚本|压缩包|网页|页面|HTML|DOCX|XLSX|CSV|ZIP)';
@@ -239,6 +241,8 @@ export async function* runQuery(userInput, options) {
239
241
  let resultPresented = false;
240
242
  let presentedResultUnverified = false;
241
243
  let deliveryCorrectionAttempted = false;
244
+ let emptyResponseRecoveryAttempted = false;
245
+ let emptyResponseRecoveryReason = '';
242
246
  let askCorrectionAttempted = false;
243
247
  let diagramPreparationAttempted = false;
244
248
  const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
@@ -326,9 +330,13 @@ export async function* runQuery(userInput, options) {
326
330
  const parts = [
327
331
  transientFor(activeTools),
328
332
  coordinationText,
333
+ delegationRuntimeGuidance({ available: runtimeContext.profile === 'main' && currentEffectiveToolSet.names.has('Agent'),
334
+ maxConcurrent: subagents.maxConcurrent, callsUsed: toolCallContext.agentCalls?.used ?? 0,
335
+ maxCalls: toolCallContext.agentCalls?.max ?? 0 }),
329
336
  diagramPreparationAttempted ? visualPresentation(output) : '',
330
337
  delegationCorrectionAttempted ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。' : '',
331
338
  askCorrectionAttempted ? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。' : '',
339
+ emptyResponseRecoveryAttempted ? `上一次模型响应没有可交付正文(${emptyResponseRecoveryReason})。请基于已有历史和工具结果继续完成任务:信息充分则给出实质结论,不充分则继续必要操作或明确缺口。不要重复已经完成的调查或有副作用的操作;不要把未完成的任务说成完成。已有顾问意见继续有效,无需仅因恢复而再次咨询。` : '',
332
340
  deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
333
341
  advisorAvailable ? advisorCoordinator.transientReminder() : '',
334
342
  advisorAvailable
@@ -692,16 +700,25 @@ export async function* runQuery(userInput, options) {
692
700
  requiredNames: [...effectiveToolSet.requiredNames],
693
701
  });
694
702
  advisorCoordinator.setAvailable(currentEffectiveToolSet.names.has('Advisor'));
703
+ // Tool availability may change during emergency fitting. Rebuild dynamic
704
+ // guidance against the actual request boundary, then account for its size.
705
+ requestTransientContext = buildRequestTransientContext();
706
+ lastRequestTransientContext = requestTransientContext;
707
+ const finalRequestBudget = buildContextBudgetPlan({
708
+ provider, history, tools: currentEffectiveToolSet.tools,
709
+ transientContext: requestTransientContext,
710
+ });
695
711
  if (emergencyFit.removedNames.length > 0) {
696
712
  yield {
697
713
  type: 'warning',
698
714
  message: `当前模型窗口压力过高,本次请求临时省略工具 Schema:${emergencyFit.removedNames.join(', ')};下一轮会重新评估并恢复。`,
699
715
  };
700
716
  }
701
- if (emergencyFit.plan.overBudget) {
717
+ toolCallContext.reserveReadChars = createReadBudget(finalRequestBudget.inputLimit - finalRequestBudget.totalInputTokens);
718
+ if (finalRequestBudget.overBudget) {
702
719
  yield {
703
720
  type: 'error',
704
- error: `当前请求即使经过上下文降级仍需要约 ${emergencyFit.plan.totalInputTokens} tokens,超过模型输入上限 ${emergencyFit.plan.inputLimit}。已停止发送,避免 Provider 接收必然失败的超限请求。`,
721
+ error: `当前请求即使经过上下文降级仍需要约 ${finalRequestBudget.totalInputTokens} tokens,超过模型输入上限 ${finalRequestBudget.inputLimit}。已停止发送,避免 Provider 接收必然失败的超限请求。`,
705
722
  code: 'context_overflow',
706
723
  };
707
724
  yield { type: 'harness_diagnostics', diagnostics: harnessDiagnostics('error') };
@@ -999,8 +1016,19 @@ export async function* runQuery(userInput, options) {
999
1016
  yield { type: 'warning', message: '已达到本轮执行上限,顾问交付检查尚未完成;不能把此次输出视为已复核。' };
1000
1017
  }
1001
1018
  if (emptyResponse) {
1002
- // A no-tool response is already the model's terminal signal. Do not turn it
1003
- // into another model round, and do not persist/broadcast an empty bubble.
1019
+ // Recover once within the existing turn budget, retaining tool receipts and
1020
+ // advisor state. Never replay tools or expose an empty candidate as an answer.
1021
+ if (!resultPresented && !emptyResponseRecoveryAttempted && turn < maxTurns) {
1022
+ emptyResponseRecoveryAttempted = true;
1023
+ emptyResponseRecoveryReason = lastFinishReason === 'length'
1024
+ ? '输出额度耗尽' : lastFinishReason === 'unknown'
1025
+ ? '响应流缺少正常结束状态' : '响应为空或仅含思考内容';
1026
+ const message = { role: 'system', content: '正在恢复回答,已保留调查结果与工作进展。',
1027
+ id: crypto.randomUUID(), timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
1028
+ history.push(message);
1029
+ yield { type: 'injected_message', message };
1030
+ continue;
1031
+ }
1004
1032
  yield* retireDraft('empty_response');
1005
1033
  }
1006
1034
  else {
@@ -1162,7 +1190,9 @@ export async function* runQuery(userInput, options) {
1162
1190
  trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
1163
1191
  };
1164
1192
  if (emptyResponse && !resultPresented) {
1165
- const warning = '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1193
+ const warning = emptyResponseRecoveryAttempted
1194
+ ? '自动恢复后仍未生成最终答复,已有进展已保留。可以继续本轮或检查模型服务。'
1195
+ : '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1166
1196
  yield { type: 'warning', message: warning };
1167
1197
  yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
1168
1198
  }
package/src/loop.ts CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  getToolGateDetails,
73
73
  } from './tools/index.js';
74
74
  import type { ToolGate } from './toolGate.js';
75
+ import { createReadBudget } from './tools/shared/readBudget.js';
75
76
  import type {
76
77
  DeliverableManifest,
77
78
  ImageDetail,
@@ -113,6 +114,7 @@ import {
113
114
  import { stripThinkBlocks } from './utils/stripThink.js';
114
115
  import { orderedPool } from './runtime/orderedPool.js';
115
116
  import { takeAgentNotifications, agentCoordinationContext } from './tools/agent/agentTaskService.js';
117
+ import { delegationRuntimeGuidance } from './prompts/delegation.js';
116
118
  import { executionSegments } from './tools/executionSegments.js';
117
119
  import { acquireSubagentLease, normalizeWriteScopes } from './tools/agent/coordinator.js';
118
120
 
@@ -449,6 +451,8 @@ export async function* runQuery(
449
451
  let resultPresented = false;
450
452
  let presentedResultUnverified = false;
451
453
  let deliveryCorrectionAttempted = false;
454
+ let emptyResponseRecoveryAttempted = false;
455
+ let emptyResponseRecoveryReason = '';
452
456
  let askCorrectionAttempted = false;
453
457
  let diagramPreparationAttempted = false;
454
458
  const output = resolveOutputContract(runtimeContext.surface, runtimeContext.output);
@@ -539,9 +543,13 @@ export async function* runQuery(
539
543
  const parts = [
540
544
  transientFor(activeTools),
541
545
  coordinationText,
546
+ delegationRuntimeGuidance({ available: runtimeContext.profile === 'main' && currentEffectiveToolSet.names.has('Agent'),
547
+ maxConcurrent: subagents.maxConcurrent, callsUsed: toolCallContext.agentCalls?.used ?? 0,
548
+ maxCalls: toolCallContext.agentCalls?.max ?? 0 }),
542
549
  diagramPreparationAttempted ? visualPresentation(output) : '',
543
550
  delegationCorrectionAttempted ? '本轮必要子任务尚未验收或接管完成。先读取结果、等待运行任务或完成接力,使用 AgentResolve 记录判断;若确实无法完成,明确报告缺口,不声称全部完成。' : '',
544
551
  askCorrectionAttempted ? '当前交互宿主支持 AskUserQuestion。若仍需用户选择或补充,请调用该工具一次只问一个问题,等待答案后继续;不要仅在正文列问后停止。若没有待决事项则正常回答,不必提问。' : '',
552
+ emptyResponseRecoveryAttempted ? `上一次模型响应没有可交付正文(${emptyResponseRecoveryReason})。请基于已有历史和工具结果继续完成任务:信息充分则给出实质结论,不充分则继续必要操作或明确缺口。不要重复已经完成的调查或有副作用的操作;不要把未完成的任务说成完成。已有顾问意见继续有效,无需仅因恢复而再次咨询。` : '',
545
553
  deliveryCorrectionAttempted && !resultPresented ? DELIVERY_CORRECTION_REMINDER : '',
546
554
  advisorAvailable ? advisorCoordinator.transientReminder() : '',
547
555
  advisorAvailable
@@ -917,16 +925,25 @@ export async function* runQuery(
917
925
  advisorCoordinator.setAvailable(
918
926
  currentEffectiveToolSet.names.has('Advisor'),
919
927
  );
928
+ // Tool availability may change during emergency fitting. Rebuild dynamic
929
+ // guidance against the actual request boundary, then account for its size.
930
+ requestTransientContext = buildRequestTransientContext();
931
+ lastRequestTransientContext = requestTransientContext;
932
+ const finalRequestBudget = buildContextBudgetPlan({
933
+ provider, history, tools: currentEffectiveToolSet.tools,
934
+ transientContext: requestTransientContext,
935
+ });
920
936
  if (emergencyFit.removedNames.length > 0) {
921
937
  yield {
922
938
  type: 'warning',
923
939
  message: `当前模型窗口压力过高,本次请求临时省略工具 Schema:${emergencyFit.removedNames.join(', ')};下一轮会重新评估并恢复。`,
924
940
  };
925
941
  }
926
- if (emergencyFit.plan.overBudget) {
942
+ toolCallContext.reserveReadChars = createReadBudget(finalRequestBudget.inputLimit - finalRequestBudget.totalInputTokens);
943
+ if (finalRequestBudget.overBudget) {
927
944
  yield {
928
945
  type: 'error',
929
- error: `当前请求即使经过上下文降级仍需要约 ${emergencyFit.plan.totalInputTokens} tokens,超过模型输入上限 ${emergencyFit.plan.inputLimit}。已停止发送,避免 Provider 接收必然失败的超限请求。`,
946
+ error: `当前请求即使经过上下文降级仍需要约 ${finalRequestBudget.totalInputTokens} tokens,超过模型输入上限 ${finalRequestBudget.inputLimit}。已停止发送,避免 Provider 接收必然失败的超限请求。`,
930
947
  code: 'context_overflow',
931
948
  };
932
949
  yield { type: 'harness_diagnostics', diagnostics: harnessDiagnostics('error') };
@@ -1224,8 +1241,19 @@ export async function* runQuery(
1224
1241
  if (!deliveryCheckpoint.allow) yield { type: 'warning', message: '已达到本轮执行上限,顾问交付检查尚未完成;不能把此次输出视为已复核。' };
1225
1242
  }
1226
1243
  if (emptyResponse) {
1227
- // A no-tool response is already the model's terminal signal. Do not turn it
1228
- // into another model round, and do not persist/broadcast an empty bubble.
1244
+ // Recover once within the existing turn budget, retaining tool receipts and
1245
+ // advisor state. Never replay tools or expose an empty candidate as an answer.
1246
+ if (!resultPresented && !emptyResponseRecoveryAttempted && turn < maxTurns) {
1247
+ emptyResponseRecoveryAttempted = true;
1248
+ emptyResponseRecoveryReason = lastFinishReason === 'length'
1249
+ ? '输出额度耗尽' : lastFinishReason === 'unknown'
1250
+ ? '响应流缺少正常结束状态' : '响应为空或仅含思考内容';
1251
+ const message: Message = { role: 'system', content: '正在恢复回答,已保留调查结果与工作进展。',
1252
+ id: crypto.randomUUID(), timestamp: Date.now(), _ephemeral: true, _uiProgress: true };
1253
+ history.push(message);
1254
+ yield { type: 'injected_message', message };
1255
+ continue;
1256
+ }
1229
1257
  yield* retireDraft('empty_response');
1230
1258
  } else {
1231
1259
  // Publication is a separate tool-free request. Nothing from preparation
@@ -1375,7 +1403,9 @@ export async function* runQuery(
1375
1403
  trustworthy: turnUsage.totalCalls > 0 && turnUsage.reportedCalls === turnUsage.totalCalls,
1376
1404
  };
1377
1405
  if (emptyResponse && !resultPresented) {
1378
- const warning = '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1406
+ const warning = emptyResponseRecoveryAttempted
1407
+ ? '自动恢复后仍未生成最终答复,已有进展已保留。可以继续本轮或检查模型服务。'
1408
+ : '本轮未生成最终答复,已有进展已保留。可以重试上一回合。';
1379
1409
  yield { type: 'warning', message: warning };
1380
1410
  yield { type: 'turn_done', completionStatus: 'no_answer', warning, usage };
1381
1411
  } else if (pendingDelegations.length) {
@@ -2,7 +2,7 @@
2
2
  * its source material. A durable receipt makes retries idempotent. */
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { parseMemoryProposal } from './proposal.js';
5
- import { failureSchema, jobEligible, recordJobFailure, reportLearningFailure } from './jobPolicy.js';
5
+ import { failureSchema, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
6
6
  import { join } from 'node:path';
7
7
  import lockfile from 'proper-lockfile';
8
8
  import { z } from 'zod';
@@ -437,7 +437,7 @@ export function scheduleFactLearning(options) {
437
437
  void runFactLearning(options)
438
438
  .catch((error) => {
439
439
  if (!options.signal?.aborted)
440
- reportLearningFailure(directory(options.workspaceId));
440
+ recordBackgroundLearningFailure(options.workspaceId, 'facts', 'learning');
441
441
  })
442
442
  .finally(async () => {
443
443
  const settings = await memoryService.settings();
@@ -453,7 +453,7 @@ export function scheduleFactLearning(options) {
453
453
  if (pending.length)
454
454
  wakeAt(directory(options.workspaceId), Math.max(Date.now() + 1000, Math.min(...pending.map((turn) => turn.nextAttemptAt ?? Date.now()))), () => scheduleFactLearning({ ...options, force: false }), options.signal);
455
455
  })
456
- .catch((error) => process.stderr.write(`[minimal-agent memory] 恢复调度失败:${error.message}\n`));
456
+ .catch(() => recordBackgroundLearningFailure(options.workspaceId, 'facts', 'scheduling'));
457
457
  }
458
458
  export async function drainFactLearning() {
459
459
  await Promise.allSettled([...running.values()].map((task) => task.promise));
@@ -2,7 +2,7 @@
2
2
  * its source material. A durable receipt makes retries idempotent. */
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { parseMemoryProposal } from './proposal.js';
5
- import { failureSchema, jobEligible, recordJobFailure, reportLearningFailure } from './jobPolicy.js';
5
+ import { failureSchema, jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
6
6
  import { join } from 'node:path';
7
7
  import lockfile from 'proper-lockfile';
8
8
  import { z } from 'zod';
@@ -574,7 +574,7 @@ export function scheduleFactLearning(
574
574
  void runFactLearning(options)
575
575
  .catch((error) => {
576
576
  if (!options.signal?.aborted)
577
- reportLearningFailure(directory(options.workspaceId));
577
+ recordBackgroundLearningFailure(options.workspaceId, 'facts', 'learning');
578
578
  })
579
579
  .finally(async () => {
580
580
  const settings = await memoryService.settings();
@@ -604,11 +604,7 @@ export function scheduleFactLearning(
604
604
  options.signal,
605
605
  );
606
606
  })
607
- .catch((error) =>
608
- process.stderr.write(
609
- `[minimal-agent memory] 恢复调度失败:${error.message}\n`,
610
- ),
611
- );
607
+ .catch(() => recordBackgroundLearningFailure(options.workspaceId, 'facts', 'scheduling'));
612
608
  }
613
609
  export async function drainFactLearning() {
614
610
  await Promise.allSettled([...running.values()].map((task) => task.promise));
@@ -13,23 +13,31 @@ export function recordJobFailure(job, error) {
13
13
  : /VERSION_CONFLICT|SOURCE_CHANGED/.test(message) ? 'conflict'
14
14
  : /timeout|超时|429|503|fetch|network|ECONN|连接/i.test(message) ? 'transient'
15
15
  : 'unknown';
16
- const consecutive = job.failure?.kind === kind ? job.failure.consecutive + 1 : 1;
16
+ // Changing failure categories is not recovery. Otherwise alternating provider
17
+ // and proposal failures can keep a source retrying forever.
18
+ const consecutive = (job.failure?.consecutive ?? 0) + 1;
17
19
  job.attempts = (job.attempts ?? 0) + 1;
18
- job.failure = { kind, consecutive, paused: consecutive >= (kind === 'proposal' ? 3 : 6) };
20
+ job.failure = { kind, consecutive, paused: Boolean(job.failure?.paused) || consecutive >= (kind === 'proposal' ? 3 : 6) };
19
21
  job.nextAttemptAt = retryAt(consecutive);
20
22
  }
21
23
  export function jobEligible(job, force = false, now = Date.now()) {
22
24
  return force || (!job.failure?.paused && (job.nextAttemptAt ?? 0) <= now);
23
25
  }
24
- const notices = new Map();
25
- /** No raw provider response, quotes, or repeated terminal floods. Durable job
26
- * state retains the failure category and can be resumed with a forced run. */
27
- export function reportLearningFailure(key) {
28
- const now = Date.now();
29
- if (now - (notices.get(key) ?? 0) < 300_000)
30
- return;
31
- notices.set(key, now);
32
- if (notices.size > 128)
33
- notices.delete(notices.keys().next().value);
34
- process.stderr.write('[minimal-agent memory] 后台学习未完成,资料已保留;可通过 MemoryMaintenance status 查看、run 重试。重复失败将暂停自动重试。\n');
26
+ const health = new Map();
27
+ /** Background services never write over a host's TUI. Durable queues retain
28
+ * source failures; this bounded process snapshot also exposes scheduler/IO errors
29
+ * that could not be persisted. No source text, paths or provider secrets. */
30
+ export function recordBackgroundLearningFailure(workspaceId, partition, phase) {
31
+ const key = `${workspaceId}:${partition}`;
32
+ const previous = health.get(key);
33
+ health.delete(key);
34
+ health.set(key, { partition, phase, failures: (previous?.failures ?? 0) + 1, at: Date.now() });
35
+ if (health.size > 128)
36
+ health.delete(health.keys().next().value);
37
+ }
38
+ export function backgroundLearningHealth(workspaceId) {
39
+ return ['facts', 'experience'].flatMap(partition => {
40
+ const value = health.get(`${workspaceId}:${partition}`);
41
+ return value ? [{ ...value }] : [];
42
+ });
35
43
  }
@@ -16,9 +16,11 @@ export function recordJobFailure(job: Job, error: unknown): void {
16
16
  : /VERSION_CONFLICT|SOURCE_CHANGED/.test(message) ? 'conflict'
17
17
  : /timeout|超时|429|503|fetch|network|ECONN|连接/i.test(message) ? 'transient'
18
18
  : 'unknown';
19
- const consecutive = job.failure?.kind === kind ? job.failure.consecutive + 1 : 1;
19
+ // Changing failure categories is not recovery. Otherwise alternating provider
20
+ // and proposal failures can keep a source retrying forever.
21
+ const consecutive = (job.failure?.consecutive ?? 0) + 1;
20
22
  job.attempts = (job.attempts ?? 0) + 1;
21
- job.failure = { kind, consecutive, paused: consecutive >= (kind === 'proposal' ? 3 : 6) };
23
+ job.failure = { kind, consecutive, paused: Boolean(job.failure?.paused) || consecutive >= (kind === 'proposal' ? 3 : 6) };
22
24
  job.nextAttemptAt = retryAt(consecutive);
23
25
  }
24
26
 
@@ -26,13 +28,22 @@ export function jobEligible(job: Job, force = false, now = Date.now()): boolean
26
28
  return force || (!job.failure?.paused && (job.nextAttemptAt ?? 0) <= now);
27
29
  }
28
30
 
29
- const notices = new Map<string, number>();
30
- /** No raw provider response, quotes, or repeated terminal floods. Durable job
31
- * state retains the failure category and can be resumed with a forced run. */
32
- export function reportLearningFailure(key: string): void {
33
- const now = Date.now();
34
- if (now - (notices.get(key) ?? 0) < 300_000) return;
35
- notices.set(key, now);
36
- if (notices.size > 128) notices.delete(notices.keys().next().value!);
37
- process.stderr.write('[minimal-agent memory] 后台学习未完成,资料已保留;可通过 MemoryMaintenance status 查看、run 重试。重复失败将暂停自动重试。\n');
31
+ type Partition = 'facts' | 'experience';
32
+ type Health = { partition: Partition; phase: 'learning' | 'scheduling'; failures: number; at: number };
33
+ const health = new Map<string, Health>();
34
+ /** Background services never write over a host's TUI. Durable queues retain
35
+ * source failures; this bounded process snapshot also exposes scheduler/IO errors
36
+ * that could not be persisted. No source text, paths or provider secrets. */
37
+ export function recordBackgroundLearningFailure(workspaceId: string, partition: Partition, phase: Health['phase']): void {
38
+ const key = `${workspaceId}:${partition}`;
39
+ const previous = health.get(key);
40
+ health.delete(key);
41
+ health.set(key, { partition, phase, failures: (previous?.failures ?? 0) + 1, at: Date.now() });
42
+ if (health.size > 128) health.delete(health.keys().next().value!);
43
+ }
44
+ export function backgroundLearningHealth(workspaceId: string): Health[] {
45
+ return (['facts', 'experience'] as const).flatMap(partition => {
46
+ const value = health.get(`${workspaceId}:${partition}`);
47
+ return value ? [{ ...value }] : [];
48
+ });
38
49
  }
@@ -5,7 +5,7 @@
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { parseMemoryProposal } from './proposal.js';
7
7
  import { acquireModelSlot, MAX_MODEL_DURATION_MS } from './modelBudget.js';
8
- import { jobEligible, recordJobFailure, reportLearningFailure } from './jobPolicy.js';
8
+ import { jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
9
9
  import { join } from 'node:path';
10
10
  import lockfile from 'proper-lockfile';
11
11
  import { ensurePrivateDir } from './fileStore.js';
@@ -343,7 +343,7 @@ export function scheduleMemoryMaintenance(options) {
343
343
  void runMemoryMaintenance(options).catch((error) => {
344
344
  if (options.signal?.aborted)
345
345
  return;
346
- reportLearningFailure(experienceDirectory(options.workspaceId));
346
+ recordBackgroundLearningFailure(options.workspaceId, 'experience', 'learning');
347
347
  }).finally(async () => {
348
348
  const settings = await memoryService.settings();
349
349
  if (cancelled.has(experienceDirectory(options.workspaceId)) || options.signal?.aborted || !settings.autoRemember || !settings.learning?.enabled)
@@ -358,7 +358,7 @@ export function scheduleMemoryMaintenance(options) {
358
358
  if (cancelled.has(experienceDirectory(options.workspaceId)) || active.has(experienceDirectory(options.workspaceId)))
359
359
  return;
360
360
  wakeAt(experienceDirectory(options.workspaceId), Math.max(next, Date.now() + 1000), () => scheduleMemoryMaintenance({ ...options, force: false }), options.signal);
361
- }).catch(error => process.stderr.write(`[minimal-agent memory] 恢复调度失败:${error.message}\n`));
361
+ }).catch(() => recordBackgroundLearningFailure(options.workspaceId, 'experience', 'scheduling'));
362
362
  }
363
363
  /** Tests and orderly hosts may drain without polling or busy waiting. */
364
364
  export async function drainMemoryMaintenance() {
@@ -5,7 +5,7 @@
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { parseMemoryProposal } from './proposal.js';
7
7
  import { acquireModelSlot, MAX_MODEL_DURATION_MS } from './modelBudget.js';
8
- import { jobEligible, recordJobFailure, reportLearningFailure } from './jobPolicy.js';
8
+ import { jobEligible, recordJobFailure, recordBackgroundLearningFailure } from './jobPolicy.js';
9
9
  import { join } from 'node:path';
10
10
  import lockfile from 'proper-lockfile';
11
11
  import { ensurePrivateDir } from './fileStore.js';
@@ -423,7 +423,7 @@ export function scheduleMemoryMaintenance(
423
423
  if (active.has(experienceDirectory(options.workspaceId))) return;
424
424
  void runMemoryMaintenance(options).catch((error) => {
425
425
  if (options.signal?.aborted) return;
426
- reportLearningFailure(experienceDirectory(options.workspaceId));
426
+ recordBackgroundLearningFailure(options.workspaceId, 'experience', 'learning');
427
427
  }).finally(async () => {
428
428
  const settings = await memoryService.settings();
429
429
  if (cancelled.has(experienceDirectory(options.workspaceId)) || options.signal?.aborted || !settings.autoRemember || !settings.learning?.enabled) return;
@@ -436,7 +436,7 @@ export function scheduleMemoryMaintenance(
436
436
  // Cancellation can happen during the preceding disk reads.
437
437
  if (cancelled.has(experienceDirectory(options.workspaceId)) || active.has(experienceDirectory(options.workspaceId))) return;
438
438
  wakeAt(experienceDirectory(options.workspaceId), Math.max(next, Date.now() + 1000), () => scheduleMemoryMaintenance({ ...options, force: false }), options.signal);
439
- }).catch(error => process.stderr.write(`[minimal-agent memory] 恢复调度失败:${error.message}\n`));
439
+ }).catch(() => recordBackgroundLearningFailure(options.workspaceId, 'experience', 'scheduling'));
440
440
  }
441
441
 
442
442
  /** Tests and orderly hosts may drain without polling or busy waiting. */
@@ -43,8 +43,10 @@ export function createLearningModel(provider, execute = chat) {
43
43
  }
44
44
  }
45
45
  signal.throwIfAborted();
46
- if (!finished || !text.trim())
47
- throw new Error('记忆整理模型未正常完成,保留待处理资料');
46
+ if (!finished)
47
+ throw new MemoryProposalError('format', '记忆整理模型未正常完成(截断或缺少正常结束标记),保留待处理资料');
48
+ if (!text.trim())
49
+ throw new MemoryProposalError('format', '记忆整理模型未正常完成(空正文),保留待处理资料');
48
50
  return { text, usage };
49
51
  };
50
52
  }
@@ -49,8 +49,10 @@ export function createLearningModel(
49
49
  }
50
50
  }
51
51
  signal.throwIfAborted();
52
- if (!finished || !text.trim())
53
- throw new Error('记忆整理模型未正常完成,保留待处理资料');
52
+ if (!finished)
53
+ throw new MemoryProposalError('format', '记忆整理模型未正常完成(截断或缺少正常结束标记),保留待处理资料');
54
+ if (!text.trim())
55
+ throw new MemoryProposalError('format', '记忆整理模型未正常完成(空正文),保留待处理资料');
54
56
  return { text, usage };
55
57
  };
56
58
  }
@@ -0,0 +1,15 @@
1
+ /** One delegation contract shared by the main prompt, tool discovery and runtime. */
2
+ export const RESEARCH_DELEGATION_POLICY = `# 大范围阅读与并行调研
3
+ - 先读项目说明、目录、入口与基线,建立问题地图,不先串行读完整仓库。批量阅读涉及多个独立模块、各需多轮探索时,优先同轮派发多个 Agent(agent_type="explore");数量取决于独立工作量和宿主额度,不按文件数量机械切块。
4
+ - 少量已知文件同轮批量读;强依赖调用链自行跟进。只读委派无需写入铺路或 worktree,不为获得 shell 升级 general。
5
+ - 自包含任务书优先用 context.researchQuestions 写清问题、范围、停止条件,附基线、已知证据与排除项,不复制主历史。
6
+ - 有独立工作时后台派发,否则同轮前台派发;不立即轮询或重复调查。整合证据、冲突与缺口;摘要不代表全文覆盖,并发不保证降低总 Token。`;
7
+ export const AGENT_TOOL_DESCRIPTION = '启动隔离子任务(fork)并返回 agent_id。大型多模块批量阅读先定位入口,再优先同轮派发独立 explore 调查;少量已知文件直接批量读。explore 默认只读,general 使用 write_scopes 或 worktree。任务书自包含,context.researchQuestions 定义问题、范围、停止条件;AgentReport.findings 返回证据结论。run_in_background 立即返回供主 Agent 推进独立工作;resume_agent_id 续接子任务私有历史。reportMaxChars 限制预览,完整交接由 TaskOutput 获取。';
8
+ export function delegationRuntimeGuidance(input) {
9
+ if (!input.available)
10
+ return '';
11
+ const remaining = Math.max(0, input.maxCalls - input.callsUsed);
12
+ if (!remaining)
13
+ return '本轮子任务派发次数已用完:收口已有子任务,其余调查由主 Agent 使用当前工具完成,不重复尝试派发。';
14
+ return `本轮可使用 Agent 进行隔离委派,剩余派发次数 ${remaining},配置并发上限 ${Math.max(1, Math.min(8, input.maxConcurrent))}(实际槽位由调度器协调)。大型批量阅读先建立问题地图,再优先并行派发独立 explore 调查;不要把所有模块留在主上下文串行阅读。少量已知文件仍直接批量读。`;
15
+ }
@@ -0,0 +1,20 @@
1
+ /** One delegation contract shared by the main prompt, tool discovery and runtime. */
2
+ export const RESEARCH_DELEGATION_POLICY = `# 大范围阅读与并行调研
3
+ - 先读项目说明、目录、入口与基线,建立问题地图,不先串行读完整仓库。批量阅读涉及多个独立模块、各需多轮探索时,优先同轮派发多个 Agent(agent_type="explore");数量取决于独立工作量和宿主额度,不按文件数量机械切块。
4
+ - 少量已知文件同轮批量读;强依赖调用链自行跟进。只读委派无需写入铺路或 worktree,不为获得 shell 升级 general。
5
+ - 自包含任务书优先用 context.researchQuestions 写清问题、范围、停止条件,附基线、已知证据与排除项,不复制主历史。
6
+ - 有独立工作时后台派发,否则同轮前台派发;不立即轮询或重复调查。整合证据、冲突与缺口;摘要不代表全文覆盖,并发不保证降低总 Token。`;
7
+
8
+ export const AGENT_TOOL_DESCRIPTION = '启动隔离子任务(fork)并返回 agent_id。大型多模块批量阅读先定位入口,再优先同轮派发独立 explore 调查;少量已知文件直接批量读。explore 默认只读,general 使用 write_scopes 或 worktree。任务书自包含,context.researchQuestions 定义问题、范围、停止条件;AgentReport.findings 返回证据结论。run_in_background 立即返回供主 Agent 推进独立工作;resume_agent_id 续接子任务私有历史。reportMaxChars 限制预览,完整交接由 TaskOutput 获取。';
9
+
10
+ export function delegationRuntimeGuidance(input: {
11
+ available: boolean;
12
+ maxConcurrent: number;
13
+ callsUsed: number;
14
+ maxCalls: number;
15
+ }): string {
16
+ if (!input.available) return '';
17
+ const remaining = Math.max(0, input.maxCalls - input.callsUsed);
18
+ if (!remaining) return '本轮子任务派发次数已用完:收口已有子任务,其余调查由主 Agent 使用当前工具完成,不重复尝试派发。';
19
+ return `本轮可使用 Agent 进行隔离委派,剩余派发次数 ${remaining},配置并发上限 ${Math.max(1, Math.min(8, input.maxConcurrent))}(实际槽位由调度器协调)。大型批量阅读先建立问题地图,再优先并行派发独立 explore 调查;不要把所有模块留在主上下文串行阅读。少量已知文件仍直接批量读。`;
20
+ }
@@ -1,4 +1,5 @@
1
1
  import { CODE_REVIEW_RUBRIC } from './codeReview.js';
2
+ import { RESEARCH_DELEGATION_POLICY } from './delegation.js';
2
3
  import { capabilityForToolName, } from '../tools/capabilities.js';
3
4
  import { skillInvokeInstruction } from './skillList.js';
4
5
  import { visualPresentation } from './visualPresentation.js';
@@ -260,7 +261,7 @@ const CAPABILITY_POLICIES = [
260
261
  {
261
262
  id: 'capability.subagent', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
262
263
  requiresTools: ['Agent'],
263
- render: () => '- 大范围调查先定位入口、模块边界与基线,不穷尽读取。独立且需多轮探索的问题可同轮派发 explore Agent,数量取决于独立工作量和宿主额度,不为并行而拆分;少量已知文件/网址直接批量读,强顺序或协调开销大于收益时自行完成。主 Agent 保留跨模块判断,有独立工作时后台派发并推进,不重复调查或立即轮询。任务书写清问题、范围、停止条件、排除项和已知证据,不复制私有历史。按问题整合结论与阅读证据,区分观察、推断、建议;冲突核验不投票,只补读关键结论、版本变化与修改位置。文件清单不代表全文覆盖。子任务用实际可用的搜索/正文工具,动态浏览器归主 Agent;不得为只读命令升级为 general。',
264
+ render: () => RESEARCH_DELEGATION_POLICY,
264
265
  },
265
266
  {
266
267
  id: 'capability.agent-handoff', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
@@ -295,7 +296,7 @@ const CAPABILITY_POLICIES = [
295
296
  if (toolNames.has('RecallMemory'))
296
297
  rules.push('自包含任务无需检索记忆。依赖跨会话用户信息、偏好、决定或项目背景时,先检查已注入证据;缺少相关信息再用 RecallMemory,避免重复检索或直接反问用户。查询无关命中时停止扩大检索;旧结论与现场冲突时重新查证,未在本轮核验的历史结论标明来源及可能过时。');
297
298
  if (toolNames.has('RecallMemory') && toolNames.has('UpdateMemory'))
298
- rules.push('纠正:RecallMemory 定位 → UpdateMemory(expectedVersion);冲突回执充分则合并重试,否则重读。语义未变不重写。');
299
+ rules.push('RecallMemory 读版本 → UpdateMemory:update 原话/review 待复核/confirm 确认;冲突重读,拒绝不重试。');
299
300
  if (toolNames.has('ForgetMemory'))
300
301
  rules.push('ForgetMemory 仅响应当前明确遗忘要求。');
301
302
  if (toolNames.has('ConfirmMemory'))
@@ -1,5 +1,6 @@
1
1
  import type { Tool } from '../types.js';
2
2
  import { CODE_REVIEW_RUBRIC } from './codeReview.js';
3
+ import { RESEARCH_DELEGATION_POLICY } from './delegation.js';
3
4
  import type { AgentProfile, AgentSurface } from '../runtime/agentRuntimeContext.js';
4
5
  import {
5
6
  capabilityForToolName,
@@ -309,7 +310,7 @@ const CAPABILITY_POLICIES: PromptPolicySpec[] = [
309
310
  {
310
311
  id: 'capability.subagent', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
311
312
  requiresTools: ['Agent'],
312
- render: () => '- 大范围调查先定位入口、模块边界与基线,不穷尽读取。独立且需多轮探索的问题可同轮派发 explore Agent,数量取决于独立工作量和宿主额度,不为并行而拆分;少量已知文件/网址直接批量读,强顺序或协调开销大于收益时自行完成。主 Agent 保留跨模块判断,有独立工作时后台派发并推进,不重复调查或立即轮询。任务书写清问题、范围、停止条件、排除项和已知证据,不复制私有历史。按问题整合结论与阅读证据,区分观察、推断、建议;冲突核验不投票,只补读关键结论、版本变化与修改位置。文件清单不代表全文覆盖。子任务用实际可用的搜索/正文工具,动态浏览器归主 Agent;不得为只读命令升级为 general。',
313
+ render: () => RESEARCH_DELEGATION_POLICY,
313
314
  },
314
315
  {
315
316
  id: 'capability.agent-handoff', layer: 'capability', profiles: MAIN_PROFILE, source: 'native',
@@ -341,7 +342,7 @@ const CAPABILITY_POLICIES: PromptPolicySpec[] = [
341
342
  ];
342
343
  if (toolNames.has('RememberMemory')) rules.push('RememberMemory 保存明确要求记住的新事实;后台提炼启用时普通陈述交后台处理,不重复写入。自动记忆关闭时仅响应明确保存要求;失败如实说明,不阻断任务。');
343
344
  if (toolNames.has('RecallMemory')) rules.push('自包含任务无需检索记忆。依赖跨会话用户信息、偏好、决定或项目背景时,先检查已注入证据;缺少相关信息再用 RecallMemory,避免重复检索或直接反问用户。查询无关命中时停止扩大检索;旧结论与现场冲突时重新查证,未在本轮核验的历史结论标明来源及可能过时。');
344
- if (toolNames.has('RecallMemory') && toolNames.has('UpdateMemory')) rules.push('纠正:RecallMemory 定位 → UpdateMemory(expectedVersion);冲突回执充分则合并重试,否则重读。语义未变不重写。');
345
+ if (toolNames.has('RecallMemory') && toolNames.has('UpdateMemory')) rules.push('RecallMemory 读版本 → UpdateMemory:update 原话/review 待复核/confirm 确认;冲突重读,拒绝不重试。');
345
346
  if (toolNames.has('ForgetMemory')) rules.push('ForgetMemory 仅响应当前明确遗忘要求。');
346
347
  if (toolNames.has('ConfirmMemory')) rules.push('用户重新确认相同事实时使用 ConfirmMemory 更新确认时间,不创建副本。');
347
348
  if (toolNames.has('ReviewMemories')) rules.push('已过复核周期的记录只降低优先级并提示复核,不自动删除。');
@@ -25,6 +25,7 @@
25
25
  * ============================================================
26
26
  */
27
27
  import { randomUUID } from 'node:crypto';
28
+ import { AGENT_TOOL_DESCRIPTION } from '../../prompts/delegation.js';
28
29
  import { researchQuestionSchema, researchPreview } from './research.js';
29
30
  import { EventEmitter } from 'node:events';
30
31
  import { access } from 'node:fs/promises';
@@ -106,7 +107,7 @@ const inputSchema = z.object({
106
107
  const parameters = toToolParameters(inputSchema);
107
108
  // ---------------- 2. Description(给 LLM 看的工具说明) ----------------
108
109
  // 借鉴官方 AgentTool prompt 文案(任务书自包含 / 同轮并行 / 何时不用)。
109
- const description = 'Launch an isolated sub-agent with a self-contained prompt. Returns agent_id; run_in_background returns immediately, resume_agent_id resumes private history. explore is read-only; general uses write_scopes or an explicit worktree. context.researchQuestions defines question IDs, scope and stopping conditions; structured findings link conclusions to tool evidence. Full handoff and paged findings remain available through TaskOutput.';
110
+ const description = AGENT_TOOL_DESCRIPTION;
110
111
  // ---------------- 3. 子 agent 系统提示词(memoize,提示词缓存友好) ----------------
111
112
  /** 任务回传契约。能力与权限已经由 assembleBasePrompt profile 精确注入。 */
112
113
  const SUBAGENT_COMMON_APPENDIX = `# 你是子 agent(fork 分身)
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { randomUUID } from 'node:crypto';
29
+ import { AGENT_TOOL_DESCRIPTION } from '../../prompts/delegation.js';
29
30
  import { researchQuestionSchema, researchPreview } from './research.js';
30
31
  import { EventEmitter } from 'node:events';
31
32
  import { access } from 'node:fs/promises';
@@ -152,7 +153,7 @@ const parameters = toToolParameters(inputSchema);
152
153
  // ---------------- 2. Description(给 LLM 看的工具说明) ----------------
153
154
  // 借鉴官方 AgentTool prompt 文案(任务书自包含 / 同轮并行 / 何时不用)。
154
155
 
155
- const description = 'Launch an isolated sub-agent with a self-contained prompt. Returns agent_id; run_in_background returns immediately, resume_agent_id resumes private history. explore is read-only; general uses write_scopes or an explicit worktree. context.researchQuestions defines question IDs, scope and stopping conditions; structured findings link conclusions to tool evidence. Full handoff and paged findings remain available through TaskOutput.';
156
+ const description = AGENT_TOOL_DESCRIPTION;
156
157
 
157
158
  // ---------------- 3. 子 agent 系统提示词(memoize,提示词缓存友好) ----------------
158
159
 
@@ -341,7 +341,7 @@ async function call(input, context) {
341
341
  contentHash: hashFileBytes(encodeTextFile(file.after, file.encoding)),
342
342
  }, context?.sessionState?.fileReads);
343
343
  emitFileMutation(file.path);
344
- summaries.push(`${file.activity}: ${file.label} (+${patch.additions} -${patch.deletions})`);
344
+ summaries.push(`${file.activity}: ${file.label} (${patch.additions === undefined ? 'diff 未统计' : `+${patch.additions} -${patch.deletions}`})`);
345
345
  }
346
346
  return {
347
347
  ok: true,
@@ -403,7 +403,7 @@ async function call(
403
403
  );
404
404
  emitFileMutation(file.path);
405
405
  summaries.push(
406
- `${file.activity}: ${file.label} (+${patch.additions} -${patch.deletions})`,
406
+ `${file.activity}: ${file.label} (${patch.additions === undefined ? 'diff 未统计' : `+${patch.additions} -${patch.deletions}`})`,
407
407
  );
408
408
  }
409
409
  return {