Skip to content

Commit 1dfbe84

Browse files
committed
✨ 实现 /compact 命令和自动 compact 机制
- 新增模型上下文窗口映射表(model_context.ts),支持主流模型家族前缀匹配 - 新增 compact 摘要 prompt(compact_prompt.ts),参考 Claude Code 的 analysis + summary 结构 - AgentModelConfig 新增 contextWindow 字段,UI 表单支持配置 - handleConversationChat 新增 compact 分支,支持 /compact [指令] 手动压缩 - callLLMWithToolLoop 中当 inputTokens/contextWindow >= 80% 时自动触发 compact - ChatStreamEvent 新增 compact_done 事件类型 - 新增 27 个单元测试覆盖映射表、摘要提取、手动/自动 compact 逻辑
1 parent 41bbe50 commit 1dfbe84

25 files changed

Lines changed: 833 additions & 50 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from "vitest";
2+
import { extractSummary, buildCompactUserPrompt, COMPACT_SYSTEM_PROMPT } from "./compact_prompt";
3+
4+
describe("extractSummary", () => {
5+
it("extracts content from <summary> tags", () => {
6+
const response = `<analysis>Some analysis here</analysis>
7+
8+
<summary>
9+
1. **Primary Request**: Build a feature
10+
2. **Key Decisions**: Used React
11+
</summary>`;
12+
const result = extractSummary(response);
13+
expect(result).toBe("1. **Primary Request**: Build a feature\n2. **Key Decisions**: Used React");
14+
});
15+
16+
it("returns full content when no <summary> tag found", () => {
17+
const response = "Just a plain summary without tags";
18+
expect(extractSummary(response)).toBe("Just a plain summary without tags");
19+
});
20+
21+
it("handles empty <summary> tags", () => {
22+
expect(extractSummary("<summary></summary>")).toBe("");
23+
});
24+
25+
it("handles multiline content inside <summary>", () => {
26+
const response = `<summary>
27+
Line 1
28+
Line 2
29+
Line 3
30+
</summary>`;
31+
expect(extractSummary(response)).toBe("Line 1\nLine 2\nLine 3");
32+
});
33+
});
34+
35+
describe("buildCompactUserPrompt", () => {
36+
it("builds prompt without custom instruction", () => {
37+
const prompt = buildCompactUserPrompt();
38+
expect(prompt).toContain("Create a detailed summary");
39+
expect(prompt).toContain("<summary>");
40+
expect(prompt).not.toContain("Additional summarization instructions");
41+
});
42+
43+
it("appends custom instruction when provided", () => {
44+
const prompt = buildCompactUserPrompt("只保留代码相关内容");
45+
expect(prompt).toContain("Additional summarization instructions from the user: 只保留代码相关内容");
46+
});
47+
});
48+
49+
describe("COMPACT_SYSTEM_PROMPT", () => {
50+
it("is defined and non-empty", () => {
51+
expect(COMPACT_SYSTEM_PROMPT).toBeTruthy();
52+
expect(COMPACT_SYSTEM_PROMPT.length).toBeGreaterThan(0);
53+
});
54+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export const COMPACT_SYSTEM_PROMPT = `You are a conversation summarizer. Your task is to create a detailed summary of the conversation, preserving all critical information needed to continue effectively.`;
2+
3+
export function buildCompactUserPrompt(customInstruction?: string): string {
4+
let prompt = `Create a detailed summary of the conversation so far.
5+
6+
Before providing your final summary, wrap your analysis in <analysis> tags to organize your thoughts:
7+
8+
1. Chronologically analyze each message. For each section identify:
9+
- The user's explicit requests and intents
10+
- Key decisions and outcomes
11+
- Specific details: file names, code snippets, function signatures
12+
- Errors encountered and how they were fixed
13+
- Important user feedback or corrections
14+
15+
2. Double-check for completeness.
16+
17+
Your summary should include the following sections in <summary> tags:
18+
19+
1. **Primary Request and Intent**: The user's core requests and success criteria
20+
2. **Key Decisions**: Important decisions made and their rationale
21+
3. **Current State**: What has been completed, files modified, artifacts produced
22+
4. **Errors and Fixes**: Problems encountered and their solutions
23+
5. **Pending Tasks**: Outstanding work items
24+
6. **Current Work**: What was being worked on immediately before this summary
25+
7. **Next Steps**: Specific actions needed to continue
26+
27+
Be concise but complete — preserve all information that would prevent duplicate work or repeated mistakes.`;
28+
29+
if (customInstruction) {
30+
prompt += `\n\nAdditional summarization instructions from the user: ${customInstruction}`;
31+
}
32+
33+
return prompt;
34+
}
35+
36+
/** 从 LLM 响应中提取 <summary> 标签内容 */
37+
export function extractSummary(content: string): string {
38+
const match = content.match(/<summary>([\s\S]*?)<\/summary>/);
39+
return match ? match[1].trim() : content.trim();
40+
}

src/app/service/agent/mcp_client.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,21 @@ describe("MCPClient", () => {
409409
});
410410
});
411411

