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.
- package/package.json +5 -1
- package/plugins/workflow-runner/plugin.js +27 -15
- package/plugins/workflow-runner/plugin.ts +34 -20
- package/plugins/workflow-runner/src/agentCommon.js +13 -12
- package/plugins/workflow-runner/src/agentCommon.ts +22 -13
- package/plugins/workflow-runner/src/expressions.js +29 -8
- package/plugins/workflow-runner/src/expressions.ts +28 -10
- package/plugins/workflow-runner/src/index.js +50 -20
- package/plugins/workflow-runner/src/index.ts +75 -31
- package/plugins/workflow-runner/src/multimodal.js +2 -1
- package/plugins/workflow-runner/src/multimodal.ts +2 -1
- package/plugins/workflow-runner/src/recovery.js +9 -2
- package/plugins/workflow-runner/src/recovery.ts +9 -2
- package/plugins/workflow-runner/src/runner.js +31 -5
- package/plugins/workflow-runner/src/runner.ts +33 -5
- package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
- package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
- package/plugins/workflow-runner/src/stepExecutors/tool.js +91 -61
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +111 -71
- package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
- package/plugins/workflow-runner/src/taskIdentity.js +197 -21
- package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
- package/plugins/workflow-runner/src/toolRuntime.js +48 -0
- package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
- package/plugins/workflow-runner/src/types.js +113 -7
- package/plugins/workflow-runner/src/types.ts +137 -14
- package/plugins/workflow-runner/src/workflowState.js +455 -54
- package/plugins/workflow-runner/src/workflowState.ts +508 -63
- package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
- package/plugins/workflow-runner/test/context-files.test.ts +47 -16
- package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
- package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
- package/plugins/workflow-runner/test/expressions.test.ts +42 -0
- package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
- package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
- package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
- package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
- package/plugins/workflow-runner/test/research.load.test.ts +328 -0
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
- package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
- package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
- package/plugins/workflow-runner/test/word.load.test.ts +9 -9
- package/skills/skill-creator/scripts/run_eval.py +3 -2
- package/src/cli/print.js +36 -4
- package/src/cli/print.ts +42 -4
- package/src/cli/streamJson.js +19 -4
- package/src/cli/streamJson.ts +17 -4
- package/src/config/configDefaults.js +25 -0
- package/src/config/configDefaults.ts +33 -0
- package/src/config/configFile.js +2 -21
- package/src/config/configFile.ts +11 -24
- package/src/config/configManagement.js +1 -1
- package/src/config/configManagement.ts +2 -2
- package/src/config/configService.js +24 -0
- package/src/config/configService.ts +23 -0
- package/src/context/compact.js +4 -0
- package/src/context/compact.ts +4 -0
- package/src/context/contextBundle.js +38 -13
- package/src/context/contextBundle.ts +47 -13
- package/src/harness/stallGuard.js +19 -4
- package/src/harness/stallGuard.ts +30 -7
- package/src/infra/verification.js +25 -18
- package/src/infra/verification.ts +50 -18
- package/src/loop.js +95 -11
- package/src/loop.ts +125 -16
- package/src/main.js +8 -1
- package/src/main.tsx +7 -1
- package/src/memory/constants.js +10 -0
- package/src/memory/constants.ts +13 -0
- package/src/memory/episodicMemory.js +274 -2
- package/src/memory/episodicMemory.ts +306 -1
- package/src/memory/fileStore.js +24 -4
- package/src/memory/fileStore.ts +22 -2
- package/src/memory/memoryService.js +79 -12
- package/src/memory/memoryService.ts +108 -9
- package/src/memory/repository.js +415 -85
- package/src/memory/repository.ts +437 -86
- package/src/memory/turnJournal.js +95 -0
- package/src/memory/turnJournal.ts +120 -0
- package/src/memory/types.ts +16 -0
- package/src/plugins/pluginApi.ts +30 -6
- package/src/plugins/pluginRunner.js +23 -8
- package/src/plugins/pluginRunner.ts +32 -13
- package/src/prompts/policyRegistry.js +55 -1
- package/src/prompts/policyRegistry.ts +55 -1
- package/src/prompts/promptAssembly.js +39 -7
- package/src/prompts/promptAssembly.ts +145 -59
- package/src/session/runAgentTurn.js +43 -21
- package/src/session/runAgentTurn.ts +68 -26
- package/src/session/runTurn.js +11 -46
- package/src/session/runTurn.ts +12 -46
- package/src/session/runtimeStore.js +149 -0
- package/src/session/runtimeStore.ts +197 -0
- package/src/session/sessionState.js +5 -0
- package/src/session/sessionState.ts +14 -0
- package/src/tools/advisor/advisor.js +76 -9
- package/src/tools/advisor/advisor.ts +79 -9
- package/src/tools/agent/agent.js +143 -14
- package/src/tools/agent/agent.ts +149 -14
- package/src/tools/agent/agentTaskService.js +60 -0
- package/src/tools/agent/agentTaskService.ts +113 -0
- package/src/tools/agent/agentTypes.js +5 -0
- package/src/tools/agent/agentTypes.ts +5 -0
- package/src/tools/bash/bash.js +80 -15
- package/src/tools/bash/bash.ts +83 -15
- package/src/tools/capabilities.js +5 -0
- package/src/tools/capabilities.ts +11 -1
- package/src/tools/edit/edit.js +7 -4
- package/src/tools/edit/edit.ts +7 -4
- package/src/tools/edit/multi-edit.js +4 -2
- package/src/tools/edit/multi-edit.ts +4 -2
- package/src/tools/glob/glob.js +156 -40
- package/src/tools/glob/glob.ts +185 -46
- package/src/tools/index.js +35 -17
- package/src/tools/index.ts +81 -20
- package/src/tools/lsp/lsp.js +165 -22
- package/src/tools/lsp/lsp.ts +206 -36
- package/src/tools/powershell/powershell.js +134 -27
- package/src/tools/powershell/powershell.ts +171 -30
- package/src/tools/presentresult/presentresult.js +10 -0
- package/src/tools/presentresult/presentresult.ts +10 -0
- package/src/tools/read/read.js +249 -29
- package/src/tools/read/read.ts +396 -59
- package/src/tools/shared/diffSummary.js +44 -0
- package/src/tools/shared/diffSummary.ts +63 -0
- package/src/tools/shared/fileActivity.js +2 -1
- package/src/tools/shared/fileActivity.ts +3 -0
- package/src/tools/shared/permanentDeleteGuard.js +57 -0
- package/src/tools/shared/permanentDeleteGuard.ts +71 -0
- package/src/tools/shared/toolOutputStore.js +97 -0
- package/src/tools/shared/toolOutputStore.ts +110 -0
- package/src/tools/skill/skill.js +86 -0
- package/src/tools/skill/skill.ts +94 -0
- package/src/tools/snip/snip.js +67 -0
- package/src/tools/snip/snip.ts +80 -0
- package/src/tools/tasks/backgroundTaskService.js +79 -28
- package/src/tools/tasks/backgroundTaskService.ts +165 -49
- package/src/tools/tasks/taskManagement.js +163 -0
- package/src/tools/tasks/taskManagement.ts +180 -0
- package/src/tools/tasks/taskOutput.js +64 -14
- package/src/tools/tasks/taskOutput.ts +102 -23
- package/src/tools/tasks/taskStop.js +48 -8
- package/src/tools/tasks/taskStop.ts +69 -9
- package/src/tools/tasks/taskStore.js +46 -0
- package/src/tools/tasks/taskStore.ts +77 -0
- package/src/tools/terminal/terminalCapture.js +167 -0
- package/src/tools/terminal/terminalCapture.ts +179 -0
- package/src/tools/trash/trash.js +233 -0
- package/src/tools/trash/trash.ts +283 -0
- package/src/tools/webbrowser/browserControl.js +5 -1
- package/src/tools/webbrowser/browserControl.ts +7 -1
- package/src/tools/webbrowser/webbrowser.js +8 -4
- package/src/tools/webbrowser/webbrowser.ts +8 -4
- package/src/tools/worktree/sessionWorktree.js +128 -0
- package/src/tools/worktree/sessionWorktree.ts +158 -0
- package/src/tools/write/write.js +6 -2
- package/src/tools/write/write.ts +6 -2
- package/src/types.ts +22 -4
- package/src/ui/hooks/useChat.js +31 -11
- package/src/ui/hooks/useChat.ts +31 -13
- package/src/web/protocol.ts +78 -3
- package/workflows/adaptive-research.yaml +5 -5
- package/workflows/book-review-short.yaml +5 -5
- package/workflows/e2e-write-greet.yaml +2 -2
- package/workflows/illustrate.yaml +12 -12
- package/workflows/plan.yaml +18 -18
- package/workflows/ppt.yaml +201 -201
- package/workflows/ralph-loop.yaml +3 -1
- package/workflows/research.md +449 -0
- package/workflows/research.yaml +873 -0
- package/workflows/webppt.yaml +47 -47
- package/workflows/word.yaml +80 -80
- 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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 <=
|
|
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) &&
|
|
62
|
-
|
|
63
|
-
const content =
|
|
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 <=
|
|
90
|
+
if (clipped.content && clippedTokens <= budgetForItem) {
|
|
67
91
|
included.push({ fragment: clipped, index: item.index });
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 <=
|
|
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) &&
|
|
108
|
-
|
|
109
|
-
const content =
|
|
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 <=
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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 {
|
|
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(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
* -
|
|
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],
|
|
100
|
-
: spawn('bash', ['-c', command],
|
|
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
|
-
//
|
|
134
|
-
const resolved = isAbsolutePath(file) ? 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 : `${
|
|
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
|
-
* -
|
|
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(
|
|
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],
|
|
136
|
-
: spawn('bash', ['-c', command],
|
|
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(
|
|
159
|
-
|
|
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(
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
186
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 = {
|