wave-agent-sdk 0.19.7 → 0.19.9
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/builtin/plugins/sdd/.wave-plugin/plugin.json +8 -0
- package/builtin/plugins/sdd/hooks/hooks.json +14 -0
- package/builtin/plugins/sdd/scripts/session-start.js +24 -0
- package/builtin/plugins/sdd/scripts/spec-count.js +77 -0
- package/builtin/plugins/sdd/skills/specify/SKILL.md +48 -0
- package/builtin/plugins/sdd/skills/specify/templates/spec-template.md +47 -0
- package/dist/agent.d.ts +8 -0
- package/dist/agent.js +30 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/managers/aiManager.d.ts +18 -0
- package/dist/managers/aiManager.js +155 -46
- package/dist/managers/permissionManager.d.ts +7 -0
- package/dist/managers/permissionManager.js +102 -142
- package/dist/managers/pluginManager.d.ts +7 -0
- package/dist/managers/pluginManager.js +31 -0
- package/dist/managers/subagentManager.js +6 -0
- package/dist/prompts/index.d.ts +12 -1
- package/dist/prompts/index.js +133 -45
- package/dist/services/aiService.d.ts +1 -17
- package/dist/services/aiService.js +3 -85
- package/dist/services/configurationService.d.ts +6 -0
- package/dist/services/configurationService.js +31 -0
- package/dist/services/remoteSettingsService.js +2 -0
- package/dist/services/session.d.ts +3 -1
- package/dist/services/session.js +12 -4
- package/dist/services/taskManager.d.ts +1 -0
- package/dist/services/taskManager.js +34 -5
- package/dist/tools/editTool.js +24 -10
- package/dist/tools/enterWorktreeTool.js +2 -1
- package/dist/tools/grepTool.js +8 -2
- package/dist/tools/writeTool.js +36 -0
- package/dist/types/configuration.d.ts +5 -0
- package/dist/types/permissions.d.ts +0 -2
- package/dist/types/processes.d.ts +27 -0
- package/dist/types/workflow.d.ts +1 -1
- package/dist/utils/bashParser.d.ts +25 -0
- package/dist/utils/bashParser.js +103 -0
- package/dist/utils/configPaths.d.ts +4 -0
- package/dist/utils/configPaths.js +6 -0
- package/dist/utils/containerSetup.js +0 -9
- package/dist/utils/fileSearch.js +4 -2
- package/dist/utils/worktreeSession.d.ts +1 -1
- package/dist/utils/worktreeSession.js +1 -1
- package/dist/utils/worktreeUtils.d.ts +7 -1
- package/dist/utils/worktreeUtils.js +10 -4
- package/dist/workflow/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/agent.ts +29 -10
- package/src/index.ts +1 -0
- package/src/managers/aiManager.ts +219 -61
- package/src/managers/permissionManager.ts +116 -168
- package/src/managers/pluginManager.ts +29 -0
- package/src/managers/subagentManager.ts +6 -0
- package/src/prompts/index.ts +144 -37
- package/src/services/aiService.ts +9 -128
- package/src/services/configurationService.ts +37 -0
- package/src/services/remoteSettingsService.ts +1 -0
- package/src/services/session.ts +18 -4
- package/src/services/taskManager.ts +46 -7
- package/src/tools/editTool.ts +29 -11
- package/src/tools/enterWorktreeTool.ts +2 -1
- package/src/tools/grepTool.ts +11 -2
- package/src/tools/writeTool.ts +43 -0
- package/src/types/configuration.ts +5 -0
- package/src/types/permissions.ts +0 -2
- package/src/types/processes.ts +29 -0
- package/src/types/workflow.ts +1 -0
- package/src/utils/bashParser.ts +106 -0
- package/src/utils/configPaths.ts +7 -0
- package/src/utils/containerSetup.ts +0 -11
- package/src/utils/fileSearch.ts +6 -2
- package/src/utils/worktreeSession.ts +1 -1
- package/src/utils/worktreeUtils.ts +14 -4
- package/src/workflow/types.ts +6 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart hook for the sdd built-in plugin.
|
|
3
|
+
// Emits the spec-first workflow guidance as additionalContext (JSON form),
|
|
4
|
+
// resolving the absolute path to the plugin's spec-count validator so the
|
|
5
|
+
// agent can run it from its bash tool (which does not carry WAVE_PLUGIN_ROOT).
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const root =
|
|
9
|
+
process.env.WAVE_PLUGIN_ROOT ||
|
|
10
|
+
path.dirname(new URL("..", import.meta.url).pathname);
|
|
11
|
+
const specCount = `node ${JSON.stringify(path.join(root, "scripts", "spec-count.js"))}`;
|
|
12
|
+
|
|
13
|
+
const guidance = [
|
|
14
|
+
"Spec-First Workflow(规格优先工作流):",
|
|
15
|
+
"- 需求增加或变更时,优先更新 spec:先更新对应规格说明(新增用户故事、验收场景),待用户确认 spec 后再实现代码。spec 是功能设计的权威来源,不是 changelog。",
|
|
16
|
+
"- 边界模糊时也先写 spec 草稿请用户确认,不要直接改代码。",
|
|
17
|
+
"- 使用 /sdd:specify 技能创建或更新规格文件。",
|
|
18
|
+
`- 新增或修改 spec 后运行校验:${specCount}(自动检测 docs/specs/,否则 specs/,否则退出)。`,
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
// JSON form → parsed as hookSpecificOutput.additionalContext by the hook manager.
|
|
22
|
+
console.log(
|
|
23
|
+
JSON.stringify({ hookSpecificOutput: { additionalContext: guidance } }),
|
|
24
|
+
);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generic, self-contained spec validator. Counts user stories and acceptance
|
|
3
|
+
// scenarios under the project's specs directory and warns on missing sections.
|
|
4
|
+
// Detects the specs dir: prefers docs/specs/, else specs/, else exits gracefully.
|
|
5
|
+
// No dependency on any project's VitePress/docs-site modules.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
function detectSpecsDir() {
|
|
10
|
+
for (const dir of ["docs/specs", "specs"]) {
|
|
11
|
+
const resolved = path.resolve(process.cwd(), dir);
|
|
12
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function walk(dir, out = []) {
|
|
18
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
const fullPath = path.join(dir, entry.name);
|
|
20
|
+
if (entry.isDirectory()) walk(fullPath, out);
|
|
21
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) out.push(fullPath);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function countUserStories(content) {
|
|
27
|
+
const m = content.match(/^### 用户故事[::]/gm);
|
|
28
|
+
return m ? m.length : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function countAcceptanceScenarios(content) {
|
|
32
|
+
const m = content.match(/^\d+\.\s+\*\*假设\*\*/gm);
|
|
33
|
+
return m ? m.length : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const specsDir = detectSpecsDir();
|
|
37
|
+
if (!specsDir) {
|
|
38
|
+
console.log("未找到规格目录(docs/specs/ 或 specs/),跳过校验。");
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const files = walk(specsDir).sort();
|
|
43
|
+
const totals = { specs: 0, us: 0, ac: 0 };
|
|
44
|
+
const warnings = [];
|
|
45
|
+
|
|
46
|
+
// index.md is conventionally a directory listing page, not a spec — skip it.
|
|
47
|
+
const specFiles = files.filter(
|
|
48
|
+
(fp) => path.basename(fp).toLowerCase() !== "index.md",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
for (const fp of specFiles) {
|
|
52
|
+
const content = fs.readFileSync(fp, "utf-8");
|
|
53
|
+
const usCount = countUserStories(content);
|
|
54
|
+
const acCount = countAcceptanceScenarios(content);
|
|
55
|
+
const rel = path.relative(process.cwd(), fp);
|
|
56
|
+
totals.specs++;
|
|
57
|
+
totals.us += usCount;
|
|
58
|
+
totals.ac += acCount;
|
|
59
|
+
if (!content.match(/^## 用户场景与测试/m))
|
|
60
|
+
warnings.push(`${rel}: 缺少 "## 用户场景与测试" 章节`);
|
|
61
|
+
if (usCount === 0)
|
|
62
|
+
warnings.push(`${rel}: 未找到用户故事(期望 \`### 用户故事:\`)`);
|
|
63
|
+
if (acCount === 0)
|
|
64
|
+
warnings.push(
|
|
65
|
+
`${rel}: 未找到验收场景(期望 \`N. **假设** … **当** … **则** …\`)`,
|
|
66
|
+
);
|
|
67
|
+
console.log(`${rel} 用户故事: ${usCount} 验收场景: ${acCount}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log("---");
|
|
71
|
+
console.log(
|
|
72
|
+
`规格: ${totals.specs} 用户故事: ${totals.us} 验收场景: ${totals.ac}`,
|
|
73
|
+
);
|
|
74
|
+
if (warnings.length) {
|
|
75
|
+
for (const w of warnings) console.warn(`⚠ ${w}`);
|
|
76
|
+
console.warn(`⚠ ${warnings.length} 条模板警告——见上方。`);
|
|
77
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: specify
|
|
3
|
+
description: 根据自然语言描述创建或更新功能规格说明,生成包含用户故事与验收场景的规格文件。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 用户输入
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
$ARGUMENTS
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
你**必须**在继续之前考虑用户输入(如果不为空)。
|
|
13
|
+
|
|
14
|
+
## 流程
|
|
15
|
+
|
|
16
|
+
用户在 `/sdd:specify` 后输入的文本就是功能描述。不要让用户重复,除非他们提供了空命令。
|
|
17
|
+
|
|
18
|
+
根据功能描述,执行以下步骤:
|
|
19
|
+
|
|
20
|
+
1. **确定规格文件路径**:
|
|
21
|
+
- **确定规格根目录**:优先复用项目中已有的规格目录——若 `docs/specs/` 存在则用之,否则若 `specs/` 存在则用之,否则默认 `specs/`(并在完成报告中说明所选目录,便于用户纠正)。
|
|
22
|
+
- **选择分组**:若规格目录下已有分组子目录,沿用其既有分组约定;否则默认扁平结构(直接放在规格根目录下)。
|
|
23
|
+
- 根据功能描述生成 2-4 个词的 slug(小写、连字符、保留缩写词),与组内已有文件名不冲突
|
|
24
|
+
- 规格文件路径:`<规格根目录>/<分组>/<slug>.md`(无分组时为 `<规格根目录>/<slug>.md`)
|
|
25
|
+
|
|
26
|
+
2. **加载模板** `${WAVE_SKILL_DIR}/templates/spec-template.md`,了解必需章节。
|
|
27
|
+
|
|
28
|
+
3. **编写规格说明**:
|
|
29
|
+
- 解析用户描述,提取关键概念:角色、操作、数据、约束
|
|
30
|
+
- 对于不明确的部分,根据上下文和行业标准做出合理推断
|
|
31
|
+
- 仅在关键决策处标记 `[待澄清:具体问题]`(最多 3 处)
|
|
32
|
+
- 填写 frontmatter(`name` 为功能中文名、`description` 为一句话简述、`order` 为控制组内排序的数字)
|
|
33
|
+
- 填写「用户场景与测试」章节,包含按优先级排序的用户故事(P1、P2、P3...),每个故事以「作为…,我希望…,以便…」描述,附 `**为什么是这个优先级**` 与 `**独立测试**`(不适用的可省略)
|
|
34
|
+
- 为每个用户故事编写可测试的验收场景(**假设** … **当** … **则** …)
|
|
35
|
+
- 写入规格文件,替换所有占位符
|
|
36
|
+
|
|
37
|
+
4. **如果存在 `[待澄清]` 标记**(最多 3 处):
|
|
38
|
+
- 将每个标记作为问题展示,附带建议答案
|
|
39
|
+
- 等待用户回复后更新规格文件
|
|
40
|
+
|
|
41
|
+
5. 报告完成,输出规格文件路径。
|
|
42
|
+
|
|
43
|
+
## 指南
|
|
44
|
+
|
|
45
|
+
- 关注用户**需要什麼**和**为什么**,而非如何实现
|
|
46
|
+
- 不包含实现细节(不涉及技术栈、API、代码结构)
|
|
47
|
+
- 每个验收场景必须可测试、无歧义
|
|
48
|
+
- 删除不适用的可选章节(不要留 "N/A")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "[功能名称]"
|
|
3
|
+
description: "[一句话简短描述]"
|
|
4
|
+
order: [数字,控制组内排序]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 功能规格说明:[功能名称]
|
|
8
|
+
|
|
9
|
+
**创建日期**:[日期]
|
|
10
|
+
|
|
11
|
+
## 用户场景与测试 *(必填)*
|
|
12
|
+
|
|
13
|
+
### 用户故事:[简要标题](优先级:P1)
|
|
14
|
+
|
|
15
|
+
作为[角色],我希望[操作],以便[价值/目的]。
|
|
16
|
+
|
|
17
|
+
**为什么是这个优先级**:[解释其价值以及为何具有此优先级]
|
|
18
|
+
|
|
19
|
+
**独立测试**:[描述如何独立测试——例如,"可以通过 [具体操作] 进行完整测试,并交付 [具体价值]"]
|
|
20
|
+
|
|
21
|
+
**验收场景**:
|
|
22
|
+
|
|
23
|
+
1. **假设** [初始状态],**当** [操作],**则** [预期结果]
|
|
24
|
+
2. **假设** [初始状态],**当** [操作],**则** [预期结果]
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
### 用户故事:[简要标题](优先级:P2)
|
|
29
|
+
|
|
30
|
+
作为[角色],我希望[操作],以便[价值/目的]。
|
|
31
|
+
|
|
32
|
+
**为什么是这个优先级**:[解释其价值以及为何具有此优先级]
|
|
33
|
+
|
|
34
|
+
**独立测试**:[描述如何独立测试]
|
|
35
|
+
|
|
36
|
+
**验收场景**:
|
|
37
|
+
|
|
38
|
+
1. **假设** [初始状态],**当** [操作],**则** [预期结果]
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
[根据需要添加更多用户故事,每个都分配优先级]
|
|
43
|
+
|
|
44
|
+
### 边界情况
|
|
45
|
+
|
|
46
|
+
- **[问题?]** [答案/处理方式]
|
|
47
|
+
- **[问题?]** [答案/处理方式]
|
package/dist/agent.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export declare class Agent {
|
|
|
24
24
|
private reversionManager;
|
|
25
25
|
private messageQueue;
|
|
26
26
|
private dispatchPromise;
|
|
27
|
+
private isAborting;
|
|
27
28
|
private memoryRuleManager;
|
|
28
29
|
private liveConfigManager;
|
|
29
30
|
private taskManager;
|
|
@@ -390,4 +391,11 @@ export declare class Agent {
|
|
|
390
391
|
* Check if there are any running background tasks or active subagents
|
|
391
392
|
*/
|
|
392
393
|
get hasRunningBackgroundWork(): boolean;
|
|
394
|
+
/**
|
|
395
|
+
* Check if there are pending items (messages, bang commands, or background
|
|
396
|
+
* task notifications) in the message queue. Background task completion
|
|
397
|
+
* notifications are enqueued before the main agent's dispatch consumes them,
|
|
398
|
+
* so callers waiting for the agent to fully settle must also wait on this.
|
|
399
|
+
*/
|
|
400
|
+
get hasPendingMessages(): boolean;
|
|
393
401
|
}
|
package/dist/agent.js
CHANGED
|
@@ -52,6 +52,7 @@ export class Agent {
|
|
|
52
52
|
constructor(options) {
|
|
53
53
|
this.bangManager = null;
|
|
54
54
|
this.dispatchPromise = null; // Track current dispatch for teardown
|
|
55
|
+
this.isAborting = false; // Guard: prevents tryDispatch from firing during abortMessage
|
|
55
56
|
this.sessionStartTime = Date.now();
|
|
56
57
|
const { logger, workdir, systemPrompt, stream = true } = options;
|
|
57
58
|
// Set working directory early as we need it for loading configuration
|
|
@@ -278,6 +279,8 @@ export class Agent {
|
|
|
278
279
|
* onLoadingChange(false), and onCommandRunningChange(false).
|
|
279
280
|
*/
|
|
280
281
|
tryDispatch() {
|
|
282
|
+
if (this.isAborting)
|
|
283
|
+
return; // Suppress dispatch during abort to prevent queued notifications from being dispatched as a side-effect
|
|
281
284
|
if (this.messageQueue.state !== "idle")
|
|
282
285
|
return;
|
|
283
286
|
if (!this.messageQueue.hasPending())
|
|
@@ -632,17 +635,25 @@ export class Agent {
|
|
|
632
635
|
}
|
|
633
636
|
/** Unified interrupt method, interrupts both AI messages and command execution */
|
|
634
637
|
abortMessage() {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
this.
|
|
640
|
-
|
|
638
|
+
// Guard: prevent tryDispatch (triggered by abortAIMessage → setIsLoading(false))
|
|
639
|
+
// from dispatching preserved notifications as a new AI turn during the abort.
|
|
640
|
+
this.isAborting = true;
|
|
641
|
+
try {
|
|
642
|
+
if (this.aiManager.isLoading || this.isCommandRunning) {
|
|
643
|
+
// Clear user-facing queue items first to prevent processQueuedMessage
|
|
644
|
+
// from dequeuing when abortAIMessage triggers onLoadingChange(false).
|
|
645
|
+
// Notifications are preserved so background task results aren't lost.
|
|
646
|
+
this.messageQueue.clear();
|
|
647
|
+
this.options.callbacks?.onQueuedMessagesChange?.(this.queuedMessages);
|
|
648
|
+
}
|
|
649
|
+
this.messageQueue.transitionTo("idle"); // Reset state on abort
|
|
650
|
+
this.abortAIMessage(); // This will abort tools including Agent tool (subagents)
|
|
651
|
+
this.abortBashCommand();
|
|
652
|
+
this.abortSlashCommand();
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
this.isAborting = false;
|
|
641
656
|
}
|
|
642
|
-
this.messageQueue.transitionTo("idle"); // Reset state on abort
|
|
643
|
-
this.abortAIMessage(); // This will abort tools including Agent tool (subagents)
|
|
644
|
-
this.abortBashCommand();
|
|
645
|
-
this.abortSlashCommand();
|
|
646
657
|
}
|
|
647
658
|
/** Interrupt bash command execution */
|
|
648
659
|
abortBashCommand() {
|
|
@@ -978,4 +989,13 @@ export class Agent {
|
|
|
978
989
|
const activeSubagents = this.subagentManager.getActiveInstances().length > 0;
|
|
979
990
|
return runningTasks || activeSubagents;
|
|
980
991
|
}
|
|
992
|
+
/**
|
|
993
|
+
* Check if there are pending items (messages, bang commands, or background
|
|
994
|
+
* task notifications) in the message queue. Background task completion
|
|
995
|
+
* notifications are enqueued before the main agent's dispatch consumes them,
|
|
996
|
+
* so callers waiting for the agent to fully settle must also wait on this.
|
|
997
|
+
*/
|
|
998
|
+
get hasPendingMessages() {
|
|
999
|
+
return this.messageQueue.hasPending();
|
|
1000
|
+
}
|
|
981
1001
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export * from "./utils/tokenCalculation.js";
|
|
|
22
22
|
export * from "./utils/gitUtils.js";
|
|
23
23
|
export * from "./utils/nameGenerator.js";
|
|
24
24
|
export * from "./utils/worktreeSession.js";
|
|
25
|
+
export { loadMergedWaveConfig } from "./services/configurationService.js";
|
|
25
26
|
export * from "./types/index.js";
|
|
26
27
|
export * from "./tools/buildTool.js";
|
|
27
28
|
export type { ToolPlugin, ToolResult, ToolContext } from "./tools/types.js";
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ export * from "./utils/tokenCalculation.js";
|
|
|
26
26
|
export * from "./utils/gitUtils.js";
|
|
27
27
|
export * from "./utils/nameGenerator.js";
|
|
28
28
|
export * from "./utils/worktreeSession.js";
|
|
29
|
+
export { loadMergedWaveConfig } from "./services/configurationService.js";
|
|
29
30
|
export * from "./types/index.js";
|
|
30
31
|
// Export tool building utilities
|
|
31
32
|
export * from "./tools/buildTool.js";
|
|
@@ -58,6 +58,7 @@ export declare class AIManager {
|
|
|
58
58
|
getMaxInputTokens(): number;
|
|
59
59
|
getLanguage(): string | undefined;
|
|
60
60
|
getAutoMemoryEnabled(): boolean;
|
|
61
|
+
getWorktreeBaseRef(): "fresh" | "head";
|
|
61
62
|
getWorkdir(): string;
|
|
62
63
|
getOriginalWorkdir(): string;
|
|
63
64
|
/**
|
|
@@ -102,6 +103,23 @@ export declare class AIManager {
|
|
|
102
103
|
customInstructions?: string;
|
|
103
104
|
abortSignal?: AbortSignal;
|
|
104
105
|
}): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Build the system prompt used by the main agent loop. Extracted so the
|
|
108
|
+
* compaction fork can mirror it exactly — the forked request prefix must
|
|
109
|
+
* match the main conversation's for the prompt cache to be reused.
|
|
110
|
+
*/
|
|
111
|
+
private buildMainSystemPrompt;
|
|
112
|
+
private resolveFilteredTools;
|
|
113
|
+
/**
|
|
114
|
+
* Fork-path compaction: run a bounded agent loop over a copy of the
|
|
115
|
+
* conversation using the same system prompt, tools, model, and generation
|
|
116
|
+
* params as the main loop, so the forked request prefix matches exactly
|
|
117
|
+
* and the prompt cache is reused. Tool calls are denied locally (the model
|
|
118
|
+
* is told to summarize, not act) and their rejections are fed back for
|
|
119
|
+
* another turn. Returns undefined content when the model never produces
|
|
120
|
+
* text; the caller treats that as a compaction failure.
|
|
121
|
+
*/
|
|
122
|
+
private runCompactFork;
|
|
105
123
|
/**
|
|
106
124
|
* Build post-compact context restoration content.
|
|
107
125
|
* Restores file reads, working directory, plan mode, skills, and background tasks.
|
|
@@ -6,8 +6,8 @@ import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js"
|
|
|
6
6
|
import { estimateTokens } from "../utils/tokenEstimate.js";
|
|
7
7
|
import { getTaskReminderTurnCounts, maybeInjectTaskReminder, TASK_REMINDER_CONFIG, } from "../utils/taskReminder.js";
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
-
import { buildSystemPrompt } from "../prompts/index.js";
|
|
10
|
-
import { buildPlanModeReminder, buildPlanModeReEntryReminder, buildExitedPlanModeReminder, } from "../prompts/planModeReminders.js";
|
|
9
|
+
import { buildSystemPrompt, formatCompactSummary, getCompactPrompt, } from "../prompts/index.js";
|
|
10
|
+
import { buildPlanModeReminder, buildPlanModeReEntryReminder, buildExitedPlanModeReminder, wrapInSystemReminder, } from "../prompts/planModeReminders.js";
|
|
11
11
|
import { recoverTruncatedJson } from "../utils/stringUtils.js";
|
|
12
12
|
import { logger } from "../utils/globalLogger.js";
|
|
13
13
|
import { startInteractionSpan, endInteractionSpan, startLLMRequestSpan, endLLMRequestSpan, resetTracingState, } from "../telemetry/sessionTracing.js";
|
|
@@ -128,6 +128,9 @@ export class AIManager {
|
|
|
128
128
|
getAutoMemoryEnabled() {
|
|
129
129
|
return this.configurationService.resolveAutoMemoryEnabled();
|
|
130
130
|
}
|
|
131
|
+
getWorktreeBaseRef() {
|
|
132
|
+
return this.configurationService.resolveWorktreeBaseRef();
|
|
133
|
+
}
|
|
131
134
|
getWorkdir() {
|
|
132
135
|
return this.container.get("Workdir") ?? process.cwd();
|
|
133
136
|
}
|
|
@@ -345,30 +348,35 @@ export class AIManager {
|
|
|
345
348
|
await this.messageManager.saveSession();
|
|
346
349
|
this.setIsCompacting(true);
|
|
347
350
|
try {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
gatewayConfig: this.getGatewayConfig(),
|
|
352
|
-
modelConfig: this.getModelConfig(),
|
|
353
|
-
messages: recentChatMessages,
|
|
354
|
-
abortSignal: options.abortSignal,
|
|
355
|
-
model: this.getModelConfig().fastModel,
|
|
356
|
-
customInstructions: mergedInstructions,
|
|
351
|
+
const modelConfig = this.getModelConfig();
|
|
352
|
+
const recentChatMessages = convertMessagesForAPI(messagesToCompact, {
|
|
353
|
+
supportsVision: supportsVision(modelConfig.capabilities),
|
|
357
354
|
});
|
|
355
|
+
const compactPrompt = getCompactPrompt(mergedInstructions);
|
|
356
|
+
// 4. Fork path: fork the conversation with the same system prompt,
|
|
357
|
+
// tools, model, and generation params as the main loop so the forked
|
|
358
|
+
// request prefix matches exactly and the prompt cache is reused.
|
|
359
|
+
const forkResult = await this.runCompactFork(recentChatMessages, compactPrompt, options.abortSignal);
|
|
360
|
+
const summaryContent = forkResult.content;
|
|
361
|
+
const compactTokens = forkResult.usage;
|
|
362
|
+
if (!summaryContent) {
|
|
363
|
+
throw new Error("Compaction failed: the model produced no summary output");
|
|
364
|
+
}
|
|
365
|
+
const compactModel = modelConfig.model;
|
|
358
366
|
// 5. Handle usage tracking
|
|
359
367
|
let compactUsage;
|
|
360
|
-
if (
|
|
368
|
+
if (compactTokens) {
|
|
361
369
|
compactUsage = {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
total_tokens: compactResult.usage.total_tokens,
|
|
365
|
-
model: this.getModelConfig().fastModel,
|
|
370
|
+
...compactTokens,
|
|
371
|
+
model: compactModel,
|
|
366
372
|
operation_type: "compact",
|
|
367
373
|
};
|
|
368
374
|
}
|
|
369
|
-
// 6.
|
|
370
|
-
const
|
|
371
|
-
// 7.
|
|
375
|
+
// 6. Strip the <analysis> scratchpad and extract the <summary> body
|
|
376
|
+
const formattedSummary = formatCompactSummary(summaryContent);
|
|
377
|
+
// 7. Build post-compact context restoration
|
|
378
|
+
const enhancedSummary = await this.buildPostCompactContext(formattedSummary);
|
|
379
|
+
// 8. Execute message reconstruction
|
|
372
380
|
await this.messageManager.compactMessagesAndUpdateSession(enhancedSummary, compactUsage);
|
|
373
381
|
// Re-add plan mode reminder as persistent meta message after compaction
|
|
374
382
|
const postCompactMode = this.permissionManager?.getCurrentEffectiveMode(this.getModelConfig().permissionMode);
|
|
@@ -382,20 +390,20 @@ export class AIManager {
|
|
|
382
390
|
});
|
|
383
391
|
}
|
|
384
392
|
}
|
|
385
|
-
//
|
|
393
|
+
// 9. Track usage
|
|
386
394
|
if (compactUsage && this.callbacks?.onUsageAdded) {
|
|
387
395
|
this.callbacks.onUsageAdded(compactUsage);
|
|
388
396
|
}
|
|
389
397
|
this.consecutiveCompactionFailures = 0;
|
|
390
398
|
// Reset incremental tracing state after compaction
|
|
391
399
|
resetTracingState();
|
|
392
|
-
//
|
|
400
|
+
// 10. Log OTEL event
|
|
393
401
|
logOTelEvent("compaction", {
|
|
394
402
|
beforeTokens: String(messagesToCompact.length),
|
|
395
403
|
afterTokens: "1",
|
|
396
|
-
model:
|
|
404
|
+
model: compactModel,
|
|
397
405
|
}).catch(() => { });
|
|
398
|
-
//
|
|
406
|
+
// 11. Run SessionStart hooks (existing behavior)
|
|
399
407
|
if (this.hookManager) {
|
|
400
408
|
try {
|
|
401
409
|
const newSessionId = this.messageManager.getSessionId();
|
|
@@ -417,10 +425,10 @@ export class AIManager {
|
|
|
417
425
|
logger?.warn(`SessionStart hooks on compact failed: ${error.message}`);
|
|
418
426
|
}
|
|
419
427
|
}
|
|
420
|
-
//
|
|
428
|
+
// 12. Run PostCompact hooks
|
|
421
429
|
if (this.hookManager) {
|
|
422
430
|
try {
|
|
423
|
-
await this.hookManager.executePostCompactHooks(this.messageManager.getSessionId(), this.messageManager.getTranscriptPath(),
|
|
431
|
+
await this.hookManager.executePostCompactHooks(this.messageManager.getSessionId(), this.messageManager.getTranscriptPath(), formattedSummary);
|
|
424
432
|
}
|
|
425
433
|
catch (error) {
|
|
426
434
|
logger?.warn(`PostCompact hooks failed: ${error.message}`);
|
|
@@ -437,6 +445,115 @@ export class AIManager {
|
|
|
437
445
|
this.setIsCompacting(false);
|
|
438
446
|
}
|
|
439
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Build the system prompt used by the main agent loop. Extracted so the
|
|
450
|
+
* compaction fork can mirror it exactly — the forked request prefix must
|
|
451
|
+
* match the main conversation's for the prompt cache to be reused.
|
|
452
|
+
*/
|
|
453
|
+
async buildMainSystemPrompt(filteredToolPlugins) {
|
|
454
|
+
let autoMemoryOptions;
|
|
455
|
+
if (this.getAutoMemoryEnabled()) {
|
|
456
|
+
const directory = this.memoryService.getAutoMemoryDirectory(this.getWorkdir());
|
|
457
|
+
const content = await this.memoryService.getAutoMemoryContent(this.getWorkdir());
|
|
458
|
+
autoMemoryOptions = { directory, content };
|
|
459
|
+
}
|
|
460
|
+
return buildSystemPrompt(this.systemPrompt, filteredToolPlugins, {
|
|
461
|
+
workdir: this.getWorkdir(),
|
|
462
|
+
originalWorkdir: this.getOriginalWorkdir(),
|
|
463
|
+
language: this.getLanguage(),
|
|
464
|
+
isSubagent: !!this.subagentType,
|
|
465
|
+
worktreeSession: this.getWorktreeSession(),
|
|
466
|
+
autoMemory: autoMemoryOptions,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
resolveFilteredTools() {
|
|
470
|
+
const toolsConfig = this.getFilteredToolsConfig();
|
|
471
|
+
const toolNames = new Set(toolsConfig.map((t) => t.function.name));
|
|
472
|
+
const filteredToolPlugins = this.toolManager
|
|
473
|
+
.getTools()
|
|
474
|
+
.filter((t) => toolNames.has(t.name));
|
|
475
|
+
return { toolsConfig, toolNames, filteredToolPlugins };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Fork-path compaction: run a bounded agent loop over a copy of the
|
|
479
|
+
* conversation using the same system prompt, tools, model, and generation
|
|
480
|
+
* params as the main loop, so the forked request prefix matches exactly
|
|
481
|
+
* and the prompt cache is reused. Tool calls are denied locally (the model
|
|
482
|
+
* is told to summarize, not act) and their rejections are fed back for
|
|
483
|
+
* another turn. Returns undefined content when the model never produces
|
|
484
|
+
* text; the caller treats that as a compaction failure.
|
|
485
|
+
*/
|
|
486
|
+
async runCompactFork(historyMessages, compactPrompt, abortSignal) {
|
|
487
|
+
const MAX_FORK_TURNS = 3;
|
|
488
|
+
const modelConfig = this.getModelConfig();
|
|
489
|
+
const gatewayConfig = this.getGatewayConfig();
|
|
490
|
+
const sessionId = this.messageManager.getSessionId();
|
|
491
|
+
const workdir = this.getWorkdir();
|
|
492
|
+
const forkMessages = [...historyMessages];
|
|
493
|
+
// Mirror the main loop's memory injection so the request prefix matches.
|
|
494
|
+
const { prependContent } = await this.messageManager.getMemoryForInjection();
|
|
495
|
+
if (prependContent.trim()) {
|
|
496
|
+
forkMessages.unshift({
|
|
497
|
+
role: "user",
|
|
498
|
+
content: wrapInSystemReminder(prependContent),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
forkMessages.push({ role: "user", content: compactPrompt });
|
|
502
|
+
const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
|
|
503
|
+
const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
504
|
+
let totalUsage;
|
|
505
|
+
let content;
|
|
506
|
+
for (let turn = 0; turn < MAX_FORK_TURNS; turn++) {
|
|
507
|
+
const result = await aiService.callAgent({
|
|
508
|
+
gatewayConfig,
|
|
509
|
+
modelConfig,
|
|
510
|
+
messages: forkMessages,
|
|
511
|
+
sessionId,
|
|
512
|
+
abortSignal,
|
|
513
|
+
workdir,
|
|
514
|
+
tools: toolsConfig,
|
|
515
|
+
systemPrompt,
|
|
516
|
+
toolChoice: this.toolChoiceOverride,
|
|
517
|
+
// Stream so a slow reasoning model emits first bytes before the
|
|
518
|
+
// gateway's idle timeout fires (non-streaming waits for the full
|
|
519
|
+
// summary, which exceeds the timeout on large contexts).
|
|
520
|
+
stream: true,
|
|
521
|
+
});
|
|
522
|
+
if (result.usage) {
|
|
523
|
+
totalUsage = {
|
|
524
|
+
prompt_tokens: (totalUsage?.prompt_tokens ?? 0) + result.usage.prompt_tokens,
|
|
525
|
+
completion_tokens: (totalUsage?.completion_tokens ?? 0) +
|
|
526
|
+
result.usage.completion_tokens,
|
|
527
|
+
total_tokens: (totalUsage?.total_tokens ?? 0) + result.usage.total_tokens,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
if (result.content?.trim()) {
|
|
531
|
+
content = result.content;
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
if (result.tool_calls && result.tool_calls.length > 0) {
|
|
535
|
+
// Deny all tool calls locally and feed the rejections back so the
|
|
536
|
+
// model gets another turn to produce the summary text.
|
|
537
|
+
forkMessages.push({
|
|
538
|
+
role: "assistant",
|
|
539
|
+
content: result.content ?? null,
|
|
540
|
+
tool_calls: result.tool_calls,
|
|
541
|
+
});
|
|
542
|
+
for (const toolCall of result.tool_calls) {
|
|
543
|
+
forkMessages.push({
|
|
544
|
+
role: "tool",
|
|
545
|
+
tool_call_id: toolCall.id,
|
|
546
|
+
content: "Tool use is not allowed during compaction",
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
// Neither text nor tool calls: retrying the identical request is
|
|
552
|
+
// pointless, bail out and let the caller fail the compaction.
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
return { content, usage: totalUsage };
|
|
556
|
+
}
|
|
440
557
|
/**
|
|
441
558
|
* Build post-compact context restoration content.
|
|
442
559
|
* Restores file reads, working directory, plan mode, skills, and background tasks.
|
|
@@ -570,7 +687,7 @@ export class AIManager {
|
|
|
570
687
|
// has superseded us. setIsLoading(true) here also closes the "idle but
|
|
571
688
|
// previous turn not finished" race window by marking us busy immediately.
|
|
572
689
|
this.turnGeneration++;
|
|
573
|
-
|
|
690
|
+
let myGeneration = this.turnGeneration;
|
|
574
691
|
this.setIsLoading(true);
|
|
575
692
|
outer: while (true) {
|
|
576
693
|
let shouldRestart = false;
|
|
@@ -656,19 +773,10 @@ export class AIManager {
|
|
|
656
773
|
// Track if assistant message has been created
|
|
657
774
|
let assistantMessageCreated = false;
|
|
658
775
|
logger?.debug("modelConfig in sendAIMessage", this.getModelConfig());
|
|
659
|
-
const toolsConfig = this.
|
|
660
|
-
const toolNames = new Set(toolsConfig.map((t) => t.function.name));
|
|
661
|
-
const filteredToolPlugins = this.toolManager
|
|
662
|
-
.getTools()
|
|
663
|
-
.filter((t) => toolNames.has(t.name));
|
|
664
|
-
let autoMemoryOptions;
|
|
665
|
-
if (this.getAutoMemoryEnabled()) {
|
|
666
|
-
const directory = this.memoryService.getAutoMemoryDirectory(this.getWorkdir());
|
|
667
|
-
const content = await this.memoryService.getAutoMemoryContent(this.getWorkdir());
|
|
668
|
-
autoMemoryOptions = { directory, content };
|
|
669
|
-
}
|
|
776
|
+
const { toolsConfig, toolNames, filteredToolPlugins } = this.resolveFilteredTools();
|
|
670
777
|
// Get memory for message-array injection (not system prompt)
|
|
671
778
|
const { prependContent } = await this.messageManager.getMemoryForInjection();
|
|
779
|
+
const mainSystemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
672
780
|
// Call AI service with streaming callbacks if enabled
|
|
673
781
|
const callAgentOptions = {
|
|
674
782
|
gatewayConfig: this.getGatewayConfig(),
|
|
@@ -679,14 +787,7 @@ export class AIManager {
|
|
|
679
787
|
workdir: this.getWorkdir(), // Pass working directory
|
|
680
788
|
tools: toolsConfig, // Pass filtered tool configuration
|
|
681
789
|
model: model, // Use passed model
|
|
682
|
-
systemPrompt:
|
|
683
|
-
workdir: this.getWorkdir(),
|
|
684
|
-
originalWorkdir: this.getOriginalWorkdir(),
|
|
685
|
-
language: this.getLanguage(),
|
|
686
|
-
isSubagent: !!this.subagentType,
|
|
687
|
-
worktreeSession: this.getWorktreeSession(),
|
|
688
|
-
autoMemory: autoMemoryOptions,
|
|
689
|
-
}), // Pass custom system prompt
|
|
790
|
+
systemPrompt: mainSystemPrompt, // Pass custom system prompt
|
|
690
791
|
maxTokens: maxTokens, // Pass max tokens override
|
|
691
792
|
toolChoice: this.toolChoiceOverride, // Pass tool_choice override
|
|
692
793
|
};
|
|
@@ -694,7 +795,7 @@ export class AIManager {
|
|
|
694
795
|
if (prependContent.trim()) {
|
|
695
796
|
callAgentOptions.messages.unshift({
|
|
696
797
|
role: "user",
|
|
697
|
-
content:
|
|
798
|
+
content: wrapInSystemReminder(prependContent),
|
|
698
799
|
});
|
|
699
800
|
}
|
|
700
801
|
// Task reminder: persist as meta message (conditional rules already persisted above)
|
|
@@ -1084,6 +1185,14 @@ export class AIManager {
|
|
|
1084
1185
|
});
|
|
1085
1186
|
}
|
|
1086
1187
|
}
|
|
1188
|
+
// Re-assert loading state before restarting: if this turn was
|
|
1189
|
+
// aborted, abortAIMessage already reset loading to false, and the
|
|
1190
|
+
// restart below continues the conversation — the UI must show
|
|
1191
|
+
// streaming again (cursor, stop button, ESC handling).
|
|
1192
|
+
this.setIsLoading(true);
|
|
1193
|
+
// Adopt the current (possibly abort-bumped) generation so this
|
|
1194
|
+
// continued turn's end-of-turn cleanup is not skipped as superseded.
|
|
1195
|
+
myGeneration = this.turnGeneration;
|
|
1087
1196
|
// Restart outer loop to process the notifications
|
|
1088
1197
|
shouldRestart = true;
|
|
1089
1198
|
turnOffset = 0;
|