412+
describe("请求超时", () => {
413+
it("sendRequest 应传递 AbortSignal.timeout(60s)", async () => {
414+
const client = new MCPClient(createConfig());
415+
416+
mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} }));
417+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
418+
await client.initialize();
419+
420+
// 检查 initialize 的 fetch 调用带了 signal
421+
expect(mockFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
422+
// 检查 notification 的 fetch 调用也带了 signal
423+
expect(mockFetch.mock.calls[1][1].signal).toBeInstanceOf(AbortSignal);
424+
});
425+
});
426+
412427
describe("sendNotification 失败", () => {
413428
it("initialize 过程中通知失败应抛出", async () => {
414429
const client = new MCPClient(createConfig());

src/app/service/agent/mcp_client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export class MCPClient {
196196
method: "POST",
197197
headers: this.buildHeaders(),
198198
body: JSON.stringify(body),
199+
signal: AbortSignal.timeout(60_000),
199200
});
200201

201202
// 存储 session ID
@@ -229,6 +230,7 @@ export class MCPClient {
229230
method: "POST",
230231
headers: this.buildHeaders(),
231232
body: JSON.stringify(body),
233+
signal: AbortSignal.timeout(60_000),
232234
});
233235

234236
// 存储 session ID
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getContextWindow, inferContextWindow, DEFAULT_CONTEXT_WINDOW } from "./model_context";
3+
4+
describe("getContextWindow", () => {
5+
it("returns user-configured contextWindow when provided", () => {
6+
expect(getContextWindow({ model: "gpt-4o", contextWindow: 50_000 })).toBe(50_000);
7+
});
8+
9+
it("matches GPT-4o prefix", () => {
10+
expect(getContextWindow({ model: "gpt-4o" })).toBe(128_000);
11+
expect(getContextWindow({ model: "gpt-4o-mini" })).toBe(128_000);
12+
});
13+
14+
it("matches GPT-4.1 prefix before GPT-4", () => {
15+
expect(getContextWindow({ model: "gpt-4.1-nano" })).toBe(1_047_576);
16+
});
17+
18+
it("matches GPT-4 Turbo before GPT-4 base", () => {
19+
expect(getContextWindow({ model: "gpt-4-turbo-preview" })).toBe(128_000);
20+
});
21+
22+
it("matches GPT-4 base", () => {
23+
expect(getContextWindow({ model: "gpt-4-0613" })).toBe(8_192);
24+
});
25+
26+
it("matches Claude models", () => {
27+
expect(getContextWindow({ model: "claude-sonnet-4-20250514" })).toBe(200_000);
28+
expect(getContextWindow({ model: "claude-3-haiku" })).toBe(200_000);
29+
});
30+
31+
it("matches Gemini models", () => {
32+
expect(getContextWindow({ model: "gemini-2.0-flash" })).toBe(1_048_576);
33+
});
34+
35+
it("matches DeepSeek models", () => {
36+
expect(getContextWindow({ model: "deepseek-chat" })).toBe(64_000);
37+
});
38+
39+
it("matches Qwen models", () => {
40+
expect(getContextWindow({ model: "qwen-max" })).toBe(131_072);
41+
});
42+
43+
it("matches Llama-4 before Llama base", () => {
44+
expect(getContextWindow({ model: "llama-4-maverick" })).toBe(1_048_576);
45+
expect(getContextWindow({ model: "llama-3.1-70b" })).toBe(131_072);
46+
});
47+
48+
it("is case-insensitive", () => {
49+
expect(getContextWindow({ model: "GPT-4O" })).toBe(128_000);
50+
expect(getContextWindow({ model: "Claude-Sonnet-4" })).toBe(200_000);
51+
});
52+
53+
it("returns default for unknown models", () => {
54+
expect(getContextWindow({ model: "my-custom-model" })).toBe(DEFAULT_CONTEXT_WINDOW);
55+
});
56+
});
57+
58+
describe("inferContextWindow", () => {
59+
it("returns prefix-matched value", () => {
60+
expect(inferContextWindow("gpt-4o")).toBe(128_000);
61+
});
62+
63+
it("returns default for unknown models", () => {
64+
expect(inferContextWindow("unknown-model")).toBe(DEFAULT_CONTEXT_WINDOW);
65+
});
66+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// 模型上下文窗口大小映射表
2+
// [前缀, 上下文窗口大小],按前缀长度降序排列以确保最精确匹配优先
3+
const MODEL_CONTEXT_PREFIXES: Array<[string, number]> = [
4+
// OpenAI — GPT-5 系列
5+
["gpt-5", 400_000],
6+
// OpenAI — GPT-4.1 系列
7+
["gpt-4.1", 1_047_576],
8+
// OpenAI — GPT-4o 系列
9+
["gpt-4o", 128_000],
10+
// OpenAI — GPT-4 Turbo
11+
["gpt-4-turbo", 128_000],
12+
// OpenAI — GPT-4 基础
13+
["gpt-4", 8_192],
14+
// OpenAI — GPT-3.5
15+
["gpt-3.5", 16_385],
16+
// OpenAI — o 系列推理模型
17+
["o1", 200_000],
18+
["o3", 200_000],
19+
["o4", 200_000],
20+
// Anthropic — Claude 系列(所有版本都是 200K)
21+
["claude", 200_000],
22+
// Google — Gemini 系列
23+
["gemini", 1_048_576],
24+
// Google — Gemma(本地部署)
25+
["gemma", 128_000],
26+
// DeepSeek
27+
["deepseek", 64_000],
28+
// Alibaba — Qwen 系列
29+
["qwen", 131_072],
30+
["qwq", 32_000],
31+
// Meta — Llama 系列
32+
["llama-4", 1_048_576],
33+
["llama", 131_072],
34+
// Mistral
35+
["mistral-nemo", 128_000],
36+
["mistral", 32_000],
37+
// Microsoft — Phi
38+
["phi", 16_000],
39+
// GLM
40+
["glm", 200_000],
41+
];
42+
43+
export const DEFAULT_CONTEXT_WINDOW = 128_000;
44+
45+
/** 获取模型的上下文窗口大小,优先使用用户配置,否则按前缀匹配 */
46+
export function getContextWindow(config: { model: string; contextWindow?: number }): number {
47+
if (config.contextWindow) return config.contextWindow;
48+
const modelLower = config.model.toLowerCase();
49+
for (const [prefix, size] of MODEL_CONTEXT_PREFIXES) {
50+
if (modelLower.startsWith(prefix)) return size;
51+
}
52+
return DEFAULT_CONTEXT_WINDOW;
53+
}
54+
55+
/** 根据模型名称推断上下文窗口大小(不考虑用户配置) */
56+
export function inferContextWindow(model: string): number {
57+
const modelLower = model.toLowerCase();
58+
for (const [prefix, size] of MODEL_CONTEXT_PREFIXES) {
59+
if (modelLower.startsWith(prefix)) return size;
60+
}
61+
return DEFAULT_CONTEXT_WINDOW;
62+
}

src/app/service/agent/skill_script_executor.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ describe("SkillScriptExecutor 超时处理", () => {
483483
vi.useRealTimers();
484484
});
485485

486-
it("执行超过 30s 时应抛出带 errorCode=tool_timeout 的错误", async () => {
486+
it("执行超过默认超时(300s)时应抛出带 errorCode=tool_timeout 的错误", async () => {
487487
vi.useFakeTimers();
488488

489489
// sender.sendMessage 永不 resolve,模拟挂死的 SkillScript
@@ -497,8 +497,8 @@ describe("SkillScriptExecutor 超时处理", () => {
497497
// 先附加 catch 再推进时间,防止 rejection 在处理前被标记为 unhandled
498498
const errPromise = executor.execute({}).catch((e) => e);
499499

500-
// 推进 30s 触发超时
501-
await vi.advanceTimersByTimeAsync(30_000);
500+
// 推进 300s 触发超时
501+
await vi.advanceTimersByTimeAsync(300_000);
502502

503503
const err = await errPromise;
504504
expect(err).toBeInstanceOf(Error);
@@ -522,14 +522,14 @@ describe("SkillScriptExecutor 超时处理", () => {
522522
const executor = new SkillScriptExecutor(record, sender);
523523

524524
const execPromise = executor.execute({}).catch(() => {});
525-
await vi.advanceTimersByTimeAsync(30_000);
525+
await vi.advanceTimersByTimeAsync(300_000);
526526
await execPromise;
527527

528528
expect(capturedUuid).toMatch(/^skillscript-/);
529529
expect(getSkillScriptNameByUuid(capturedUuid)).toBe("");
530530
});
531531

532-
it("自定义 timeout 应覆盖默认 30s", async () => {
532+
it("自定义 timeout 应覆盖默认 300s", async () => {
533533
vi.useFakeTimers();
534534

535535
const sender = {
@@ -556,7 +556,7 @@ describe("SkillScriptExecutor 超时处理", () => {
556556
expect((err as any).errorCode).toBe("tool_timeout");
557557
});
558558

559-
it("30s 内完成的执行不应超时", async () => {
559+
it("默认超时内完成的执行不应超时", async () => {
560560
vi.useFakeTimers();
561561

562562
const sender = {

src/app/service/agent/skill_script_executor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { uuidv4 } from "@App/pkg/utils/uuid";
99
export const SKILL_SCRIPT_UUID_PREFIX = "skillscript-";
1010

1111
// Skill Script 默认超时(ms)
12-
const SKILL_SCRIPT_DEFAULT_TIMEOUT_MS = 30_000;
12+
const SKILL_SCRIPT_DEFAULT_TIMEOUT_MS = 300_000;
1313

1414
// 全局的 Skill Script UUID → 工具信息映射,供 GM API 权限验证时使用
1515
// 直接携带 grants,避免运行时再查 repo(skill 的 Skill Script 不在 skillScriptRepo 中)

src/app/service/agent/tool_registry.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -71,27 +71,30 @@ export class ToolRegistry {
7171

7272
const results: ToolExecuteResult[] = [];
7373

74-
// 执行内置工具
75-
for (const tc of builtinCalls) {
76-
const tool = this.builtinTools.get(tc.name)!;
77-
try {
78-
let args: Record<string, unknown> = {};
79-
if (tc.arguments) {
80-
args = JSON.parse(tc.arguments);
81-
}
82-
const rawResult = await tool.executor.execute(args);
83-
84-
// 检查是否带附件
85-
if (isToolResultWithAttachments(rawResult)) {
86-
const attachments = await this.saveAttachments(rawResult.attachments);
87-
results.push({ id: tc.id, result: rawResult.content, attachments });
88-
} else {
89-
results.push({ id: tc.id, result: typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult) });
74+
// 并行执行内置工具
75+
const builtinResults = await Promise.all(
76+
builtinCalls.map(async (tc): Promise<ToolExecuteResult> => {
77+
const tool = this.builtinTools.get(tc.name)!;
78+
try {
79+
let args: Record<string, unknown> = {};
80+
if (tc.arguments) {
81+
args = JSON.parse(tc.arguments);
82+
}
83+
const rawResult = await tool.executor.execute(args);
84+
85+
// 检查是否带附件
86+
if (isToolResultWithAttachments(rawResult)) {
87+
const attachments = await this.saveAttachments(rawResult.attachments);
88+
return { id: tc.id, result: rawResult.content, attachments };
89+
} else {
90+
return { id: tc.id, result: typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult) };
91+
}
92+
} catch (e: any) {
93+
return { id: tc.id, result: JSON.stringify({ error: e.message || "Tool execution failed" }) };
9094
}
91-
} catch (e: any) {
92-
results.push({ id: tc.id, result: JSON.stringify({ error: e.message || "Tool execution failed" }) });
93-
}
94-
}
95+
})
96+
);
97+
results.push(...builtinResults);
9598

9699
// 执行脚本工具
97100
if (scriptCalls.length > 0) {

src/app/service/agent/tools/ask_user.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe("ask_user", () => {
3737
await expect(executor.execute({})).rejects.toThrow("question is required");
3838
});
3939

40-
it("should timeout after 5 minutes", async () => {
40+
it("should resolve with timeout reason after 5 minutes", async () => {
4141
vi.useFakeTimers();
4242
const sendEvent = vi.fn();
4343
const resolvers = new Map<string, (answer: string) => void>();
@@ -48,7 +48,8 @@ describe("ask_user", () => {
4848
// Advance time past timeout
4949
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
5050

51-
await expect(resultPromise).rejects.toThrow("User did not respond within 5 minutes");
51+
const result = JSON.parse((await resultPromise) as string);
52+
expect(result).toEqual({ answer: null, reason: "timeout" });
5253
expect(resolvers.size).toBe(0);
5354

5455
vi.useRealTimers();

0 commit comments

Comments
 (0)