minimal-agent 0.11.0 → 0.12.0

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 (128) hide show
  1. package/package.json +4 -1
  2. package/plugins/workflow-runner/plugin.js +27 -15
  3. package/plugins/workflow-runner/plugin.ts +34 -20
  4. package/plugins/workflow-runner/src/agentCommon.js +13 -12
  5. package/plugins/workflow-runner/src/agentCommon.ts +22 -13
  6. package/plugins/workflow-runner/src/index.js +5 -3
  7. package/plugins/workflow-runner/src/index.ts +31 -13
  8. package/plugins/workflow-runner/src/multimodal.js +2 -1
  9. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  10. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  11. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  12. package/plugins/workflow-runner/src/stepExecutors/tool.js +68 -57
  13. package/plugins/workflow-runner/src/stepExecutors/tool.ts +88 -66
  14. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  15. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  16. package/plugins/workflow-runner/src/types.ts +26 -7
  17. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  18. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  19. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  20. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  21. package/src/cli/print.js +19 -4
  22. package/src/cli/print.ts +17 -4
  23. package/src/cli/streamJson.js +19 -4
  24. package/src/cli/streamJson.ts +17 -4
  25. package/src/config/configDefaults.js +25 -0
  26. package/src/config/configDefaults.ts +33 -0
  27. package/src/config/configFile.js +2 -21
  28. package/src/config/configFile.ts +11 -24
  29. package/src/config/configManagement.js +1 -1
  30. package/src/config/configManagement.ts +2 -2
  31. package/src/context/compact.js +4 -0
  32. package/src/context/compact.ts +4 -0
  33. package/src/context/contextBundle.js +38 -13
  34. package/src/context/contextBundle.ts +47 -13
  35. package/src/harness/stallGuard.js +19 -4
  36. package/src/harness/stallGuard.ts +30 -7
  37. package/src/loop.js +95 -11
  38. package/src/loop.ts +125 -16
  39. package/src/main.js +8 -1
  40. package/src/main.tsx +7 -1
  41. package/src/memory/constants.js +10 -0
  42. package/src/memory/constants.ts +13 -0
  43. package/src/memory/episodicMemory.js +274 -2
  44. package/src/memory/episodicMemory.ts +306 -1
  45. package/src/memory/fileStore.js +24 -4
  46. package/src/memory/fileStore.ts +22 -2
  47. package/src/memory/memoryService.js +79 -12
  48. package/src/memory/memoryService.ts +108 -9
  49. package/src/memory/repository.js +415 -85
  50. package/src/memory/repository.ts +437 -86
  51. package/src/memory/turnJournal.js +95 -0
  52. package/src/memory/turnJournal.ts +120 -0
  53. package/src/memory/types.ts +16 -0
  54. package/src/plugins/pluginApi.ts +30 -6
  55. package/src/plugins/pluginRunner.js +6 -2
  56. package/src/plugins/pluginRunner.ts +12 -7
  57. package/src/prompts/policyRegistry.js +27 -0
  58. package/src/prompts/policyRegistry.ts +27 -0
  59. package/src/prompts/promptAssembly.js +39 -7
  60. package/src/prompts/promptAssembly.ts +145 -59
  61. package/src/session/runAgentTurn.js +43 -21
  62. package/src/session/runAgentTurn.ts +68 -26
  63. package/src/session/runTurn.js +11 -46
  64. package/src/session/runTurn.ts +12 -46
  65. package/src/session/runtimeStore.js +149 -0
  66. package/src/session/runtimeStore.ts +197 -0
  67. package/src/session/sessionState.js +5 -0
  68. package/src/session/sessionState.ts +14 -0
  69. package/src/tools/agent/agent.js +143 -14
  70. package/src/tools/agent/agent.ts +149 -14
  71. package/src/tools/agent/agentTaskService.js +60 -0
  72. package/src/tools/agent/agentTaskService.ts +113 -0
  73. package/src/tools/agent/agentTypes.js +5 -0
  74. package/src/tools/agent/agentTypes.ts +5 -0
  75. package/src/tools/bash/bash.js +31 -11
  76. package/src/tools/bash/bash.ts +29 -10
  77. package/src/tools/capabilities.js +5 -0
  78. package/src/tools/capabilities.ts +11 -1
  79. package/src/tools/edit/edit.js +7 -4
  80. package/src/tools/edit/edit.ts +7 -4
  81. package/src/tools/edit/multi-edit.js +4 -2
  82. package/src/tools/edit/multi-edit.ts +4 -2
  83. package/src/tools/glob/glob.js +156 -40
  84. package/src/tools/glob/glob.ts +185 -46
  85. package/src/tools/index.js +35 -17
  86. package/src/tools/index.ts +81 -20
  87. package/src/tools/lsp/lsp.js +165 -22
  88. package/src/tools/lsp/lsp.ts +206 -36
  89. package/src/tools/powershell/powershell.js +116 -26
  90. package/src/tools/powershell/powershell.ts +155 -30
  91. package/src/tools/presentresult/presentresult.js +10 -0
  92. package/src/tools/presentresult/presentresult.ts +10 -0
  93. package/src/tools/read/read.js +244 -29
  94. package/src/tools/read/read.ts +391 -59
  95. package/src/tools/shared/diffSummary.js +44 -0
  96. package/src/tools/shared/diffSummary.ts +63 -0
  97. package/src/tools/shared/fileActivity.js +2 -1
  98. package/src/tools/shared/fileActivity.ts +3 -0
  99. package/src/tools/shared/permanentDeleteGuard.js +26 -0
  100. package/src/tools/shared/permanentDeleteGuard.ts +34 -0
  101. package/src/tools/shared/toolOutputStore.js +97 -0
  102. package/src/tools/shared/toolOutputStore.ts +110 -0
  103. package/src/tools/skill/skill.js +86 -0
  104. package/src/tools/skill/skill.ts +94 -0
  105. package/src/tools/snip/snip.js +67 -0
  106. package/src/tools/snip/snip.ts +80 -0
  107. package/src/tools/tasks/backgroundTaskService.js +79 -28
  108. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  109. package/src/tools/tasks/taskManagement.js +163 -0
  110. package/src/tools/tasks/taskManagement.ts +180 -0
  111. package/src/tools/tasks/taskOutput.js +64 -14
  112. package/src/tools/tasks/taskOutput.ts +102 -23
  113. package/src/tools/tasks/taskStop.js +48 -8
  114. package/src/tools/tasks/taskStop.ts +69 -9
  115. package/src/tools/tasks/taskStore.js +46 -0
  116. package/src/tools/tasks/taskStore.ts +77 -0
  117. package/src/tools/terminal/terminalCapture.js +167 -0
  118. package/src/tools/terminal/terminalCapture.ts +179 -0
  119. package/src/tools/trash/trash.js +233 -0
  120. package/src/tools/trash/trash.ts +283 -0
  121. package/src/tools/worktree/sessionWorktree.js +128 -0
  122. package/src/tools/worktree/sessionWorktree.ts +158 -0
  123. package/src/tools/write/write.js +6 -2
  124. package/src/tools/write/write.ts +6 -2
  125. package/src/types.ts +12 -0
  126. package/src/ui/hooks/useChat.js +31 -11
  127. package/src/ui/hooks/useChat.ts +31 -13
  128. package/src/web/protocol.ts +78 -3
