minimal-agent 0.11.0 → 0.13.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 (181) hide show
  1. package/package.json +5 -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/expressions.js +29 -8
  7. package/plugins/workflow-runner/src/expressions.ts +28 -10
  8. package/plugins/workflow-runner/src/index.js +50 -20
  9. package/plugins/workflow-runner/src/index.ts +75 -31
  10. package/plugins/workflow-runner/src/multimodal.js +2 -1
  11. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  12. package/plugins/workflow-runner/src/recovery.js +9 -2
  13. package/plugins/workflow-runner/src/recovery.ts +9 -2
  14. package/plugins/workflow-runner/src/runner.js +31 -5
  15. package/plugins/workflow-runner/src/runner.ts +33 -5
  16. package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
  17. package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
  18. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  19. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  20. package/plugins/workflow-runner/src/stepExecutors/tool.js +91 -61
  21. package/plugins/workflow-runner/src/stepExecutors/tool.ts +111 -71
  22. package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
  23. package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
  24. package/plugins/workflow-runner/src/taskIdentity.js +197 -21
  25. package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
  26. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  27. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  28. package/plugins/workflow-runner/src/types.js +113 -7
  29. package/plugins/workflow-runner/src/types.ts +137 -14
  30. package/plugins/workflow-runner/src/workflowState.js +455 -54
  31. package/plugins/workflow-runner/src/workflowState.ts +508 -63
  32. package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
  33. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  34. package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
  35. package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
  36. package/plugins/workflow-runner/test/expressions.test.ts +42 -0
  37. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  38. package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
  39. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  40. package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
  41. package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
  42. package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
  43. package/plugins/workflow-runner/test/research.load.test.ts +328 -0
  44. package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
  45. package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
  46. package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
  47. package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
  48. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  49. package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
  50. package/plugins/workflow-runner/test/word.load.test.ts +9 -9
  51. package/skills/skill-creator/scripts/run_eval.py +3 -2
  52. package/src/cli/print.js +36 -4
  53. package/src/cli/print.ts +42 -4
  54. package/src/cli/streamJson.js +19 -4
  55. package/src/cli/streamJson.ts +17 -4
  56. package/src/config/configDefaults.js +25 -0
  57. package/src/config/configDefaults.ts +33 -0
  58. package/src/config/configFile.js +2 -21
  59. package/src/config/configFile.ts +11 -24
  60. package/src/config/configManagement.js +1 -1
  61. package/src/config/configManagement.ts +2 -2
  62. package/src/config/configService.js +24 -0
  63. package/src/config/configService.ts +23 -0
  64. package/src/context/compact.js +4 -0
  65. package/src/context/compact.ts +4 -0
  66. package/src/context/contextBundle.js +38 -13
  67. package/src/context/contextBundle.ts +47 -13
  68. package/src/harness/stallGuard.js +19 -4
  69. package/src/harness/stallGuard.ts +30 -7
  70. package/src/infra/verification.js +25 -18
  71. package/src/infra/verification.ts +50 -18
  72. package/src/loop.js +95 -11
  73. package/src/loop.ts +125 -16
  74. package/src/main.js +8 -1
  75. package/src/main.tsx +7 -1
  76. package/src/memory/constants.js +10 -0
  77. package/src/memory/constants.ts +13 -0
  78. package/src/memory/episodicMemory.js +274 -2
  79. package/src/memory/episodicMemory.ts +306 -1
  80. package/src/memory/fileStore.js +24 -4
  81. package/src/memory/fileStore.ts +22 -2
  82. package/src/memory/memoryService.js +79 -12
  83. package/src/memory/memoryService.ts +108 -9
  84. package/src/memory/repository.js +415 -85
  85. package/src/memory/repository.ts +437 -86
  86. package/src/memory/turnJournal.js +95 -0
  87. package/src/memory/turnJournal.ts +120 -0
  88. package/src/memory/types.ts +16 -0
  89. package/src/plugins/pluginApi.ts +30 -6
  90. package/src/plugins/pluginRunner.js +23 -8
  91. package/src/plugins/pluginRunner.ts +32 -13
  92. package/src/prompts/policyRegistry.js +55 -1
  93. package/src/prompts/policyRegistry.ts +55 -1
  94. package/src/prompts/promptAssembly.js +39 -7
  95. package/src/prompts/promptAssembly.ts +145 -59
  96. package/src/session/runAgentTurn.js +43 -21
  97. package/src/session/runAgentTurn.ts +68 -26
  98. package/src/session/runTurn.js +11 -46
  99. package/src/session/runTurn.ts +12 -46
  100. package/src/session/runtimeStore.js +149 -0
  101. package/src/session/runtimeStore.ts +197 -0
  102. package/src/session/sessionState.js +5 -0
  103. package/src/session/sessionState.ts +14 -0
  104. package/src/tools/advisor/advisor.js +76 -9
  105. package/src/tools/advisor/advisor.ts +79 -9
  106. package/src/tools/agent/agent.js +143 -14
  107. package/src/tools/agent/agent.ts +149 -14
  108. package/src/tools/agent/agentTaskService.js +60 -0
  109. package/src/tools/agent/agentTaskService.ts +113 -0
  110. package/src/tools/agent/agentTypes.js +5 -0
  111. package/src/tools/agent/agentTypes.ts +5 -0
  112. package/src/tools/bash/bash.js +80 -15
  113. package/src/tools/bash/bash.ts +83 -15
  114. package/src/tools/capabilities.js +5 -0
  115. package/src/tools/capabilities.ts +11 -1
  116. package/src/tools/edit/edit.js +7 -4
  117. package/src/tools/edit/edit.ts +7 -4
  118. package/src/tools/edit/multi-edit.js +4 -2
  119. package/src/tools/edit/multi-edit.ts +4 -2
  120. package/src/tools/glob/glob.js +156 -40
  121. package/src/tools/glob/glob.ts +185 -46
  122. package/src/tools/index.js +35 -17
  123. package/src/tools/index.ts +81 -20
  124. package/src/tools/lsp/lsp.js +165 -22
  125. package/src/tools/lsp/lsp.ts +206 -36
  126. package/src/tools/powershell/powershell.js +134 -27
  127. package/src/tools/powershell/powershell.ts +171 -30
  128. package/src/tools/presentresult/presentresult.js +10 -0
  129. package/src/tools/presentresult/presentresult.ts +10 -0
  130. package/src/tools/read/read.js +249 -29
  131. package/src/tools/read/read.ts +396 -59
  132. package/src/tools/shared/diffSummary.js +44 -0
  133. package/src/tools/shared/diffSummary.ts +63 -0
  134. package/src/tools/shared/fileActivity.js +2 -1
  135. package/src/tools/shared/fileActivity.ts +3 -0
  136. package/src/tools/shared/permanentDeleteGuard.js +57 -0
  137. package/src/tools/shared/permanentDeleteGuard.ts +71 -0
  138. package/src/tools/shared/toolOutputStore.js +97 -0
  139. package/src/tools/shared/toolOutputStore.ts +110 -0
  140. package/src/tools/skill/skill.js +86 -0
  141. package/src/tools/skill/skill.ts +94 -0
  142. package/src/tools/snip/snip.js +67 -0
  143. package/src/tools/snip/snip.ts +80 -0
  144. package/src/tools/tasks/backgroundTaskService.js +79 -28
  145. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  146. package/src/tools/tasks/taskManagement.js +163 -0
  147. package/src/tools/tasks/taskManagement.ts +180 -0
  148. package/src/tools/tasks/taskOutput.js +64 -14
  149. package/src/tools/tasks/taskOutput.ts +102 -23
  150. package/src/tools/tasks/taskStop.js +48 -8
  151. package/src/tools/tasks/taskStop.ts +69 -9
  152. package/src/tools/tasks/taskStore.js +46 -0
  153. package/src/tools/tasks/taskStore.ts +77 -0
  154. package/src/tools/terminal/terminalCapture.js +167 -0
  155. package/src/tools/terminal/terminalCapture.ts +179 -0
  156. package/src/tools/trash/trash.js +233 -0
  157. package/src/tools/trash/trash.ts +283 -0
  158. package/src/tools/webbrowser/browserControl.js +5 -1
  159. package/src/tools/webbrowser/browserControl.ts +7 -1
  160. package/src/tools/webbrowser/webbrowser.js +8 -4
  161. package/src/tools/webbrowser/webbrowser.ts +8 -4
  162. package/src/tools/worktree/sessionWorktree.js +128 -0
  163. package/src/tools/worktree/sessionWorktree.ts +158 -0
  164. package/src/tools/write/write.js +6 -2
  165. package/src/tools/write/write.ts +6 -2
  166. package/src/types.ts +22 -4
  167. package/src/ui/hooks/useChat.js +31 -11
  168. package/src/ui/hooks/useChat.ts +31 -13
  169. package/src/web/protocol.ts +78 -3
  170. package/workflows/adaptive-research.yaml +5 -5
  171. package/workflows/book-review-short.yaml +5 -5
  172. package/workflows/e2e-write-greet.yaml +2 -2
  173. package/workflows/illustrate.yaml +12 -12
  174. package/workflows/plan.yaml +18 -18
  175. package/workflows/ppt.yaml +201 -201
  176. package/workflows/ralph-loop.yaml +3 -1
  177. package/workflows/research.md +449 -0
  178. package/workflows/research.yaml +873 -0
  179. package/workflows/webppt.yaml +47 -47
  180. package/workflows/word.yaml +80 -80
  181. package/workflows/youtube-shorts.yaml +25 -25