@@ -3,14 +3,12 @@
3
3
  * src/tools/agent/agent.ts —— Agent 工具(fork 子 agent / 多 agent 并行)
4
4
  * ------------------------------------------------------------
5
5
  * 借鉴 Claude Code 官方 AgentTool/ForkAgent 的核心思想,按 minimal-agent
6
- * 内核架构做同步等效实现:
6
+ * 内核架构做可恢复的前台/后台等效实现:
7
7
  * - 官方 fork = 「隔离 context 嵌套跑同一主循环 + 摘要回传(完整历史不进
8
- * 父上下文)」。我们不需要它的后台任务队列/AsyncLocalStorage 身份追踪
9
- * (那是为 Ink UI 后台任务服务的)——本工具同步 await 一个嵌套 runQuery,
10
- * 声明 isConcurrencySafe:true 后,模型在同一轮发出 N 个 Agent 调用时,
11
- * loop.ts 现成的并行 worker 队列自动把它们并发执行。这就是并行的全部来源。
12
- * - 同轮混入任一非并发安全工具(如 Edit)会整轮退化串行——这是想要的安全
13
- * 语义(写操作与并行 fork 不该交错)。
8
+ * 父上下文)」。本工具在隔离 history 中嵌套运行同一主循环,并将私有历史、
9
+ * 进度和结果写入会话运行存储;前台、后台和重启后续接共用同一执行内核。
10
+ * - 同轮多个 Agent 调用仍由 loop.ts 的并行 worker 执行;write scopes 与可选
11
+ * worktree 负责隔离真正可并行的写任务。
14
12
  *
15
13
  * 已知限制(如实声明):
16
14
  * - 子 agent 的 LoopEvent 不外泄:-p / stream-json 宿主只看到本工具的一对
@@ -27,6 +25,8 @@
27
25
  * ============================================================
28
26
  */
29
27
  import { randomUUID } from 'node:crypto';
28
+ import { EventEmitter } from 'node:events';
29
+ import { access } from 'node:fs/promises';
30
30
  import { withAskSource } from '../../askBridge.js';
31
31
  import { isAbsolute, relative, resolve, sep } from 'node:path';
32
32
  import { z } from 'zod';
@@ -41,6 +41,7 @@ import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
41
41
  import { SUBAGENT_EXCLUDED_TOOLS, resolveAgentType } from './agentTypes.js';
42
42
  import { acquireSubagentLease, normalizeWriteScopes } from './coordinator.js';
43
43
  import { commitWorktreeChanges, createAgentWorktree, hasWorktreeChanges, removeAgentWorktree, worktreeChangedFiles, worktreeDiffStat, } from './worktree.js';
44
+ import { appendAgentEvent, loadAgentRecord, saveAgentRecord } from './agentTaskService.js';
44
45
  let corePromise = null;
45
46
  /** 首次调用时才真正加载 loop/tools——此刻模块图早已初始化完毕,环安全。 */