@@ -8,10 +8,24 @@ export function contextFragment(input) {
8
8
  estimatedTokens: input.estimatedTokens ?? countTextTokens(content),
9
9
  };
10
10
  }
11
- function clipToTokenBudget(content, budgetTokens) {
11
+ const CLIP_MARKER = '\n[上下文片段因预算截断]';
12
+ /**
13
+ * 按**渲染后**的 token 数收敛,而不是裸内容。
14
+ *
15
+ * ⚠️ 曾经的实现拿裸内容 token 判预算、外层却用 `renderedTokens()`(JSON 转义 + 信封)验收,
16
+ * 于是 `裸 token ≤ 预算 < 渲染 token` 这个窗口里 clip 会「认为放得下」原样返回,验收再不通过,
17
+ * 片段就被整段丢进 `omittedIds` —— `clippable` 形同虚设(`clippedIds` 恒空)。
18
+ * 窗口宽度 = 转义膨胀量,所以 **Windows 上尤其致命**:技能目录里每个 `\` 都要转义成 `\\`,
19
+ * 27 条技能的窗口从 POSIX 的 ~25 token 涨到 ~92 token,本仓库实测整份 `skills.catalog`
20
+ * (2643 token)在还剩 1900+ token 预算时被整个扔掉,模型完全看不到任何 Skill。
21
+ *
22
+ * 判据必须与验收同口径:这里直接对 `measure(候选内容)` 二分,measure 由调用方传入,
23
+ * 保证「算的就是最终要花的」。
24
+ */
25
+ function clipToRenderedBudget(content, budgetTokens, measure) {
12
26
  if (budgetTokens <= 0)
13
27
  return '';
14
- if (countTextTokens(content) <= budgetTokens)
28
+ if (measure(content) <= budgetTokens)
15
29
  return content;
16
30
  // Token counter is deliberately dependency-free. Binary search preserves its
17
31
  // exact mixed Chinese/ASCII estimate instead of falling back to chars/4.
@@ -19,12 +33,15 @@ function clipToTokenBudget(content, budgetTokens) {
19
33
  let high = content.length;
20
34
  while (low < high) {
21
35
  const mid = Math.ceil((low + high) / 2);
22
- if (countTextTokens(content.slice(0, mid)) <= Math.max(1, budgetTokens - 12))
36
+ const candidate = `${content.slice(0, mid).trimEnd()}${CLIP_MARKER}`;
37
+ if (measure(candidate) <= budgetTokens)
23
38
  low = mid;
24
39
  else
25
40
  high = mid - 1;
26
41
  }
27
- return `${content.slice(0, low).trimEnd()}\n[上下文片段因预算截断]`;
42
+ if (low <= 0)
43
+ return '';
44
+ return `${content.slice(0, low).trimEnd()}${CLIP_MARKER}`;
28
45
  }
29
46
  export function renderContextFragment(fragment) {
30
47
  const encoded = JSON.stringify(fragment.content)
@@ -36,6 +53,9 @@ export function renderContextFragment(fragment) {
36
53
  function renderedTokens(fragment) {
37
54
  return countTextTokens(renderContextFragment(fragment));
38
55
  }
56
+ /** 片段之间的分隔符——记账时必须与最终 join 用的是同一个常量。 */
57
+ const FRAGMENT_SEPARATOR = '\n\n';
58
+ const SEPARATOR_TOKENS = countTextTokens(FRAGMENT_SEPARATOR);
39
59
  /**
40
60
  * Deterministic context budgeting. Required host facts are kept first; optional
41
61
  * fragments compete by priority, then original order. No LLM call is involved.
@@ -52,21 +72,26 @@ export function assembleContextBundle(input, budgetTokens = DEFAULT_TRANSIENT_BU
52
72
  let remaining = Math.max(0, budgetTokens);
53
73
  for (const item of ordered) {
54
74
  const fragment = item.fragment;
75
+ // 片段之间要用 SEPARATOR 拼接,那几个 token 也得记账,否则最终 text 会超出预算
76
+ // (usedTokens > budgetTokens 会让 loop.ts::transientFor 的余量判断失真)。
77
+ const separator = included.length > 0 ? SEPARATOR_TOKENS : 0;
78
+ const budgetForItem = remaining - separator;
55
79
  const fullTokens = renderedTokens(fragment);
56
- if (fullTokens <= remaining) {
80
+ if (fullTokens <= budgetForItem) {
57
81
  included.push(item);
58
- remaining -= fullTokens;
82
+ remaining -= fullTokens + separator;
59
83
  continue;
60
84
  }
61
- if ((fragment.required || fragment.clippable) && remaining > 24) {
62
- const wrapperTokens = renderedTokens({ ...fragment, content: '' });
63
- const content = clipToTokenBudget(fragment.content, Math.max(0, remaining - wrapperTokens));
85
+ if ((fragment.required || fragment.clippable) && budgetForItem > 24) {
86
+ // 同口径:二分时就按「这段内容渲染进本片段信封后要花多少 token」来量。
87
+ const content = clipToRenderedBudget(fragment.content, budgetForItem, (candidate) => renderedTokens({ ...fragment, content: candidate }));
64
88
  const clipped = contextFragment({ ...fragment, content });
65
89
  const clippedTokens = renderedTokens(clipped);
66
- if (clipped.content && clippedTokens <= remaining) {
90
+ if (clipped.content && clippedTokens <= budgetForItem) {
67
91
  included.push({ fragment: clipped, index: item.index });
68
- clippedIds.push(fragment.id);
69
- remaining -= clippedTokens;
92
+ if (clipped.content !== fragment.content)
93
+ clippedIds.push(fragment.id);
94
+ remaining -= clippedTokens + separator;
70
95
  continue;
71
96
  }
72
97
  }
@@ -75,7 +100,7 @@ export function assembleContextBundle(input, budgetTokens = DEFAULT_TRANSIENT_BU
75
100
  // Rendering follows semantic input order, not selection priority.
76
101
  included.sort((a, b) => a.index - b.index);
77
102
  const selected = included.map((item) => item.fragment);
78
- const text = selected.map(renderContextFragment).join('\n\n');
103
+ const text = selected.map(renderContextFragment).join(FRAGMENT_SEPARATOR);
79
104
  const usedTokens = countTextTokens(text);
80
105
  return {
81
106
  fragments: selected,
@@ -8,6 +8,7 @@ export type ContextFragmentKind =
8
8
  | 'project'
9
9
  | 'skill'
10
10
  | 'plugin'
11
+ | 'task'
11
12
  | 'diagnostic';
12
13
 
13
14
  export type ContextAuthority = 'host' | 'constraint' | 'user-evidence' | 'untrusted-data';
@@ -50,19 +51,40 @@ export function contextFragment(
50
51
  };
51
52
  }
52
53
 
53
- function clipToTokenBudget(content: string, budgetTokens: number): string {
54
+ const CLIP_MARKER = '\n[上下文片段因预算截断]';
55
+
56
+ /**
57
+ * 按**渲染后**的 token 数收敛,而不是裸内容。
58
+ *
59
+ * ⚠️ 曾经的实现拿裸内容 token 判预算、外层却用 `renderedTokens()`(JSON 转义 + 信封)验收,
60
+ * 于是 `裸 token ≤ 预算 < 渲染 token` 这个窗口里 clip 会「认为放得下」原样返回,验收再不通过,
61
+ * 片段就被整段丢进 `omittedIds` —— `clippable` 形同虚设(`clippedIds` 恒空)。
62
+ * 窗口宽度 = 转义膨胀量,所以 **Windows 上尤其致命**:技能目录里每个 `\` 都要转义成 `\\`,
63
+ * 27 条技能的窗口从 POSIX 的 ~25 token 涨到 ~92 token,本仓库实测整份 `skills.catalog`
64
+ * (2643 token)在还剩 1900+ token 预算时被整个扔掉,模型完全看不到任何 Skill。
65
+ *
66
+ * 判据必须与验收同口径:这里直接对 `measure(候选内容)` 二分,measure 由调用方传入,
67
+ * 保证「算的就是最终要花的」。
68
+ */
69
+ function clipToRenderedBudget(
70
+ content: string,
71
+ budgetTokens: number,
72
+ measure: (candidate: string) => number,
73
+ ): string {
54
74
  if (budgetTokens <= 0) return '';
55
- if (countTextTokens(content) <= budgetTokens) return content;
75
+ if (measure(content) <= budgetTokens) return content;
56
76
  // Token counter is deliberately dependency-free. Binary search preserves its
57
77
  // exact mixed Chinese/ASCII estimate instead of falling back to chars/4.
58
78
  let low = 0;
59
79
  let high = content.length;
60
80
  while (low < high) {
61
81
  const mid = Math.ceil((low + high) / 2);
62
- if (countTextTokens(content.slice(0, mid)) <= Math.max(1, budgetTokens - 12)) low = mid;
82
+ const candidate = `${content.slice(0, mid).trimEnd()}${CLIP_MARKER}`;
83
+ if (measure(candidate) <= budgetTokens) low = mid;
63
84
  else high = mid - 1;
64
85
  }
65
- return `${content.slice(0, low).trimEnd()}\n[上下文片段因预算截断]`;
86
+ if (low <= 0) return '';
87
+ return `${content.slice(0, low).trimEnd()}${CLIP_MARKER}`;
66
88
  }
67
89
 
68
90
  export function renderContextFragment(fragment: ContextFragment): string {
@@ -77,6 +99,10 @@ function renderedTokens(fragment: ContextFragment): number {
77
99
  return countTextTokens(renderContextFragment(fragment));
78
100
  }
79
101
 
102
+ /** 片段之间的分隔符——记账时必须与最终 join 用的是同一个常量。 */
103
+ const FRAGMENT_SEPARATOR = '\n\n';
104
+ const SEPARATOR_TOKENS = countTextTokens(FRAGMENT_SEPARATOR);
105
+
80
106
  /**
81
107
  * Deterministic context budgeting. Required host facts are kept first; optional
82
108
  * fragments compete by priority, then original order. No LLM call is involved.
@@ -98,21 +124,29 @@ export function assembleContextBundle(
98
124
 
99
125
  for (const item of ordered) {
100
126
  const fragment = item.fragment;
127
+ // 片段之间要用 SEPARATOR 拼接,那几个 token 也得记账,否则最终 text 会超出预算
128
+ // (usedTokens > budgetTokens 会让 loop.ts::transientFor 的余量判断失真)。
129
+ const separator = included.length > 0 ? SEPARATOR_TOKENS : 0;
130
+ const budgetForItem = remaining - separator;
101
131
  const fullTokens = renderedTokens(fragment);
102
- if (fullTokens <= remaining) {
132
+ if (fullTokens <= budgetForItem) {
103
133
  included.push(item);
104
- remaining -= fullTokens;
134
+ remaining -= fullTokens + separator;
105
135
  continue;
106
136
  }
107
- if ((fragment.required || fragment.clippable) && remaining > 24) {
108
- const wrapperTokens = renderedTokens({ ...fragment, content: '' });
109
- const content = clipToTokenBudget(fragment.content, Math.max(0, remaining - wrapperTokens));
137
+ if ((fragment.required || fragment.clippable) && budgetForItem > 24) {
138
+ // 同口径:二分时就按「这段内容渲染进本片段信封后要花多少 token」来量。
139
+ const content = clipToRenderedBudget(
140
+ fragment.content,
141
+ budgetForItem,
142
+ (candidate) => renderedTokens({ ...fragment, content: candidate }),
143
+ );
110
144
  const clipped = contextFragment({ ...fragment, content });
111
145
  const clippedTokens = renderedTokens(clipped);
112
- if (clipped.content && clippedTokens <= remaining) {
146
+ if (clipped.content && clippedTokens <= budgetForItem) {
113
147
  included.push({ fragment: clipped, index: item.index });
114
- clippedIds.push(fragment.id);
115
- remaining -= clippedTokens;
148
+ if (clipped.content !== fragment.content) clippedIds.push(fragment.id);
149
+ remaining -= clippedTokens + separator;
116
150
  continue;
117
151
  }
118
152
  }
@@ -122,7 +156,7 @@ export function assembleContextBundle(
122
156
  // Rendering follows semantic input order, not selection priority.
123
157
  included.sort((a, b) => a.index - b.index);
124
158
  const selected = included.map((item) => item.fragment);
125
- const text = selected.map(renderContextFragment).join('\n\n');
159
+ const text = selected.map(renderContextFragment).join(FRAGMENT_SEPARATOR);
126
160
  const usedTokens = countTextTokens(text);
127
161
  return {
128
162
  fragments: selected,
@@ -5,33 +5,48 @@ function hash(value) {
5
5
  /** Run-local deterministic guard. It never persists arguments or result text. */
6
6
  export class StallGuard {
7
7
  #lastFailure = '';
8
+ #lastFailureCall = '';
8
9
  #failureCount = 0;
9
10
  #lastBatch = '';
10
11
  #batchCount = 0;
12
+ shouldReject(call) {
13
+ return (this.#failureCount >= 2 &&
14
+ this.#lastFailureCall ===
15
+ hash(`${call.function.name}\0${call.function.arguments}`));
16
+ }
11
17
  observe(calls, results, assistantText) {
12
18
  for (let index = 0; index < calls.length; index += 1) {
13
19
  const call = calls[index];
14
20
  const result = results[index];
15
21
  if (result.ok) {
16
22
  this.#lastFailure = '';
23
+ this.#lastFailureCall = '';
17
24
  this.#failureCount = 0;
18
25
  continue;
19
26
  }
20
- const signature = hash(`${call.function.name}\0${call.function.arguments}\0${result.error}`);
27
+ const callSignature = hash(`${call.function.name}\0${call.function.arguments}`);
28
+ const signature = hash(`${callSignature}\0${result.error}`);
21
29
  if (signature === this.#lastFailure)
22
30
  this.#failureCount += 1;
23
31
  else {
24
32
  this.#lastFailure = signature;
33
+ this.#lastFailureCall = callSignature;
25
34
  this.#failureCount = 1;
26
35
  }
27
36
  if (this.#failureCount >= 3) {
28
- return { stalled: true, reason: 'repeated_failure', signature: signature.slice(0, 16) };
37
+ return {
38
+ stalled: true,
39
+ reason: 'repeated_failure',
40
+ signature: signature.slice(0, 16),
41
+ };
29
42
  }
30
43
  }
31
- const batch = hash(calls.map((call, index) => {
44
+ const batch = hash(calls
45
+ .map((call, index) => {
32
46
  const result = results[index];
33
47
  return `${call.function.name}\0${call.function.arguments}\0${result.ok ? result.content : result.error}`;
34
- }).join('\x01'));
48
+ })
49
+ .join('\x01'));
35
50
  if (assistantText.trim().length === 0 && batch === this.#lastBatch)
36
51
  this.#batchCount += 1;
37
52
  else {
@@ -14,10 +14,19 @@ export interface StallObservation {
14
14
  /** Run-local deterministic guard. It never persists arguments or result text. */
15
15
  export class StallGuard {
16
16
  #lastFailure = '';
17
+ #lastFailureCall = '';
17
18
  #failureCount = 0;
18
19
  #lastBatch = '';
19
20
  #batchCount = 0;
20
21
 
22
+ shouldReject(call: ToolCall): boolean {
23
+ return (
24
+ this.#failureCount >= 2 &&
25
+ this.#lastFailureCall ===
26
+ hash(`${call.function.name}\0${call.function.arguments}`)
27
+ );
28
+ }
29
+
21
30
  observe(
22
31
  calls: readonly ToolCall[],
23
32
  results: readonly ToolResult[],
@@ -28,25 +37,39 @@ export class StallGuard {
28
37
  const result = results[index]!;
29
38
  if (result.ok) {
30
39
  this.#lastFailure = '';
40
+ this.#lastFailureCall = '';
31
41
  this.#failureCount = 0;
32
42
  continue;
33
43
  }
34
- const signature = hash(`${call.function.name}\0${call.function.arguments}\0${result.error}`);
44
+ const callSignature = hash(
45
+ `${call.function.name}\0${call.function.arguments}`,
46
+ );
47
+ const signature = hash(`${callSignature}\0${result.error}`);
35
48
  if (signature === this.#lastFailure) this.#failureCount += 1;
36
49
  else {
37
50
  this.#lastFailure = signature;
51
+ this.#lastFailureCall = callSignature;
38
52
  this.#failureCount = 1;
39
53
  }
40
54
  if (this.#failureCount >= 3) {
41
- return { stalled: true, reason: 'repeated_failure', signature: signature.slice(0, 16) };
55
+ return {
56
+ stalled: true,
57
+ reason: 'repeated_failure',
58
+ signature: signature.slice(0, 16),
59
+ };
42
60
  }
43
61
  }
44
62
 
45
- const batch = hash(calls.map((call, index) => {
46
- const result = results[index]!;
47
- return `${call.function.name}\0${call.function.arguments}\0${result.ok ? result.content : result.error}`;
48
- }).join('\x01'));
49
- if (assistantText.trim().length === 0 && batch === this.#lastBatch) this.#batchCount += 1;
63
+ const batch = hash(
64
+ calls
65
+ .map((call, index) => {
66
+ const result = results[index]!;
67
+ return `${call.function.name}\0${call.function.arguments}\0${result.ok ? result.content : result.error}`;
68
+ })
69
+ .join('\x01'),
70
+ );
71
+ if (assistantText.trim().length === 0 && batch === this.#lastBatch)
72
+ this.#batchCount += 1;
50
73
  else {
51
74
  this.#lastBatch = batch;
52
75
  this.#batchCount = assistantText.trim().length === 0 ? 1 : 0;
@@ -16,7 +16,13 @@
16
16
  * 设计原则:
17
17
  * - 纯工具函数,无全局状态,无副作用(除了 shell 子进程)
18
18
  * - 不依赖任何插件代码,可被 src/ 和 plugins/ 同时导入
19
- * - 工作目录隔离:所有相对路径以 getWorkingDir() 为锚
19
+ * - 工作目录隔离:相对路径以调用方给的 cwd 为锚,缺省 getWorkingDir()
20
+ *
21
+ * ⚠️ cwd 参数不是可有可无的修饰:**验收必须和写入锚在同一个目录**。
22
+ * 会话进入 session worktree 后(EnterWorktree),写文件的工具跟着 worktree 走
23
+ * (loop.ts 按 sessionState.activeWorktree 重定向 cwd),验收若仍锚原 workspace,
24
+ * 就会在空目录里找产物 —— file_exists 恒 false,工作流被判失败并反复重试,
25
+ * 而产物其实好好躺在 worktree 里。调用方拿得到有效 cwd 时务必传进来。
20
26
  * ============================================================
21
27
  */
22
28
  import { existsSync, readFileSync } from 'node:fs';
@@ -92,12 +98,13 @@ function isAbsolutePath(file) {
92
98
  /^[A-Za-z]:[\\/]/.test(file) // C:\ 或 C:/
93
99
  );
94
100
  }
95
- function runShell(command, timeout) {
101
+ function runShell(command, timeout, cwd) {
96
102
  return new Promise((resolve) => {
97
103
  const isWin = process.platform === 'win32';
104
+ const opts = { timeout, cwd, env: sanitizeCredentialEnv() };
98
105
  const child = isWin
99
- ? spawn('cmd', ['/c', command], { timeout, env: sanitizeCredentialEnv() })
100
- : spawn('bash', ['-c', command], { timeout, env: sanitizeCredentialEnv() });
106
+ ? spawn('cmd', ['/c', command], opts)
107
+ : spawn('bash', ['-c', command], opts);
101
108
  let stdout = '';
102
109
  let stderr = '';
103
110
  child.stdout.on('data', (d) => {
@@ -114,8 +121,8 @@ function runShell(command, timeout) {
114
121
  });
115
122
  });
116
123
  }
117
- export async function verifyShell(command, timeout) {
118
- const r = await runShell(command, timeout);
124
+ export async function verifyShell(command, timeout, cwd = getWorkingDir()) {
125
+ const r = await runShell(command, timeout, cwd);
119
126
  if (r.errored) {
120
127
  return {
121
128
  check: { type: 'shell', command },
@@ -129,9 +136,9 @@ export async function verifyShell(command, timeout) {
129
136
  output: r.stdout.trim() || r.stderr.trim() || `exit code ${r.exitCode}`,
130
137
  };
131
138
  }
132
- export function verifyFileExists(file) {
133
- // 相对路径以 workingDir 为锚(绝对路径识别含 UNC)
134
- const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
139
+ export function verifyFileExists(file, cwd = getWorkingDir()) {
140
+ // 相对路径以调用方给的 cwd 为锚(绝对路径识别含 UNC)
141
+ const resolved = isAbsolutePath(file) ? file : `${cwd}/${file}`;
135
142
  const exists = existsSync(resolved);
136
143
  return {
137
144
  check: { type: 'file_exists', file },
@@ -139,8 +146,8 @@ export function verifyFileExists(file) {
139
146
  output: exists ? '文件存在' : `文件不存在: ${resolved}`,
140
147
  };
141
148
  }
142
- export function verifyFileContains(file, pattern) {
143
- const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
149
+ export function verifyFileContains(file, pattern, cwd = getWorkingDir()) {
150
+ const resolved = isAbsolutePath(file) ? file : `${cwd}/${file}`;
144
151
  try {
145
152
  const content = readFileSync(resolved, 'utf8');
146
153
  const regex = new RegExp(pattern);
@@ -159,11 +166,11 @@ export function verifyFileContains(file, pattern) {
159
166
  };
160
167
  }
161
168
  }
162
- async function verifyTestCount(minCount) {
169
+ async function verifyTestCount(minCount, cwd = getWorkingDir()) {
163
170
  // 跨运行时:开发用 bun → bun test;npm 全局安装用 Node → npm test --silent
164
171
  // 用 process.versions.bun 探测比 typeof Bun !== 'undefined' 更稳(不依赖全局类型)
165
172
  const cmd = process.versions.bun ? 'bun test' : 'npm test --silent';
166
- const r = await runShell(cmd, 60_000);
173
+ const r = await runShell(cmd, 60_000, cwd);
167
174
  const combined = `${r.stdout}\n${r.stderr}`;
168
175
  if (r.errored) {
169
176
  return {
@@ -185,7 +192,7 @@ async function verifyTestCount(minCount) {
185
192
  * 执行一组检查,返回聚合结果。
186
193
  * 全部通过 → passed=true;任一失败 → passed=false + summary 列出失败项。
187
194
  */
188
- export async function runVerification(checks) {
195
+ export async function runVerification(checks, cwd = getWorkingDir()) {
189
196
  if (checks.length === 0) {
190
197
  return { passed: true, details: [], summary: '无验证项' };
191
198
  }
@@ -194,16 +201,16 @@ export async function runVerification(checks) {
194
201
  let result;
195
202
  switch (check.type) {
196
203
  case 'shell':
197
- result = await verifyShell(check.command ?? '', check.timeout ?? 30_000);
204
+ result = await verifyShell(check.command ?? '', check.timeout ?? 30_000, cwd);
198
205
  break;
199
206
  case 'file_exists':
200
- result = verifyFileExists(check.file ?? '');
207
+ result = verifyFileExists(check.file ?? '', cwd);
201
208
  break;
202
209
  case 'file_contains':
203
- result = verifyFileContains(check.file ?? '', check.pattern ?? '');
210
+ result = verifyFileContains(check.file ?? '', check.pattern ?? '', cwd);
204
211
  break;
205
212
  case 'test_count':
206
- result = await verifyTestCount(check.minCount ?? 0);
213
+ result = await verifyTestCount(check.minCount ?? 0, cwd);
207
214
  break;
208
215
  default:
209
216
  result = {
@@ -16,7 +16,13 @@
16
16
  * 设计原则:
17
17
  * - 纯工具函数,无全局状态,无副作用(除了 shell 子进程)
18
18
  * - 不依赖任何插件代码,可被 src/ 和 plugins/ 同时导入
19
- * - 工作目录隔离:所有相对路径以 getWorkingDir() 为锚
19
+ * - 工作目录隔离:相对路径以调用方给的 cwd 为锚,缺省 getWorkingDir()
20
+ *
21
+ * ⚠️ cwd 参数不是可有可无的修饰:**验收必须和写入锚在同一个目录**。
22
+ * 会话进入 session worktree 后(EnterWorktree),写文件的工具跟着 worktree 走
23
+ * (loop.ts 按 sessionState.activeWorktree 重定向 cwd),验收若仍锚原 workspace,
24
+ * 就会在空目录里找产物 —— file_exists 恒 false,工作流被判失败并反复重试,
25
+ * 而产物其实好好躺在 worktree 里。调用方拿得到有效 cwd 时务必传进来。
20
26
  * ============================================================
21
27
  */
22
28
 
@@ -128,12 +134,17 @@ interface ShellRunResult {
128
134
  errored: boolean;
129
135
  }
130
136
 
131
- function runShell(command: string, timeout: number): Promise<ShellRunResult> {
137
+ function runShell(
138
+ command: string,
139
+ timeout: number,
140
+ cwd: string,
141
+ ): Promise<ShellRunResult> {
132
142
  return new Promise((resolve) => {
133
143
  const isWin = process.platform === 'win32';
144
+ const opts = { timeout, cwd, env: sanitizeCredentialEnv() };
134
145
  const child = isWin
135
- ? spawn('cmd', ['/c', command], { timeout, env: sanitizeCredentialEnv() })
136
- : spawn('bash', ['-c', command], { timeout, env: sanitizeCredentialEnv() });
146
+ ? spawn('cmd', ['/c', command], opts)
147
+ : spawn('bash', ['-c', command], opts);
137
148
 
138
149
  let stdout = '';
139
150
  let stderr = '';
@@ -155,8 +166,12 @@ function runShell(command: string, timeout: number): Promise<ShellRunResult> {
155
166
  });
156
167
  }
157
168
 
158
- export async function verifyShell(command: string, timeout: number): Promise<CheckResult> {
159
- const r = await runShell(command, timeout);
169
+ export async function verifyShell(
170
+ command: string,
171
+ timeout: number,
172
+ cwd: string = getWorkingDir(),
173
+ ): Promise<CheckResult> {
174
+ const r = await runShell(command, timeout, cwd);
160
175
  if (r.errored) {
161
176
  return {
162
177
  check: { type: 'shell', command },
@@ -171,9 +186,12 @@ export async function verifyShell(command: string, timeout: number): Promise<Che
171
186
  };
172
187
  }
173
188
 
174
- export function verifyFileExists(file: string): CheckResult {
175
- // 相对路径以 workingDir 为锚(绝对路径识别含 UNC)
176
- const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
189
+ export function verifyFileExists(
190
+ file: string,
191
+ cwd: string = getWorkingDir(),
192
+ ): CheckResult {
193
+ // 相对路径以调用方给的 cwd 为锚(绝对路径识别含 UNC)
194
+ const resolved = isAbsolutePath(file) ? file : `${cwd}/${file}`;
177
195
  const exists = existsSync(resolved);
178
196
  return {
179
197
  check: { type: 'file_exists', file },
@@ -182,8 +200,12 @@ export function verifyFileExists(file: string): CheckResult {
182
200
  };
183
201
  }
184
202
 
185
- export function verifyFileContains(file: string, pattern: string): CheckResult {
186
- const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
203
+ export function verifyFileContains(
204
+ file: string,
205
+ pattern: string,
206
+ cwd: string = getWorkingDir(),
207
+ ): CheckResult {
208
+ const resolved = isAbsolutePath(file) ? file : `${cwd}/${file}`;
187
209
  try {
188
210
  const content = readFileSync(resolved, 'utf8');
189
211
  const regex = new RegExp(pattern);
@@ -203,11 +225,14 @@ export function verifyFileContains(file: string, pattern: string): CheckResult {
203
225
  }
204
226
  }
205
227
 
206
- async function verifyTestCount(minCount: number): Promise<CheckResult> {
228
+ async function verifyTestCount(
229
+ minCount: number,
230
+ cwd: string = getWorkingDir(),
231
+ ): Promise<CheckResult> {
207
232
  // 跨运行时:开发用 bun → bun test;npm 全局安装用 Node → npm test --silent
208
233
  // 用 process.versions.bun 探测比 typeof Bun !== 'undefined' 更稳(不依赖全局类型)
209
234
  const cmd = process.versions.bun ? 'bun test' : 'npm test --silent';
210
- const r = await runShell(cmd, 60_000);
235
+ const r = await runShell(cmd, 60_000, cwd);
211
236
  const combined = `${r.stdout}\n${r.stderr}`;
212
237
  if (r.errored) {
213
238
  return {
@@ -232,7 +257,10 @@ async function verifyTestCount(minCount: number): Promise<CheckResult> {
232
257
  * 执行一组检查,返回聚合结果。
233
258
  * 全部通过 → passed=true;任一失败 → passed=false + summary 列出失败项。
234
259
  */
235
- export async function runVerification(checks: Check[]): Promise<VerifyResult> {
260
+ export async function runVerification(
261
+ checks: Check[],
262
+ cwd: string = getWorkingDir(),
263
+ ): Promise<VerifyResult> {
236
264
  if (checks.length === 0) {
237
265
  return { passed: true, details: [], summary: '无验证项' };
238
266
  }
@@ -244,16 +272,20 @@ export async function runVerification(checks: Check[]): Promise<VerifyResult> {
244
272
 
245
273
  switch (check.type) {
246
274
  case 'shell':
247
- result = await verifyShell(check.command ?? '', check.timeout ?? 30_000);
275
+ result = await verifyShell(
276
+ check.command ?? '',
277
+ check.timeout ?? 30_000,
278
+ cwd,
279
+ );
248
280
  break;
249
281
  case 'file_exists':
250
- result = verifyFileExists(check.file ?? '');
282
+ result = verifyFileExists(check.file ?? '', cwd);
251
283
  break;
252
284
  case 'file_contains':
253
- result = verifyFileContains(check.file ?? '', check.pattern ?? '');
285
+ result = verifyFileContains(check.file ?? '', check.pattern ?? '', cwd);
254
286
  break;
255
287
  case 'test_count':
256
- result = await verifyTestCount(check.minCount ?? 0);
288
+ result = await verifyTestCount(check.minCount ?? 0, cwd);
257
289
  break;
258
290
  default:
259
291
  result = {