46
47
  function loadCore() {
@@ -79,13 +80,16 @@ const inputSchema = z.object({
79
80
  .max(32)
80
81
  .optional()
81
82
  .describe('general 预计修改的工作目录相对路径。范围互不重叠时可并行;缺省时按整个工作目录串行。explore 不需要。'),
83
+ run_in_background: z.boolean().optional().describe('立即返回 agent_id,子任务在后台运行;用 TaskOutput/TaskStop 管理'),
84
+ resume_agent_id: z.string().regex(/^agent_[0-9a-f-]{36}$/i).optional().describe('继续当前会话中此前完成、中断或失败的 Agent 历史'),
82
85
  });
83
86
  const parameters = toToolParameters(inputSchema);
84
87
  // ---------------- 2. Description(给 LLM 看的工具说明) ----------------
85
88
  // 借鉴官方 AgentTool prompt 文案(任务书自包含 / 同轮并行 / 何时不用)。
86
- const description = `- Launches a stateless sub-agent with a fresh, isolated context to execute one self-contained subtask; returns only its final report text (its full working history never enters this conversation).
89
+ const description = `- Launches an isolated sub-agent for one self-contained task. Every run returns an agent_id; run_in_background=true continues without blocking, and resume_agent_id continues its persisted private history across user turns or an app restart.
87
90
  - The sub-agent CANNOT see this conversation. The \`prompt\` must be fully self-contained: background, exact file paths, what to look for / do, and the expected output format.
88
91
  - **To parallelize, emit multiple Agent calls in ONE assistant message** — they run concurrently. This is the main value of this tool.
92
+ - Background Agent progress/results are read with TaskOutput and stopped with TaskStop. Do not poll: TaskOutput(block=true) waits efficiently.
89
93
  - Use for: exploring/researching several independent areas of a codebase at once, parallel information gathering from multiple sources, independent analyses that will be synthesized afterwards, and (with agent_type:'general') genuinely parallelizable write/execute work.
90
94
  - Do NOT use for: single targeted lookups that one or two direct tool calls would answer (a fork starts cold and re-derives context — slower and costlier), or strictly sequential work. Only general execution agents may ask the user, and only when genuinely blocked.
91
95
  - agent_type: 'explore' (default) = strictly read-only research; 'general' = read-write execution. For direct writes, declare relative write_scopes; disjoint scopes may run concurrently, overlapping or omitted scopes are serialized.
@@ -237,10 +241,10 @@ export async function runAgentTask(input, signal, ctx, deps = {}) {
237
241
  // allowedTools 只裁「下发给模型的清单」;guardedExecute 是真正的硬防线(见下)。
238
242
  const allowedTools = def.resolveAllowedTools(ALL_TOOLS.map((t) => t.name));
239
243
  // ---- worktree 隔离(仅 general 显式要求时) ----
240
- let wt = null;
244
+ let wt = deps.existingWorktree ?? null;
241
245
  const isolation = input.isolation ?? ctx.subagentConfig?.defaultWriteIsolation ?? 'none';
242
- if (def.type === 'general' && isolation === 'worktree') {
243
- const created = await createAgentWorktree(`fork-${randomUUID().slice(0, 8)}`);
246
+ if (def.type === 'general' && isolation === 'worktree' && !wt) {
247
+ const created = await createAgentWorktree(`fork-${randomUUID().slice(0, 8)}`, ctx.cwd);
244
248
  if (!created.ok) {
245
249
  return {
246
250
  ok: false,
@@ -248,8 +252,9 @@ export async function runAgentTask(input, signal, ctx, deps = {}) {
248
252
  };
249
253
  }
250
254
  wt = created;
255
+ await deps.onWorktreeCreated?.(created);
251
256
  }
252
- const cwd = wt?.path ?? getWorkingDir();
257
+ const cwd = wt?.path ?? ctx.cwd ?? getWorkingDir();
253
258
  let writeScopes;
254
259
  try {
255
260
  writeScopes = def.type === 'general'
@@ -288,6 +293,7 @@ export async function runAgentTask(input, signal, ctx, deps = {}) {
288
293
  surface: 'subagent',
289
294
  cwd,
290
295
  workspaceId: ctx.workspaceId,
296
+ sessionId: ctx.sessionId,
291
297
  interactiveAsk: childRuntime.interactiveAsk,
292
298
  memoryAccess: childMemoryAccess,
293
299
  currentUserInput: input.prompt,
@@ -303,7 +309,7 @@ export async function runAgentTask(input, signal, ctx, deps = {}) {
303
309
  ctx.reportProgress?.(line, detail);
304
310
  };
305
311
  // ---- 子 history:一条 system(压缩按 role 定位,必须存在)+ runQuery 自己 push 任务书 ----
306
- const history = [
312
+ const history = deps.initialHistory?.length ? [...deps.initialHistory] : [
307
313
  { role: 'system', content: await buildSubSystemPrompt(cwd, def, subTools) },
308
314
  ];
309
315
  const historyLenBefore = history.length;
@@ -529,6 +535,7 @@ export async function runAgentTask(input, signal, ctx, deps = {}) {
529
535
  finally {
530
536
  stopTimeout();
531
537
  releaseLease?.();
538
+ await deps.captureHistory?.(history);
532
539
  }
533
540
  // ---- 结局映射(直接查 signal 状态,不依赖事件旗标) ----
534
541
  if (ownerCancelController.signal.aborted) {
@@ -625,6 +632,128 @@ export async function runAgentTask(input, signal, ctx, deps = {}) {
625
632
  async function call(input, signal) {
626
633
  return runAgentTask(input, signal, {});
627
634
  }
635
+ export async function runManagedAgentTask(input, signal, ctx, deps = {}) {
636
+ if (!ctx.sessionState)
637
+ return runAgentTask(input, signal, ctx);
638
+ const id = input.resume_agent_id ?? `agent_${randomUUID()}`;
639
+ let initialHistory;
640
+ let resumedWorktree;
641
+ let coreInput = { ...input, run_in_background: undefined, resume_agent_id: undefined };
642
+ if (input.resume_agent_id) {
643
+ const live = ctx.sessionState.agentTasks.tasks.get(id);
644
+ if (live?.status === 'running')
645
+ return { ok: false, error: `Agent ${id} 正在运行,不能并发恢复` };
646
+ const saved = await loadAgentRecord(ctx, id);
647
+ if (!saved)
648
+ return { ok: false, error: `找不到可恢复 Agent:${id}` };
649
+ initialHistory = saved.history;
650
+ resumedWorktree = saved.worktree && await access(saved.worktree.path)
651
+ .then(() => saved.worktree)
652
+ .catch(() => undefined);
653
+ coreInput = {
654
+ ...saved.input,
655
+ prompt: input.prompt,
656
+ description: input.description || saved.description,
657
+ run_in_background: undefined,
658
+ resume_agent_id: undefined,
659
+ };
660
+ }
661
+ const abort = new AbortController();
662
+ const combined = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
663
+ let settle;
664
+ const completion = new Promise((resolvePromise) => { settle = resolvePromise; });
665
+ const task = {
666
+ id,
667
+ status: 'running',
668
+ description: coreInput.description,
669
+ input: coreInput,
670
+ createdAt: Date.now(),
671
+ updatedAt: Date.now(),
672
+ abort,
673
+ completion,
674
+ events: new EventEmitter(),
675
+ };
676
+ ctx.sessionState.agentTasks.tasks.set(id, task);
677
+ await saveAgentRecord(ctx, task);
678
+ await appendAgentEvent(ctx, id, { phase: 'started', resumed: Boolean(input.resume_agent_id) });
679
+ const run = async () => {
680
+ let captured = initialHistory;
681
+ let lastPersistedProgressAt = 0;
682
+ const managedContext = {
683
+ ...ctx,
684
+ reportProgress: (line, detail) => {
685
+ ctx.reportProgress?.(line, detail);
686
+ task.progress = line;
687
+ task.events.emit('change');
688
+ if (Date.now() - lastPersistedProgressAt >= 1_000) {
689
+ lastPersistedProgressAt = Date.now();
690
+ void saveAgentRecord(ctx, task).catch(() => undefined);
691
+ void appendAgentEvent(ctx, id, { phase: 'progress', message: line })
692
+ .catch(() => undefined);
693
+ }
694
+ },
695
+ };
696
+ try {
697
+ const result = await runAgentTask(coreInput, combined, managedContext, {
698
+ ...deps,
699
+ initialHistory,
700
+ existingWorktree: resumedWorktree,
701
+ onWorktreeCreated: async (worktree) => {
702
+ task.worktree = worktree;
703
+ await saveAgentRecord(ctx, task);
704
+ },
705
+ captureHistory: (history) => { captured = [...history]; },
706
+ });
707
+ task.result = result;
708
+ task.history = captured;
709
+ task.status = abort.signal.aborted ? 'stopped' : result.ok ? 'completed' : 'failed';
710
+ if (task.worktree && !await access(task.worktree.path).then(() => true).catch(() => false)) {
711
+ task.worktree = undefined;
712
+ }
713
+ task.updatedAt = Date.now();
714
+ await saveAgentRecord(ctx, task);
715
+ await appendAgentEvent(ctx, id, { phase: task.status, ok: result.ok });
716
+ return result;
717
+ }
718
+ catch (error) {
719
+ task.status = abort.signal.aborted ? 'stopped' : 'failed';
720
+ task.result = { ok: false, error: error.message };
721
+ task.history = captured;
722
+ task.updatedAt = Date.now();
723
+ await saveAgentRecord(ctx, task).catch(() => undefined);
724
+ await appendAgentEvent(ctx, id, { phase: task.status, error: error.message })
725
+ .catch(() => undefined);
726
+ return task.result;
727
+ }
728
+ finally {
729
+ task.events.emit('change');
730
+ settle();
731
+ }
732
+ };
733
+ if (input.run_in_background) {
734
+ void run();
735
+ return {
736
+ ok: true,
737
+ content: `后台 Agent 已启动。agent_id=${id}\n使用 TaskOutput 获取进度/结果,TaskStop 停止。`,
738
+ metadata: {
739
+ kind: 'agent', agentId: id, status: 'running',
740
+ agentType: input.agent_type ?? 'explore', description: input.description,
741
+ isolation: input.isolation ?? 'none', turns: 0, toolCalls: 0,
742
+ failedToolCalls: 0, durationMs: 0, changedFiles: [],
743
+ },
744
+ };
745
+ }
746
+ const result = await run();
747
+ if (result.ok && result.metadata?.kind === 'agent') {
748
+ result.metadata.agentId = id;
749
+ result.metadata.status = 'completed';
750
+ result.content = `agent_id=${id}\n${result.content}`;
751
+ }
752
+ else if (!result.ok) {
753
+ result.error = `agent_id=${id}\n${result.error}`;
754
+ }
755
+ return result;
756
+ }
628
757
  // ---------------- 5. 导出 ----------------
629
758
  export const agentTool = {
630
759
  name: 'Agent',
@@ -638,5 +767,5 @@ export const agentTool = {
638
767
  maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
639
768
  interruptBehavior: () => 'block',
640
769
  call,
641
- callWithContext: runAgentTask,
770
+ callWithContext: runManagedAgentTask,
642
771
  };
@@ -3,14 +3,12 @@
3
3
  * src/tools/agent/agent.ts —— Agent 工具(fork 子 agent / 多 agent 并行)
4
4
  * ------------------------------------------------------------
5
5
  * 借鉴 Claude Code 官方 AgentTool/ForkAgent 的核心思想,按 minimal-agent
6
- * 内核架构做同步等效实现:
6
+ * 内核架构做可恢复的前台/后台等效实现:
7
7
  * - 官方 fork = 「隔离 context 嵌套跑同一主循环 + 摘要回传(完整历史不进
8
- * 父上下文)」。我们不需要它的后台任务队列/AsyncLocalStorage 身份追踪
9
- * (那是为 Ink UI 后台任务服务的)——本工具同步 await 一个嵌套 runQuery,
10
- * 声明 isConcurrencySafe:true 后,模型在同一轮发出 N 个 Agent 调用时,
11
- * loop.ts 现成的并行 worker 队列自动把它们并发执行。这就是并行的全部来源。
12
- * - 同轮混入任一非并发安全工具(如 Edit)会整轮退化串行——这是想要的安全
13
- * 语义(写操作与并行 fork 不该交错)。
8
+ * 父上下文)」。本工具在隔离 history 中嵌套运行同一主循环,并将私有历史、
9
+ * 进度和结果写入会话运行存储;前台、后台和重启后续接共用同一执行内核。
10
+ * - 同轮多个 Agent 调用仍由 loop.ts 的并行 worker 执行;write scopes 与可选
11
+ * worktree 负责隔离真正可并行的写任务。
14
12
  *
15
13
  * 已知限制(如实声明):
16
14
  * - 子 agent 的 LoopEvent 不外泄:-p / stream-json 宿主只看到本工具的一对
@@ -28,6 +26,8 @@
28
26
  */
29
27
 
30
28
  import { randomUUID } from 'node:crypto';
29
+ import { EventEmitter } from 'node:events';
30
+ import { access } from 'node:fs/promises';
31
31
  import { withAskSource } from '../../askBridge.js';
32
32
  import { isAbsolute, relative, resolve, sep } from 'node:path';
33
33
  import { z } from 'zod';
@@ -59,6 +59,7 @@ import {
59
59
  worktreeChangedFiles,
60
60
  worktreeDiffStat,
61
61
  } from './worktree.js';
62
+ import { appendAgentEvent, loadAgentRecord, saveAgentRecord, type AgentTaskRecord, type LiveAgentTask } from './agentTaskService.js';
62
63
 
63
64
  // ---------------- 0. 环内模块懒加载(见文件头「循环依赖纪律」) ----------------
64
65
 
@@ -116,6 +117,8 @@ const inputSchema = z.object({
116
117
  .describe(
117
118
  'general 预计修改的工作目录相对路径。范围互不重叠时可并行;缺省时按整个工作目录串行。explore 不需要。',
118
119
  ),
120
+ run_in_background: z.boolean().optional().describe('立即返回 agent_id,子任务在后台运行;用 TaskOutput/TaskStop 管理'),
121
+ resume_agent_id: z.string().regex(/^agent_[0-9a-f-]{36}$/i).optional().describe('继续当前会话中此前完成、中断或失败的 Agent 历史'),
119
122
  });
120
123
  type AgentInput = z.infer<typeof inputSchema>;
121
124
 
@@ -124,9 +127,10 @@ const parameters = toToolParameters(inputSchema);
124
127
  // ---------------- 2. Description(给 LLM 看的工具说明) ----------------
125
128
  // 借鉴官方 AgentTool prompt 文案(任务书自包含 / 同轮并行 / 何时不用)。
126
129
 
127
- const description = `- Launches a stateless sub-agent with a fresh, isolated context to execute one self-contained subtask; returns only its final report text (its full working history never enters this conversation).
130
+ const description = `- Launches an isolated sub-agent for one self-contained task. Every run returns an agent_id; run_in_background=true continues without blocking, and resume_agent_id continues its persisted private history across user turns or an app restart.
128
131
  - The sub-agent CANNOT see this conversation. The \`prompt\` must be fully self-contained: background, exact file paths, what to look for / do, and the expected output format.
129
132
  - **To parallelize, emit multiple Agent calls in ONE assistant message** — they run concurrently. This is the main value of this tool.
133
+ - Background Agent progress/results are read with TaskOutput and stopped with TaskStop. Do not poll: TaskOutput(block=true) waits efficiently.
130
134
  - Use for: exploring/researching several independent areas of a codebase at once, parallel information gathering from multiple sources, independent analyses that will be synthesized afterwards, and (with agent_type:'general') genuinely parallelizable write/execute work.
131
135
  - Do NOT use for: single targeted lookups that one or two direct tool calls would answer (a fork starts cold and re-derives context — slower and costlier), or strictly sequential work. Only general execution agents may ask the user, and only when genuinely blocked.
132
136
  - agent_type: 'explore' (default) = strictly read-only research; 'general' = read-write execution. For direct writes, declare relative write_scopes; disjoint scopes may run concurrently, overlapping or omitted scopes are serialized.
@@ -222,6 +226,10 @@ export function extractCoveredFiles(
222
226
  export interface AgentTaskDeps {
223
227
  chat?: RunQueryOptions['chat'];
224
228
  executeTool?: RunQueryOptions['executeTool'];
229
+ initialHistory?: RunQueryOptions['history'];
230
+ captureHistory?: (history:RunQueryOptions['history'])=>void|Promise<void>;
231
+ existingWorktree?: AgentWorktree;
232
+ onWorktreeCreated?: (worktree: AgentWorktree) => void | Promise<void>;
225
233
  }
226
234
 
227
235
  function positiveInt(value: string | undefined): number | undefined {
@@ -296,10 +304,10 @@ export async function runAgentTask(
296
304
  const allowedTools = def.resolveAllowedTools(ALL_TOOLS.map((t) => t.name));
297
305
 
298
306
  // ---- worktree 隔离(仅 general 显式要求时) ----
299
- let wt: AgentWorktree | null = null;
307
+ let wt: AgentWorktree | null = deps.existingWorktree ?? null;
300
308
  const isolation = input.isolation ?? ctx.subagentConfig?.defaultWriteIsolation ?? 'none';
301
- if (def.type === 'general' && isolation === 'worktree') {
302
- const created = await createAgentWorktree(`fork-${randomUUID().slice(0, 8)}`);
309
+ if (def.type === 'general' && isolation === 'worktree' && !wt) {
310
+ const created = await createAgentWorktree(`fork-${randomUUID().slice(0, 8)}`, ctx.cwd);
303
311
  if (!created.ok) {
304
312
  return {
305
313
  ok: false,
@@ -307,8 +315,9 @@ export async function runAgentTask(
307
315
  };
308
316
  }
309
317
  wt = created;
318
+ await deps.onWorktreeCreated?.(created);
310
319
  }
311
- const cwd = wt?.path ?? getWorkingDir();
320
+ const cwd = wt?.path ?? ctx.cwd ?? getWorkingDir();
312
321
  let writeScopes: string[];
313
322
  try {
314
323
  writeScopes = def.type === 'general'
@@ -348,6 +357,7 @@ export async function runAgentTask(
348
357
  surface: 'subagent',
349
358
  cwd,
350
359
  workspaceId: ctx.workspaceId,
360
+ sessionId: ctx.sessionId,
351
361
  interactiveAsk: childRuntime.interactiveAsk,
352
362
  memoryAccess: childMemoryAccess,
353
363
  currentUserInput: input.prompt,
@@ -364,7 +374,7 @@ export async function runAgentTask(
364
374
  };
365
375
 
366
376
  // ---- 子 history:一条 system(压缩按 role 定位,必须存在)+ runQuery 自己 push 任务书 ----
367
- const history: RunQueryOptions['history'] = [
377
+ const history: RunQueryOptions['history'] = deps.initialHistory?.length ? [...deps.initialHistory] : [
368
378
  { role: 'system', content: await buildSubSystemPrompt(cwd, def, subTools) },
369
379
  ];
370
380
  const historyLenBefore = history.length;
@@ -582,6 +592,7 @@ export async function runAgentTask(
582
592
  } finally {
583
593
  stopTimeout();
584
594
  releaseLease?.();
595
+ await deps.captureHistory?.(history);
585
596
  }
586
597
 
587
598
  // ---- 结局映射(直接查 signal 状态,不依赖事件旗标) ----
@@ -684,6 +695,130 @@ async function call(input: AgentInput, signal?: AbortSignal): Promise<ToolResult
684
695
  return runAgentTask(input, signal, {});
685
696
  }
686
697
 
698
+ export async function runManagedAgentTask(
699
+ input: AgentInput,
700
+ signal: AbortSignal | undefined,
701
+ ctx: ToolCallContext,
702
+ deps: AgentTaskDeps = {},
703
+ ): Promise<ToolResult> {
704
+ if (!ctx.sessionState) return runAgentTask(input, signal, ctx);
705
+ const id = input.resume_agent_id ?? `agent_${randomUUID()}`;
706
+ let initialHistory: RunQueryOptions['history'] | undefined;
707
+ let resumedWorktree: AgentWorktree | undefined;
708
+ let coreInput = { ...input, run_in_background: undefined, resume_agent_id: undefined };
709
+ if (input.resume_agent_id) {
710
+ const live = ctx.sessionState.agentTasks.tasks.get(id);
711
+ if (live?.status === 'running') return { ok: false, error: `Agent ${id} 正在运行,不能并发恢复` };
712
+ const saved = await loadAgentRecord(ctx, id);
713
+ if (!saved) return { ok: false, error: `找不到可恢复 Agent:${id}` };
714
+ initialHistory = saved.history;
715
+ resumedWorktree = saved.worktree && await access(saved.worktree.path)
716
+ .then(() => saved.worktree)
717
+ .catch(() => undefined);
718
+ coreInput = {
719
+ ...(saved.input as AgentInput),
720
+ prompt: input.prompt,
721
+ description: input.description || saved.description,
722
+ run_in_background: undefined,
723
+ resume_agent_id: undefined,
724
+ };
725
+ }
726
+ const abort = new AbortController();
727
+ const combined = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
728
+ let settle!: () => void;
729
+ const completion = new Promise<void>((resolvePromise) => { settle = resolvePromise; });
730
+ const task: LiveAgentTask = {
731
+ id,
732
+ status: 'running',
733
+ description: coreInput.description,
734
+ input: coreInput as unknown as Record<string, unknown>,
735
+ createdAt: Date.now(),
736
+ updatedAt: Date.now(),
737
+ abort,
738
+ completion,
739
+ events: new EventEmitter(),
740
+ };
741
+ ctx.sessionState.agentTasks.tasks.set(id, task);
742
+ await saveAgentRecord(ctx, task);
743
+ await appendAgentEvent(ctx, id, { phase: 'started', resumed: Boolean(input.resume_agent_id) });
744
+
745
+ const run = async (): Promise<ToolResult> => {
746
+ let captured = initialHistory;
747
+ let lastPersistedProgressAt = 0;
748
+ const managedContext: ToolCallContext = {
749
+ ...ctx,
750
+ reportProgress: (line, detail) => {
751
+ ctx.reportProgress?.(line, detail);
752
+ task.progress = line;
753
+ task.events.emit('change');
754
+ if (Date.now() - lastPersistedProgressAt >= 1_000) {
755
+ lastPersistedProgressAt = Date.now();
756
+ void saveAgentRecord(ctx, task).catch(() => undefined);
757
+ void appendAgentEvent(ctx, id, { phase: 'progress', message: line })
758
+ .catch(() => undefined);
759
+ }
760
+ },
761
+ };
762
+ try {
763
+ const result = await runAgentTask(coreInput, combined, managedContext, {
764
+ ...deps,
765
+ initialHistory,
766
+ existingWorktree: resumedWorktree,
767
+ onWorktreeCreated: async (worktree) => {
768
+ task.worktree = worktree;
769
+ await saveAgentRecord(ctx, task);
770
+ },
771
+ captureHistory: (history) => { captured = [...history]; },
772
+ });
773
+ task.result = result;
774
+ task.history = captured;
775
+ task.status = abort.signal.aborted ? 'stopped' : result.ok ? 'completed' : 'failed';
776
+ if (task.worktree && !await access(task.worktree.path).then(() => true).catch(() => false)) {
777
+ task.worktree = undefined;
778
+ }
779
+ task.updatedAt = Date.now();
780
+ await saveAgentRecord(ctx, task);
781
+ await appendAgentEvent(ctx, id, { phase: task.status, ok: result.ok });
782
+ return result;
783
+ } catch (error) {
784
+ task.status = abort.signal.aborted ? 'stopped' : 'failed';
785
+ task.result = { ok: false, error: (error as Error).message };
786
+ task.history = captured;
787
+ task.updatedAt = Date.now();
788
+ await saveAgentRecord(ctx, task).catch(() => undefined);
789
+ await appendAgentEvent(ctx, id, { phase: task.status, error: (error as Error).message })
790
+ .catch(() => undefined);
791
+ return task.result;
792
+ } finally {
793
+ task.events.emit('change');
794
+ settle();
795
+ }
796
+ };
797
+
798
+ if (input.run_in_background) {
799
+ void run();
800
+ return {
801
+ ok: true,
802
+ content: `后台 Agent 已启动。agent_id=${id}\n使用 TaskOutput 获取进度/结果,TaskStop 停止。`,
803
+ metadata: {
804
+ kind: 'agent', agentId: id, status: 'running',
805
+ agentType: input.agent_type ?? 'explore', description: input.description,
806
+ isolation: input.isolation ?? 'none', turns: 0, toolCalls: 0,
807
+ failedToolCalls: 0, durationMs: 0, changedFiles: [],
808
+ },
809
+ };
810
+ }
811
+ const result = await run();
812
+ if (result.ok && result.metadata?.kind === 'agent') {
813
+ result.metadata.agentId = id;
814
+ result.metadata.status = 'completed';
815
+ result.content = `agent_id=${id}\n${result.content}`;
816
+ } else if (!result.ok) {
817
+ result.error = `agent_id=${id}\n${result.error}`;
818
+ }
819
+ return result;
820
+ }
821
+
687
822
  // ---------------- 5. 导出 ----------------
688
823
 
689
824
  export const agentTool: Tool<AgentInput> = {
@@ -698,5 +833,5 @@ export const agentTool: Tool<AgentInput> = {
698
833
  maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
699
834
  interruptBehavior: () => 'block',
700
835
  call,
701
- callWithContext: runAgentTask,
836
+ callWithContext: runManagedAgentTask,
702
837
  };
@@ -0,0 +1,60 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readRuntimeJson, sessionRuntimeDir, withRuntimeFileLock, writeRuntimeJson, } from '../../session/runtimeStore.js';
4
+ export function createAgentTaskState() {
5
+ return { tasks: new Map() };
6
+ }
7
+ function dir(ctx, id) {
8
+ return join(sessionRuntimeDir(ctx), 'agents', id);
9
+ }
10
+ export async function appendAgentEvent(ctx, id, event) {
11
+ const path = join(dir(ctx, id), 'events.jsonl');
12
+ await mkdir(dir(ctx, id), { recursive: true, mode: 0o700 });
13
+ await withRuntimeFileLock(path, () => appendFile(path, `${JSON.stringify({ at: Date.now(), ...event })}\n`, {
14
+ encoding: 'utf8',
15
+ mode: 0o600,
16
+ }));
17
+ }
18
+ export async function saveAgentRecord(ctx, record) {
19
+ const persisted = {
20
+ id: record.id,
21
+ status: record.status,
22
+ description: record.description,
23
+ input: record.input,
24
+ createdAt: record.createdAt,
25
+ updatedAt: record.updatedAt,
26
+ ...(record.progress ? { progress: record.progress } : {}),
27
+ ...(record.worktree ? { worktree: record.worktree } : {}),
28
+ ...(record.result ? { result: record.result } : {}),
29
+ };
30
+ const recordDir = dir(ctx, record.id);
31
+ await withRuntimeFileLock(join(recordDir, '.record'), async () => {
32
+ await writeRuntimeJson(join(recordDir, 'state.json'), persisted);
33
+ if (record.history) {
34
+ await writeRuntimeJson(join(recordDir, 'history.json'), record.history);
35
+ }
36
+ });
37
+ }
38
+ export async function loadAgentRecord(ctx, id) {
39
+ const state = await readRuntimeJson(join(dir(ctx, id), 'state.json'), undefined);
40
+ if (!state)
41
+ return undefined;
42
+ const history = await readRuntimeJson(join(dir(ctx, id), 'history.json'), undefined);
43
+ return {
44
+ ...state,
45
+ status: state.status === 'running' ? 'interrupted' : state.status,
46
+ history,
47
+ };
48
+ }
49
+ export function getLiveAgentTask(state, id) {
50
+ return state?.tasks.get(id);
51
+ }
52
+ export async function stopAllAgentTasks(state) {
53
+ const running = [...state.tasks.values()].filter((t) => t.status === 'running');
54
+ running.forEach((t) => t.abort.abort());
55
+ await Promise.race([
56
+ Promise.allSettled(running.map((t) => t.completion)).then(() => undefined),
57
+ new Promise((r) => setTimeout(r, 5000)),
58
+ ]);
59
+ state.tasks.clear();
60
+ }
@@ -0,0 +1,113 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { appendFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { Message, ToolCallContext, ToolResult } from '../../types.js';
5
+ import type { AgentWorktree } from './worktree.js';
6
+ import {
7
+ readRuntimeJson,
8
+ sessionRuntimeDir,
9
+ withRuntimeFileLock,
10
+ writeRuntimeJson,
11
+ } from '../../session/runtimeStore.js';
12
+
13
+ export type AgentTaskStatus =
14
+ 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
15
+ export interface AgentTaskRecord {
16
+ id: string;
17
+ status: AgentTaskStatus;
18
+ description: string;
19
+ input: Record<string, unknown>;
20
+ createdAt: number;
21
+ updatedAt: number;
22
+ progress?: string;
23
+ worktree?: AgentWorktree;
24
+ result?: ToolResult;
25
+ history?: Message[];
26
+ }
27
+ export interface LiveAgentTask extends AgentTaskRecord {
28
+ abort: AbortController;
29
+ completion: Promise<void>;
30
+ events: EventEmitter;
31
+ }
32
+ export interface AgentTaskState {
33
+ tasks: Map<string, LiveAgentTask>;
34
+ }
35
+ export function createAgentTaskState(): AgentTaskState {
36
+ return { tasks: new Map() };
37
+ }
38
+ function dir(ctx: ToolCallContext, id: string) {
39
+ return join(sessionRuntimeDir(ctx), 'agents', id);
40
+ }
41
+ export async function appendAgentEvent(
42
+ ctx: ToolCallContext,
43
+ id: string,
44
+ event: Record<string, unknown>,
45
+ ): Promise<void> {
46
+ const path = join(dir(ctx, id), 'events.jsonl');
47
+ await mkdir(dir(ctx, id), { recursive: true, mode: 0o700 });
48
+ await withRuntimeFileLock(path, () =>
49
+ appendFile(path, `${JSON.stringify({ at: Date.now(), ...event })}\n`, {
50
+ encoding: 'utf8',
51
+ mode: 0o600,
52
+ }),
53
+ );
54
+ }
55
+ export async function saveAgentRecord(
56
+ ctx: ToolCallContext,
57
+ record: AgentTaskRecord,
58
+ ): Promise<void> {
59
+ const persisted: AgentTaskRecord = {
60
+ id: record.id,
61
+ status: record.status,
62
+ description: record.description,
63
+ input: record.input,
64
+ createdAt: record.createdAt,
65
+ updatedAt: record.updatedAt,
66
+ ...(record.progress ? { progress: record.progress } : {}),
67
+ ...(record.worktree ? { worktree: record.worktree } : {}),
68
+ ...(record.result ? { result: record.result } : {}),
69
+ };
70
+ const recordDir = dir(ctx, record.id);
71
+ await withRuntimeFileLock(join(recordDir, '.record'), async () => {
72
+ await writeRuntimeJson(join(recordDir, 'state.json'), persisted);
73
+ if (record.history) {
74
+ await writeRuntimeJson(join(recordDir, 'history.json'), record.history);
75
+ }
76
+ });
77
+ }
78
+ export async function loadAgentRecord(
79
+ ctx: ToolCallContext,
80
+ id: string,
81
+ ): Promise<AgentTaskRecord | undefined> {
82
+ const state = await readRuntimeJson<AgentTaskRecord | undefined>(
83
+ join(dir(ctx, id), 'state.json'),
84
+ undefined,
85
+ );
86
+ if (!state) return undefined;
87
+ const history = await readRuntimeJson<Message[] | undefined>(
88
+ join(dir(ctx, id), 'history.json'),
89
+ undefined,
90
+ );
91
+ return {
92
+ ...state,
93
+ status: state.status === 'running' ? 'interrupted' : state.status,
94
+ history,
95
+ };
96
+ }
97
+ export function getLiveAgentTask(
98
+ state: AgentTaskState | undefined,
99
+ id: string,
100
+ ): LiveAgentTask | undefined {
101
+ return state?.tasks.get(id);
102
+ }
103
+ export async function stopAllAgentTasks(state: AgentTaskState): Promise<void> {
104
+ const running = [...state.tasks.values()].filter(
105
+ (t) => t.status === 'running',
106
+ );
107
+ running.forEach((t) => t.abort.abort());
108
+ await Promise.race([
109
+ Promise.allSettled(running.map((t) => t.completion)).then(() => undefined),
110
+ new Promise<void>((r) => setTimeout(r, 5000)),
111
+ ]);
112
+ state.tasks.clear();
113
+ }