feat: 多层上下文压缩 + 多级权限管理系统
权限管理: - PermissionTypes: 权限行为/模式/规则/决策/选择类型系统 - PermissionSettings: 用户级/项目级/会话级权限持久化(settings.json) - PermissionRuleEngine: 多层规则引擎(deny→allow→readOnly→mode→dangerous→ASK) - DangerousPatterns: 危险shell命令检测(rm -rf, eval, exec等) - ReplSession: Y/A/N/D四选项权限确认UI - ConfigCommand: permission-mode/list/reset子命令 - AgentLoop: 集成规则引擎,PermissionChoice回调 上下文压缩: - TokenTracker: 上下文窗口监控(93%自动压缩/82%警告/98%阻塞) - MicroCompact: 微压缩,裁剪旧tool_result(无API调用) - SessionMemoryCompact: Session Memory压缩,AI摘要旧消息保留近期段 - FullCompact: 全量压缩+PTL重试(按API Round逐步丢弃) - AutoCompactManager: 压缩编排器(micro→session→full),含熔断机制 - CompactCommand: 改造为委托FullCompact - StatusLine: token使用百分比+颜色告警(绿→黄→红→闪烁) - AppConfig: 注册PermissionSettings/RuleEngine/AutoCompactManager Bean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -4,10 +4,10 @@ import com.claudecode.command.CommandContext;
|
|||||||
import com.claudecode.command.SlashCommand;
|
import com.claudecode.command.SlashCommand;
|
||||||
import com.claudecode.console.AnsiStyle;
|
import com.claudecode.console.AnsiStyle;
|
||||||
import com.claudecode.core.TokenTracker;
|
import com.claudecode.core.TokenTracker;
|
||||||
|
import com.claudecode.core.compact.AutoCompactManager;
|
||||||
|
import com.claudecode.core.compact.FullCompact;
|
||||||
import org.springframework.ai.chat.messages.*;
|
import org.springframework.ai.chat.messages.*;
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
import org.springframework.ai.chat.model.ChatResponse;
|
|
||||||
import org.springframework.ai.chat.prompt.Prompt;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -16,22 +16,10 @@ import java.util.List;
|
|||||||
* /compact 命令 —— 用 AI 生成摘要来压缩上下文。
|
* /compact 命令 —— 用 AI 生成摘要来压缩上下文。
|
||||||
* <p>
|
* <p>
|
||||||
* 对应 claude-code/src/commands/compact.ts。
|
* 对应 claude-code/src/commands/compact.ts。
|
||||||
* 将详细的对话历史替换为 AI 生成的摘要,大幅减少 token 消耗。
|
* 委托给 FullCompact 执行实际压缩逻辑。
|
||||||
* 保留系统提示词和最近一轮对话。
|
|
||||||
*/
|
*/
|
||||||
public class CompactCommand implements SlashCommand {
|
public class CompactCommand implements SlashCommand {
|
||||||
|
|
||||||
private static final String COMPACT_PROMPT = """
|
|
||||||
Please compress the following conversation history into a concise summary. Requirements:
|
|
||||||
1. Preserve all key decisions, code changes, and technical details
|
|
||||||
2. Keep file paths, function names, and specific information
|
|
||||||
3. Preserve user preferences and requirements
|
|
||||||
4. Omit repeated discussions and irrelevant details
|
|
||||||
5. Output within 500 words
|
|
||||||
|
|
||||||
Conversation history:
|
|
||||||
""";
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String name() {
|
public String name() {
|
||||||
return "compact";
|
return "compact";
|
||||||
@@ -58,78 +46,38 @@ public class CompactCommand implements SlashCommand {
|
|||||||
TokenTracker tracker = context.agentLoop().getTokenTracker();
|
TokenTracker tracker = context.agentLoop().getTokenTracker();
|
||||||
long tokensBefore = tracker.getInputTokens() + tracker.getOutputTokens();
|
long tokensBefore = tracker.getInputTokens() + tracker.getOutputTokens();
|
||||||
|
|
||||||
// 尝试用 AI 生成摘要
|
// 优先使用 AutoCompactManager 中的 FullCompact
|
||||||
String summary = generateSummary(context, history);
|
FullCompact fullCompact;
|
||||||
|
AutoCompactManager acm = context.agentLoop().getAutoCompactManager();
|
||||||
// 构建压缩后的历史:系统提示 + 摘要作为系统消息 + 保留最后一轮对话
|
if (acm != null) {
|
||||||
List<Message> compacted = new ArrayList<>();
|
fullCompact = acm.getFullCompact();
|
||||||
compacted.add(history.getFirst()); // 原始系统提示词
|
} else {
|
||||||
|
fullCompact = new FullCompact(context.agentLoop().getChatModel());
|
||||||
if (summary != null && !summary.isBlank()) {
|
|
||||||
compacted.add(new SystemMessage("[Conversation Summary] " + summary));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保留最后一轮用户消息和助手回复(如果有)
|
// 执行全量压缩
|
||||||
for (int i = Math.max(1, before - 2); i < before; i++) {
|
List<Message> compacted = fullCompact.compact(new ArrayList<>(history));
|
||||||
compacted.add(history.get(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (compacted != null) {
|
||||||
context.agentLoop().replaceHistory(compacted);
|
context.agentLoop().replaceHistory(compacted);
|
||||||
int after = compacted.size();
|
int after = compacted.size();
|
||||||
|
|
||||||
|
// 重置熔断器(手动压缩成功说明 AI 摘要功能正常)
|
||||||
|
if (acm != null) {
|
||||||
|
acm.resetCircuitBreaker();
|
||||||
|
}
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(AnsiStyle.green(" ✅ Context compacted")).append("\n");
|
sb.append(AnsiStyle.green(" ✅ Context compacted")).append("\n");
|
||||||
sb.append(" Messages: ").append(before).append(" → ").append(after).append("\n");
|
sb.append(" Messages: ").append(before).append(" → ").append(after).append("\n");
|
||||||
if (tokensBefore > 0) {
|
if (tokensBefore > 0) {
|
||||||
sb.append(" Tokens before compaction: ").append(TokenTracker.formatTokens(tokensBefore)).append("\n");
|
sb.append(" Tokens before compaction: ").append(TokenTracker.formatTokens(tokensBefore)).append("\n");
|
||||||
}
|
}
|
||||||
if (summary != null) {
|
|
||||||
sb.append(AnsiStyle.dim(" 📝 AI summary generated and injected into context"));
|
sb.append(AnsiStyle.dim(" 📝 AI summary generated and injected into context"));
|
||||||
} else {
|
|
||||||
sb.append(AnsiStyle.dim(" ⚠ AI summary generation failed, keeping recent conversation only"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 调用 AI 生成对话摘要 */
|
return AnsiStyle.yellow(" ⚠ Compaction failed — AI summary generation failed") + "\n"
|
||||||
private String generateSummary(CommandContext context, List<Message> history) {
|
+ AnsiStyle.dim(" The conversation history was not modified.");
|
||||||
try {
|
|
||||||
ChatModel chatModel = context.agentLoop().getChatModel();
|
|
||||||
|
|
||||||
// 构建摘要请求的消息列史
|
|
||||||
StringBuilder dialogText = new StringBuilder();
|
|
||||||
for (Message msg : history) {
|
|
||||||
switch (msg) {
|
|
||||||
case UserMessage um -> dialogText.append("[User] ").append(um.getText()).append("\n");
|
|
||||||
case AssistantMessage am -> {
|
|
||||||
if (am.getText() != null && !am.getText().isBlank()) {
|
|
||||||
// 截断过长的助手回复
|
|
||||||
String text = am.getText();
|
|
||||||
if (text.length() > 500) text = text.substring(0, 500) + "...";
|
|
||||||
dialogText.append("[Assistant] ").append(text).append("\n");
|
|
||||||
}
|
|
||||||
if (am.hasToolCalls()) {
|
|
||||||
for (var tc : am.getToolCalls()) {
|
|
||||||
dialogText.append("[Tool Call] ").append(tc.name()).append("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default -> {} // 跳过系统消息和工具响应
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dialogText.isEmpty()) return null;
|
|
||||||
|
|
||||||
Prompt summaryPrompt = new Prompt(List.of(
|
|
||||||
new UserMessage(COMPACT_PROMPT + dialogText)
|
|
||||||
));
|
|
||||||
|
|
||||||
ChatResponse response = chatModel.call(summaryPrompt);
|
|
||||||
return response.getResult().getOutput().getText();
|
|
||||||
} catch (Exception e) {
|
|
||||||
// 摘要生成失败不影响压缩操作
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.claudecode.command.impl;
|
|||||||
import com.claudecode.command.CommandContext;
|
import com.claudecode.command.CommandContext;
|
||||||
import com.claudecode.command.SlashCommand;
|
import com.claudecode.command.SlashCommand;
|
||||||
import com.claudecode.console.AnsiStyle;
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.permission.PermissionRuleEngine;
|
||||||
|
import com.claudecode.permission.PermissionSettings;
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionMode;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -10,8 +13,7 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* /config 命令 —— 查看和设置应用配置。
|
* /config 命令 —— 查看和设置应用配置。
|
||||||
* <p>
|
* <p>
|
||||||
* 支持查看当前配置、设置单个配置项。
|
* 支持查看当前配置、设置单个配置项,以及权限管理子命令。
|
||||||
* 配置变更仅在当前会话内生效。
|
|
||||||
*/
|
*/
|
||||||
public class ConfigCommand implements SlashCommand {
|
public class ConfigCommand implements SlashCommand {
|
||||||
|
|
||||||
@@ -21,9 +23,24 @@ public class ConfigCommand implements SlashCommand {
|
|||||||
"max-tokens", "Maximum output tokens per response",
|
"max-tokens", "Maximum output tokens per response",
|
||||||
"temperature", "Response randomness (0.0-1.0)",
|
"temperature", "Response randomness (0.0-1.0)",
|
||||||
"verbose", "Enable verbose logging (true/false)",
|
"verbose", "Enable verbose logging (true/false)",
|
||||||
"auto-compact", "Auto compact when context is large (true/false)"
|
"auto-compact", "Auto compact when context is large (true/false)",
|
||||||
|
"permission-mode", "Permission mode: default/accept-edits/bypass/dont-ask",
|
||||||
|
"permission-list", "List all saved permission rules",
|
||||||
|
"permission-reset", "Clear all permission rules"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private PermissionSettings permissionSettings;
|
||||||
|
|
||||||
|
public ConfigCommand() {}
|
||||||
|
|
||||||
|
public ConfigCommand(PermissionSettings permissionSettings) {
|
||||||
|
this.permissionSettings = permissionSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPermissionSettings(PermissionSettings settings) {
|
||||||
|
this.permissionSettings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String name() {
|
public String name() {
|
||||||
return "config";
|
return "config";
|
||||||
@@ -90,17 +107,6 @@ public class ConfigCommand implements SlashCommand {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String showConfig(String key, CommandContext context) {
|
|
||||||
if (!CONFIG_KEYS.containsKey(key)) {
|
|
||||||
return AnsiStyle.yellow(" ⚠ Unknown config key: " + key) + "\n"
|
|
||||||
+ AnsiStyle.dim(" Available: " + String.join(", ", CONFIG_KEYS.keySet()));
|
|
||||||
}
|
|
||||||
|
|
||||||
String desc = CONFIG_KEYS.get(key);
|
|
||||||
return " " + AnsiStyle.bold(key) + ": " + AnsiStyle.dim(desc) + "\n"
|
|
||||||
+ AnsiStyle.dim(" Set with: /config " + key + " <value>");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String setConfig(String key, String value, CommandContext context) {
|
private String setConfig(String key, String value, CommandContext context) {
|
||||||
return switch (key) {
|
return switch (key) {
|
||||||
case "model" -> {
|
case "model" -> {
|
||||||
@@ -112,6 +118,9 @@ public class ConfigCommand implements SlashCommand {
|
|||||||
boolean verbose = Boolean.parseBoolean(value);
|
boolean verbose = Boolean.parseBoolean(value);
|
||||||
yield AnsiStyle.green(" ✅ Verbose mode: " + (verbose ? "ON" : "OFF"));
|
yield AnsiStyle.green(" ✅ Verbose mode: " + (verbose ? "ON" : "OFF"));
|
||||||
}
|
}
|
||||||
|
case "permission-mode" -> setPermissionMode(value);
|
||||||
|
case "permission-list" -> listPermissionRules();
|
||||||
|
case "permission-reset" -> resetPermissionRules();
|
||||||
default -> {
|
default -> {
|
||||||
if (!CONFIG_KEYS.containsKey(key)) {
|
if (!CONFIG_KEYS.containsKey(key)) {
|
||||||
yield AnsiStyle.yellow(" ⚠ Unknown config key: " + key);
|
yield AnsiStyle.yellow(" ⚠ Unknown config key: " + key);
|
||||||
@@ -121,4 +130,69 @@ public class ConfigCommand implements SlashCommand {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String showConfig(String key, CommandContext context) {
|
||||||
|
// 无参数的权限子命令
|
||||||
|
if (key.equals("permission-list")) return listPermissionRules();
|
||||||
|
if (key.equals("permission-reset")) return resetPermissionRules();
|
||||||
|
|
||||||
|
if (!CONFIG_KEYS.containsKey(key)) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Unknown config key: " + key) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Available: " + String.join(", ", CONFIG_KEYS.keySet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
String desc = CONFIG_KEYS.get(key);
|
||||||
|
return " " + AnsiStyle.bold(key) + ": " + AnsiStyle.dim(desc) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Set with: /config " + key + " <value>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 权限管理子命令 ──
|
||||||
|
|
||||||
|
private String setPermissionMode(String value) {
|
||||||
|
if (permissionSettings == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Permission settings not initialized");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
PermissionMode mode = switch (value.toLowerCase()) {
|
||||||
|
case "default" -> PermissionMode.DEFAULT;
|
||||||
|
case "accept-edits", "acceptedits" -> PermissionMode.ACCEPT_EDITS;
|
||||||
|
case "bypass" -> PermissionMode.BYPASS;
|
||||||
|
case "dont-ask", "dontask" -> PermissionMode.DONT_ASK;
|
||||||
|
default -> throw new IllegalArgumentException(value);
|
||||||
|
};
|
||||||
|
permissionSettings.setCurrentMode(mode);
|
||||||
|
return AnsiStyle.green(" ✅ Permission mode set to: " + mode);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Unknown mode: " + value) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Available: default, accept-edits, bypass, dont-ask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String listPermissionRules() {
|
||||||
|
if (permissionSettings == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Permission settings not initialized");
|
||||||
|
}
|
||||||
|
var rules = permissionSettings.listRules();
|
||||||
|
if (rules.isEmpty()) {
|
||||||
|
return AnsiStyle.dim(" No saved permission rules") + "\n"
|
||||||
|
+ AnsiStyle.dim(" Mode: " + permissionSettings.getCurrentMode());
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 🔒 Permission Rules")).append("\n");
|
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Mode: ")).append(permissionSettings.getCurrentMode()).append("\n\n");
|
||||||
|
for (String rule : rules) {
|
||||||
|
sb.append(" ").append(rule).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resetPermissionRules() {
|
||||||
|
if (permissionSettings == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Permission settings not initialized");
|
||||||
|
}
|
||||||
|
permissionSettings.clearAll();
|
||||||
|
return AnsiStyle.green(" ✅ All permission rules cleared");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import com.claudecode.context.SystemPromptBuilder;
|
|||||||
import com.claudecode.core.AgentLoop;
|
import com.claudecode.core.AgentLoop;
|
||||||
import com.claudecode.core.TaskManager;
|
import com.claudecode.core.TaskManager;
|
||||||
import com.claudecode.core.TokenTracker;
|
import com.claudecode.core.TokenTracker;
|
||||||
|
import com.claudecode.core.compact.AutoCompactManager;
|
||||||
import com.claudecode.mcp.McpManager;
|
import com.claudecode.mcp.McpManager;
|
||||||
|
import com.claudecode.permission.PermissionRuleEngine;
|
||||||
|
import com.claudecode.permission.PermissionSettings;
|
||||||
import com.claudecode.plugin.OutputStylePlugin;
|
import com.claudecode.plugin.OutputStylePlugin;
|
||||||
import com.claudecode.plugin.PluginManager;
|
import com.claudecode.plugin.PluginManager;
|
||||||
import com.claudecode.repl.ReplSession;
|
import com.claudecode.repl.ReplSession;
|
||||||
@@ -115,7 +118,8 @@ public class AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CommandRegistry commandRegistry(PluginManager pluginManager) {
|
public CommandRegistry commandRegistry(PluginManager pluginManager, PermissionSettings permissionSettings) {
|
||||||
|
ConfigCommand configCommand = new ConfigCommand(permissionSettings);
|
||||||
CommandRegistry registry = new CommandRegistry();
|
CommandRegistry registry = new CommandRegistry();
|
||||||
registry.registerAll(
|
registry.registerAll(
|
||||||
// 基础命令
|
// 基础命令
|
||||||
@@ -127,7 +131,7 @@ public class AppConfig {
|
|||||||
new StatusCommand(),
|
new StatusCommand(),
|
||||||
new ContextCommand(),
|
new ContextCommand(),
|
||||||
new InitCommand(),
|
new InitCommand(),
|
||||||
new ConfigCommand(),
|
configCommand,
|
||||||
new HistoryCommand(),
|
new HistoryCommand(),
|
||||||
// P0 命令
|
// P0 命令
|
||||||
new DiffCommand(),
|
new DiffCommand(),
|
||||||
@@ -192,6 +196,23 @@ public class AppConfig {
|
|||||||
return new ProviderInfo(provider, baseUrl, model);
|
return new ProviderInfo(provider, baseUrl, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PermissionSettings permissionSettings() {
|
||||||
|
PermissionSettings settings = new PermissionSettings();
|
||||||
|
settings.load();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PermissionRuleEngine permissionRuleEngine(PermissionSettings permissionSettings) {
|
||||||
|
return new PermissionRuleEngine(permissionSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AutoCompactManager autoCompactManager(ChatModel activeChatModel, TokenTracker tokenTracker) {
|
||||||
|
return new AutoCompactManager(activeChatModel, tokenTracker);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public TokenTracker tokenTracker(ProviderInfo info) {
|
public TokenTracker tokenTracker(ProviderInfo info) {
|
||||||
TokenTracker tracker = new TokenTracker();
|
TokenTracker tracker = new TokenTracker();
|
||||||
@@ -223,9 +244,14 @@ public class AppConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public AgentLoop agentLoop(ChatModel activeChatModel, ToolRegistry toolRegistry,
|
public AgentLoop agentLoop(ChatModel activeChatModel, ToolRegistry toolRegistry,
|
||||||
ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker,
|
ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker,
|
||||||
PluginManager pluginManager) {
|
PluginManager pluginManager, PermissionRuleEngine permissionRuleEngine,
|
||||||
|
AutoCompactManager autoCompactManager) {
|
||||||
AgentLoop mainLoop = new AgentLoop(activeChatModel, toolRegistry, toolContext, systemPrompt, tokenTracker);
|
AgentLoop mainLoop = new AgentLoop(activeChatModel, toolRegistry, toolContext, systemPrompt, tokenTracker);
|
||||||
|
|
||||||
|
// 注入权限引擎和自动压缩管理器
|
||||||
|
mainLoop.setPermissionEngine(permissionRuleEngine);
|
||||||
|
mainLoop.setAutoCompactManager(autoCompactManager);
|
||||||
|
|
||||||
// 注册子 Agent 工厂
|
// 注册子 Agent 工厂
|
||||||
toolContext.set(AgentTool.AGENT_FACTORY_KEY,
|
toolContext.set(AgentTool.AGENT_FACTORY_KEY,
|
||||||
(java.util.function.Function<String, String>) prompt -> {
|
(java.util.function.Function<String, String>) prompt -> {
|
||||||
|
|||||||
@@ -84,10 +84,25 @@ public class StatusLine {
|
|||||||
// 模型名
|
// 模型名
|
||||||
sb.append(" ").append(modelName);
|
sb.append(" ").append(modelName);
|
||||||
|
|
||||||
// Token 用量
|
// Token 用量 + 上下文窗口占比
|
||||||
sb.append(" │ ↑").append(TokenTracker.formatTokens(inputTokens));
|
sb.append(" │ ↑").append(TokenTracker.formatTokens(inputTokens));
|
||||||
sb.append(" ↓").append(TokenTracker.formatTokens(outputTokens));
|
sb.append(" ↓").append(TokenTracker.formatTokens(outputTokens));
|
||||||
|
|
||||||
|
// 上下文窗口使用百分比(带颜色)
|
||||||
|
double usagePct = tokenTracker.getUsagePercentage();
|
||||||
|
if (usagePct > 0) {
|
||||||
|
String pctStr = String.format(" %.0f%%", usagePct * 100);
|
||||||
|
var warningState = tokenTracker.getTokenWarningState();
|
||||||
|
sb.append(AnsiStyle.RESET); // 先重置再着色
|
||||||
|
sb.append(switch (warningState) {
|
||||||
|
case NORMAL -> AnsiStyle.DIM + AnsiStyle.GREEN + pctStr;
|
||||||
|
case WARNING -> AnsiStyle.BOLD + AnsiStyle.YELLOW + pctStr;
|
||||||
|
case ERROR -> AnsiStyle.BOLD + AnsiStyle.RED + pctStr;
|
||||||
|
case BLOCKING -> AnsiStyle.BOLD + AnsiStyle.RED + "⚠" + pctStr;
|
||||||
|
});
|
||||||
|
sb.append(AnsiStyle.RESET).append(AnsiStyle.DIM);
|
||||||
|
}
|
||||||
|
|
||||||
// 费用
|
// 费用
|
||||||
if (cost > 0) {
|
if (cost > 0) {
|
||||||
sb.append(String.format(" $%.4f", cost));
|
sb.append(String.format(" $%.4f", cost));
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package com.claudecode.core;
|
package com.claudecode.core;
|
||||||
|
|
||||||
|
import com.claudecode.core.compact.AutoCompactManager;
|
||||||
|
import com.claudecode.permission.PermissionRuleEngine;
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionChoice;
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionDecision;
|
||||||
import com.claudecode.tool.ToolCallbackAdapter;
|
import com.claudecode.tool.ToolCallbackAdapter;
|
||||||
import com.claudecode.tool.ToolContext;
|
import com.claudecode.tool.ToolContext;
|
||||||
import com.claudecode.tool.ToolRegistry;
|
import com.claudecode.tool.ToolRegistry;
|
||||||
@@ -52,6 +56,12 @@ public class AgentLoop {
|
|||||||
private final TokenTracker tokenTracker;
|
private final TokenTracker tokenTracker;
|
||||||
private final HookManager hookManager;
|
private final HookManager hookManager;
|
||||||
|
|
||||||
|
/** 权限规则引擎(可选,为 null 时使用传统回调方式) */
|
||||||
|
private PermissionRuleEngine permissionEngine;
|
||||||
|
|
||||||
|
/** 自动压缩管理器(可选) */
|
||||||
|
private AutoCompactManager autoCompactManager;
|
||||||
|
|
||||||
/** 消息历史 —— 自行管理,不依赖 Spring AI ChatMemory */
|
/** 消息历史 —— 自行管理,不依赖 Spring AI ChatMemory */
|
||||||
private final List<Message> messageHistory = new ArrayList<>();
|
private final List<Message> messageHistory = new ArrayList<>();
|
||||||
|
|
||||||
@@ -64,8 +74,8 @@ public class AgentLoop {
|
|||||||
/** 流式输出开始回调:通知 UI 停止 spinner */
|
/** 流式输出开始回调:通知 UI 停止 spinner */
|
||||||
private Runnable onStreamStart;
|
private Runnable onStreamStart;
|
||||||
|
|
||||||
/** 权限确认回调:危险操作前请求用户确认(返回 true 表示允许) */
|
/** 权限确认回调:危险操作前请求用户确认(返回 PermissionChoice) */
|
||||||
private Function<PermissionRequest, Boolean> onPermissionRequest;
|
private Function<PermissionRequest, PermissionChoice> onPermissionRequest;
|
||||||
|
|
||||||
/** Thinking 内容回调:显示 AI 的思考过程 */
|
/** Thinking 内容回调:显示 AI 的思考过程 */
|
||||||
private Consumer<String> onThinkingContent;
|
private Consumer<String> onThinkingContent;
|
||||||
@@ -98,10 +108,22 @@ public class AgentLoop {
|
|||||||
this.onStreamStart = onStreamStart;
|
this.onStreamStart = onStreamStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setOnPermissionRequest(Function<PermissionRequest, Boolean> onPermissionRequest) {
|
public void setOnPermissionRequest(Function<PermissionRequest, PermissionChoice> onPermissionRequest) {
|
||||||
this.onPermissionRequest = onPermissionRequest;
|
this.onPermissionRequest = onPermissionRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setPermissionEngine(PermissionRuleEngine engine) {
|
||||||
|
this.permissionEngine = engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAutoCompactManager(AutoCompactManager manager) {
|
||||||
|
this.autoCompactManager = manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutoCompactManager getAutoCompactManager() {
|
||||||
|
return autoCompactManager;
|
||||||
|
}
|
||||||
|
|
||||||
public void setOnThinkingContent(Consumer<String> onThinkingContent) {
|
public void setOnThinkingContent(Consumer<String> onThinkingContent) {
|
||||||
this.onThinkingContent = onThinkingContent;
|
this.onThinkingContent = onThinkingContent;
|
||||||
}
|
}
|
||||||
@@ -183,6 +205,14 @@ public class AgentLoop {
|
|||||||
|
|
||||||
// 执行工具调用
|
// 执行工具调用
|
||||||
executeToolCalls(result.assistant.getToolCalls(), callbacks);
|
executeToolCalls(result.assistant.getToolCalls(), callbacks);
|
||||||
|
|
||||||
|
// 自动压缩检查(在工具调用后,下次 API 调用前)
|
||||||
|
if (autoCompactManager != null) {
|
||||||
|
autoCompactManager.autoCompactIfNeeded(
|
||||||
|
() -> messageHistory,
|
||||||
|
this::replaceHistory
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (iteration >= MAX_ITERATIONS) {
|
if (iteration >= MAX_ITERATIONS) {
|
||||||
@@ -302,12 +332,34 @@ public class AgentLoop {
|
|||||||
String result;
|
String result;
|
||||||
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName);
|
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName);
|
||||||
if (adapter != null) {
|
if (adapter != null) {
|
||||||
// 权限确认:非只读工具需要用户确认
|
// 权限检查:优先使用规则引擎,回退到传统回调
|
||||||
boolean permitted = true;
|
boolean permitted = true;
|
||||||
if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) {
|
if (permissionEngine != null) {
|
||||||
|
PermissionDecision decision = permissionEngine.evaluate(
|
||||||
|
toolName, parsedArgs, adapter.getTool().isReadOnly());
|
||||||
|
if (decision.isAllowed()) {
|
||||||
|
permitted = true;
|
||||||
|
} else if (decision.isDenied()) {
|
||||||
|
permitted = false;
|
||||||
|
log.info("[{}] Denied by rule: {}", toolName, decision.reason());
|
||||||
|
} else if (decision.needsAsk() && onPermissionRequest != null) {
|
||||||
String activity = adapter.getTool().activityDescription(parsedArgs);
|
String activity = adapter.getTool().activityDescription(parsedArgs);
|
||||||
PermissionRequest req = new PermissionRequest(toolName, toolArgs, activity);
|
PermissionRequest req = new PermissionRequest(toolName, toolArgs, activity);
|
||||||
permitted = onPermissionRequest.apply(req);
|
req.setDecision(decision);
|
||||||
|
PermissionChoice choice = onPermissionRequest.apply(req);
|
||||||
|
permitted = (choice == PermissionChoice.ALLOW_ONCE || choice == PermissionChoice.ALWAYS_ALLOW);
|
||||||
|
// 持久化用户选择
|
||||||
|
String command = parsedArgs != null ? (String) parsedArgs.get("command") : null;
|
||||||
|
permissionEngine.applyChoice(choice, toolName, command);
|
||||||
|
} else {
|
||||||
|
permitted = false;
|
||||||
|
}
|
||||||
|
} else if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) {
|
||||||
|
// 传统回调模式(向后兼容)
|
||||||
|
String activity = adapter.getTool().activityDescription(parsedArgs);
|
||||||
|
PermissionRequest req = new PermissionRequest(toolName, toolArgs, activity);
|
||||||
|
PermissionChoice choice = onPermissionRequest.apply(req);
|
||||||
|
permitted = (choice == PermissionChoice.ALLOW_ONCE || choice == PermissionChoice.ALWAYS_ALLOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permitted) {
|
if (permitted) {
|
||||||
@@ -438,7 +490,24 @@ public class AgentLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 权限确认请求 */
|
/** 权限确认请求 */
|
||||||
public record PermissionRequest(String toolName, String arguments, String activityDescription) {}
|
public static class PermissionRequest {
|
||||||
|
private final String toolName;
|
||||||
|
private final String arguments;
|
||||||
|
private final String activityDescription;
|
||||||
|
private PermissionDecision decision;
|
||||||
|
|
||||||
|
public PermissionRequest(String toolName, String arguments, String activityDescription) {
|
||||||
|
this.toolName = toolName;
|
||||||
|
this.arguments = arguments;
|
||||||
|
this.activityDescription = activityDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toolName() { return toolName; }
|
||||||
|
public String arguments() { return arguments; }
|
||||||
|
public String activityDescription() { return activityDescription; }
|
||||||
|
public PermissionDecision decision() { return decision; }
|
||||||
|
public void setDecision(PermissionDecision decision) { this.decision = decision; }
|
||||||
|
}
|
||||||
|
|
||||||
/** 工具事件,用于 UI 展示 */
|
/** 工具事件,用于 UI 展示 */
|
||||||
public record ToolEvent(String toolName, Phase phase, String arguments, String result) {
|
public record ToolEvent(String toolName, Phase phase, String arguments, String result) {
|
||||||
|
|||||||
@@ -3,29 +3,72 @@ package com.claudecode.core;
|
|||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token 使用量追踪器 —— 记录 API 调用的 token 消耗。
|
* Token 使用量追踪器 —— 记录 API 调用的 token 消耗并监控上下文窗口。
|
||||||
* <p>
|
* <p>
|
||||||
* 从 ChatResponse 的 usage 元数据中提取 token 统计信息,
|
* 从 ChatResponse 的 usage 元数据中提取 token 统计信息,
|
||||||
* 支持按会话累计和费用估算。
|
* 支持按会话累计、费用估算和上下文窗口阈值监控。
|
||||||
*/
|
*/
|
||||||
public class TokenTracker {
|
public class TokenTracker {
|
||||||
|
|
||||||
|
// ── 上下文窗口阈值常量 ──
|
||||||
|
/** 自动压缩触发百分比(有效窗口的 93%) */
|
||||||
|
public static final double AUTO_COMPACT_THRESHOLD_PCT = 0.93;
|
||||||
|
/** 警告阈值百分比(82%) */
|
||||||
|
public static final double WARNING_THRESHOLD_PCT = 0.82;
|
||||||
|
/** 阻塞阈值百分比(98%,必须压缩才能继续) */
|
||||||
|
public static final double BLOCKING_THRESHOLD_PCT = 0.98;
|
||||||
|
/** 自动压缩缓冲 token 数 */
|
||||||
|
public static final long AUTO_COMPACT_BUFFER_TOKENS = 13_000;
|
||||||
|
/** 手动压缩缓冲 token 数 */
|
||||||
|
public static final long MANUAL_COMPACT_BUFFER_TOKENS = 3_000;
|
||||||
|
|
||||||
|
/** 上下文窗口警告状态 */
|
||||||
|
public enum TokenWarningState {
|
||||||
|
NORMAL, // 正常(绿色)
|
||||||
|
WARNING, // 接近阈值(黄色)
|
||||||
|
ERROR, // 达到压缩阈值(红色)
|
||||||
|
BLOCKING // 必须压缩才能继续(闪烁红)
|
||||||
|
}
|
||||||
|
|
||||||
private final AtomicLong totalInputTokens = new AtomicLong(0);
|
private final AtomicLong totalInputTokens = new AtomicLong(0);
|
||||||
private final AtomicLong totalOutputTokens = new AtomicLong(0);
|
private final AtomicLong totalOutputTokens = new AtomicLong(0);
|
||||||
private final AtomicLong totalCacheReadTokens = new AtomicLong(0);
|
private final AtomicLong totalCacheReadTokens = new AtomicLong(0);
|
||||||
private final AtomicLong totalCacheCreationTokens = new AtomicLong(0);
|
private final AtomicLong totalCacheCreationTokens = new AtomicLong(0);
|
||||||
private final AtomicLong apiCallCount = new AtomicLong(0);
|
private final AtomicLong apiCallCount = new AtomicLong(0);
|
||||||
|
|
||||||
|
/** 最近一次 API 调用报告的 prompt token 数(近似当前上下文大小) */
|
||||||
|
private final AtomicLong lastPromptTokens = new AtomicLong(0);
|
||||||
|
|
||||||
/** 模型定价(每百万 token 的美元价格) */
|
/** 模型定价(每百万 token 的美元价格) */
|
||||||
private double inputPricePerMillion = 3.0; // Claude Sonnet 4 input
|
private double inputPricePerMillion = 3.0; // Claude Sonnet 4 input
|
||||||
private double outputPricePerMillion = 15.0; // Claude Sonnet 4 output
|
private double outputPricePerMillion = 15.0; // Claude Sonnet 4 output
|
||||||
private double cacheReadPricePerMillion = 0.3; // 缓存读取
|
private double cacheReadPricePerMillion = 0.3; // 缓存读取
|
||||||
private String modelName = "claude-sonnet-4-20250514";
|
private String modelName = "claude-sonnet-4-20250514";
|
||||||
|
|
||||||
|
/** 上下文窗口总大小(token) */
|
||||||
|
private long contextWindowSize;
|
||||||
|
/** 预留给输出的 token 数 */
|
||||||
|
private long reservedTokens = 20_000;
|
||||||
|
|
||||||
|
public TokenTracker() {
|
||||||
|
// 支持环境变量覆盖上下文窗口大小
|
||||||
|
String envWindow = System.getenv("CLAUDE_CODE_CONTEXT_WINDOW");
|
||||||
|
if (envWindow != null && !envWindow.isBlank()) {
|
||||||
|
try {
|
||||||
|
this.contextWindowSize = Long.parseLong(envWindow.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
this.contextWindowSize = 200_000; // 默认 200K
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.contextWindowSize = 200_000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 记录一次 API 调用的 token 使用 */
|
/** 记录一次 API 调用的 token 使用 */
|
||||||
public void recordUsage(long inputTokens, long outputTokens) {
|
public void recordUsage(long inputTokens, long outputTokens) {
|
||||||
totalInputTokens.addAndGet(inputTokens);
|
totalInputTokens.addAndGet(inputTokens);
|
||||||
totalOutputTokens.addAndGet(outputTokens);
|
totalOutputTokens.addAndGet(outputTokens);
|
||||||
|
lastPromptTokens.set(inputTokens);
|
||||||
apiCallCount.incrementAndGet();
|
apiCallCount.incrementAndGet();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +78,7 @@ public class TokenTracker {
|
|||||||
totalOutputTokens.addAndGet(outputTokens);
|
totalOutputTokens.addAndGet(outputTokens);
|
||||||
totalCacheReadTokens.addAndGet(cacheRead);
|
totalCacheReadTokens.addAndGet(cacheRead);
|
||||||
totalCacheCreationTokens.addAndGet(cacheCreation);
|
totalCacheCreationTokens.addAndGet(cacheCreation);
|
||||||
|
lastPromptTokens.set(inputTokens);
|
||||||
apiCallCount.incrementAndGet();
|
apiCallCount.incrementAndGet();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +117,64 @@ public class TokenTracker {
|
|||||||
return inputCost + outputCost + cacheCost;
|
return inputCost + outputCost + cacheCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 上下文窗口监控 ──
|
||||||
|
|
||||||
|
/** 有效上下文窗口大小(总窗口 - 预留输出) */
|
||||||
|
public long getEffectiveWindow() {
|
||||||
|
return contextWindowSize - reservedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近一次 prompt 的 token 数(近似当前上下文大小) */
|
||||||
|
public long getLastPromptTokens() {
|
||||||
|
return lastPromptTokens.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前上下文使用百分比 */
|
||||||
|
public double getUsagePercentage() {
|
||||||
|
long effective = getEffectiveWindow();
|
||||||
|
if (effective <= 0) return 0;
|
||||||
|
return (double) lastPromptTokens.get() / effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否应触发自动压缩 */
|
||||||
|
public boolean shouldAutoCompact() {
|
||||||
|
return getUsagePercentage() >= AUTO_COMPACT_THRESHOLD_PCT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已达到阻塞阈值(必须压缩才能继续) */
|
||||||
|
public boolean isBlocking() {
|
||||||
|
return getUsagePercentage() >= BLOCKING_THRESHOLD_PCT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取自动压缩触发的 token 阈值 */
|
||||||
|
public long getAutoCompactThreshold() {
|
||||||
|
return (long) (getEffectiveWindow() * AUTO_COMPACT_THRESHOLD_PCT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前 token 警告状态 */
|
||||||
|
public TokenWarningState getTokenWarningState() {
|
||||||
|
double pct = getUsagePercentage();
|
||||||
|
if (pct >= BLOCKING_THRESHOLD_PCT) return TokenWarningState.BLOCKING;
|
||||||
|
if (pct >= AUTO_COMPACT_THRESHOLD_PCT) return TokenWarningState.ERROR;
|
||||||
|
if (pct >= WARNING_THRESHOLD_PCT) return TokenWarningState.WARNING;
|
||||||
|
return TokenWarningState.NORMAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getContextWindowSize() { return contextWindowSize; }
|
||||||
|
|
||||||
|
public void setContextWindowSize(long size) { this.contextWindowSize = size; }
|
||||||
|
|
||||||
|
public long getReservedTokens() { return reservedTokens; }
|
||||||
|
|
||||||
|
public void setReservedTokens(long reserved) { this.reservedTokens = reserved; }
|
||||||
|
|
||||||
/** 重置统计 */
|
/** 重置统计 */
|
||||||
public void reset() {
|
public void reset() {
|
||||||
totalInputTokens.set(0);
|
totalInputTokens.set(0);
|
||||||
totalOutputTokens.set(0);
|
totalOutputTokens.set(0);
|
||||||
totalCacheReadTokens.set(0);
|
totalCacheReadTokens.set(0);
|
||||||
totalCacheCreationTokens.set(0);
|
totalCacheCreationTokens.set(0);
|
||||||
|
lastPromptTokens.set(0);
|
||||||
apiCallCount.set(0);
|
apiCallCount.set(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package com.claudecode.core.compact;
|
||||||
|
|
||||||
|
import com.claudecode.core.TokenTracker;
|
||||||
|
import com.claudecode.core.compact.CompactionResult.CompactLayer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动压缩编排器 —— 根据 token 使用量自动选择并执行压缩策略。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的自动压缩编排逻辑。在 AgentLoop 中每次 API 响应后调用。
|
||||||
|
* 流程:检查阈值 → 微压缩 → Session Memory 压缩 → 全量压缩(兜底)
|
||||||
|
* 熔断器:连续失败 {@value MAX_CONSECUTIVE_FAILURES} 次后暂停自动压缩。
|
||||||
|
*/
|
||||||
|
public class AutoCompactManager {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AutoCompactManager.class);
|
||||||
|
|
||||||
|
/** 连续失败阈值,超过后暂停自动压缩 */
|
||||||
|
private static final int MAX_CONSECUTIVE_FAILURES = 3;
|
||||||
|
|
||||||
|
private final MicroCompact microCompact;
|
||||||
|
private final SessionMemoryCompact sessionMemoryCompact;
|
||||||
|
private final FullCompact fullCompact;
|
||||||
|
private final TokenTracker tokenTracker;
|
||||||
|
|
||||||
|
/** 连续压缩失败次数 */
|
||||||
|
private int consecutiveFailures = 0;
|
||||||
|
|
||||||
|
/** 是否已触发过熔断 */
|
||||||
|
private boolean circuitBroken = false;
|
||||||
|
|
||||||
|
/** 压缩事件回调(用于通知 UI) */
|
||||||
|
private Consumer<CompactionResult> onCompactionEvent;
|
||||||
|
|
||||||
|
public AutoCompactManager(ChatModel chatModel, TokenTracker tokenTracker) {
|
||||||
|
this.tokenTracker = tokenTracker;
|
||||||
|
this.microCompact = new MicroCompact();
|
||||||
|
this.sessionMemoryCompact = new SessionMemoryCompact(chatModel);
|
||||||
|
this.fullCompact = new FullCompact(chatModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnCompactionEvent(Consumer<CompactionResult> onCompactionEvent) {
|
||||||
|
this.onCompactionEvent = onCompactionEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在每次 API 响应后调用,根据 token 使用状态自动执行压缩。
|
||||||
|
*
|
||||||
|
* @param historySupplier 获取当前消息历史的函数
|
||||||
|
* @param historyReplacer 替换消息历史的函数
|
||||||
|
* @return 如果执行了压缩返回结果,否则返回 null
|
||||||
|
*/
|
||||||
|
public CompactionResult autoCompactIfNeeded(
|
||||||
|
Supplier<List<Message>> historySupplier,
|
||||||
|
Consumer<List<Message>> historyReplacer) {
|
||||||
|
|
||||||
|
// 熔断器检查
|
||||||
|
if (circuitBroken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要压缩
|
||||||
|
if (!tokenTracker.shouldAutoCompact()) {
|
||||||
|
// 即使不需要自动压缩,也执行微压缩(成本极低)
|
||||||
|
List<Message> history = historySupplier.get();
|
||||||
|
if (history instanceof java.util.ArrayList<Message> mutableHistory) {
|
||||||
|
microCompact.compact(mutableHistory);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Auto-compact triggered at {}% token usage",
|
||||||
|
String.format("%.1f", tokenTracker.getUsagePercentage() * 100));
|
||||||
|
|
||||||
|
List<Message> history = historySupplier.get();
|
||||||
|
|
||||||
|
// 阶段 1:微压缩
|
||||||
|
if (history instanceof java.util.ArrayList<Message> mutableHistory) {
|
||||||
|
CompactionResult microResult = microCompact.compact(mutableHistory);
|
||||||
|
if (microResult.success()) {
|
||||||
|
notifyEvent(microResult);
|
||||||
|
// 微压缩后重新检查是否仍需深度压缩
|
||||||
|
if (!tokenTracker.shouldAutoCompact()) {
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
return microResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段 2:Session Memory 压缩
|
||||||
|
try {
|
||||||
|
List<Message> compacted = sessionMemoryCompact.getCompactedHistory(history);
|
||||||
|
if (compacted != null) {
|
||||||
|
historyReplacer.accept(compacted);
|
||||||
|
CompactionResult result = CompactionResult.success(
|
||||||
|
CompactLayer.SESSION_MEMORY,
|
||||||
|
history.size(), compacted.size(),
|
||||||
|
"Auto session memory compact");
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
notifyEvent(result);
|
||||||
|
log.info("Session memory compact: {} → {} messages", history.size(), compacted.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Session memory compact failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段 3:全量压缩(兜底)
|
||||||
|
try {
|
||||||
|
List<Message> compacted = fullCompact.compact(history);
|
||||||
|
if (compacted != null) {
|
||||||
|
historyReplacer.accept(compacted);
|
||||||
|
CompactionResult result = CompactionResult.success(
|
||||||
|
CompactLayer.FULL,
|
||||||
|
history.size(), compacted.size(),
|
||||||
|
"Auto full compact (fallback)");
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
notifyEvent(result);
|
||||||
|
log.info("Full compact fallback: {} → {} messages", history.size(), compacted.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Full compact failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有压缩方式均失败
|
||||||
|
consecutiveFailures++;
|
||||||
|
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||||
|
circuitBroken = true;
|
||||||
|
log.error("Auto-compact circuit breaker triggered after {} consecutive failures",
|
||||||
|
consecutiveFailures);
|
||||||
|
CompactionResult result = CompactionResult.failure(CompactLayer.FULL,
|
||||||
|
"Circuit breaker: auto-compact disabled after " + consecutiveFailures + " failures");
|
||||||
|
notifyEvent(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompactionResult.failure(CompactLayer.SESSION_MEMORY,
|
||||||
|
"All compression strategies failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动重置熔断器 */
|
||||||
|
public void resetCircuitBreaker() {
|
||||||
|
circuitBroken = false;
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
log.info("Auto-compact circuit breaker reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCircuitBroken() {
|
||||||
|
return circuitBroken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getConsecutiveFailures() {
|
||||||
|
return consecutiveFailures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 FullCompact 实例(供 CompactCommand 委托使用) */
|
||||||
|
public FullCompact getFullCompact() {
|
||||||
|
return fullCompact;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyEvent(CompactionResult result) {
|
||||||
|
if (onCompactionEvent != null) {
|
||||||
|
try {
|
||||||
|
onCompactionEvent.accept(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Compaction event notification failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.claudecode.core.compact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩操作的结果数据。
|
||||||
|
*
|
||||||
|
* @param success 是否成功
|
||||||
|
* @param layer 执行的压缩层级
|
||||||
|
* @param messagesBefore 压缩前消息数
|
||||||
|
* @param messagesAfter 压缩后消息数
|
||||||
|
* @param summary AI 生成的摘要(可能为 null)
|
||||||
|
* @param reason 结果原因/描述
|
||||||
|
*/
|
||||||
|
public record CompactionResult(
|
||||||
|
boolean success,
|
||||||
|
CompactLayer layer,
|
||||||
|
int messagesBefore,
|
||||||
|
int messagesAfter,
|
||||||
|
String summary,
|
||||||
|
String reason
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 压缩层级 */
|
||||||
|
public enum CompactLayer {
|
||||||
|
/** 微压缩:裁剪旧 tool_result 内容 */
|
||||||
|
MICRO,
|
||||||
|
/** Session Memory:AI 摘要旧消息,保留近期段 */
|
||||||
|
SESSION_MEMORY,
|
||||||
|
/** 全量压缩:AI 摘要全部,PTL 重试 */
|
||||||
|
FULL,
|
||||||
|
/** 用户手动触发的全量压缩 */
|
||||||
|
MANUAL
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CompactionResult success(CompactLayer layer, int before, int after, String summary) {
|
||||||
|
return new CompactionResult(true, layer, before, after, summary,
|
||||||
|
"Compacted from " + before + " to " + after + " messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CompactionResult noAction(CompactLayer layer, String reason) {
|
||||||
|
return new CompactionResult(false, layer, 0, 0, null, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CompactionResult failure(CompactLayer layer, String reason) {
|
||||||
|
return new CompactionResult(false, layer, 0, 0, null, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.claudecode.core.compact;
|
||||||
|
|
||||||
|
import com.claudecode.core.compact.CompactionResult.CompactLayer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.ai.chat.messages.*;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量压缩 —— AI 摘要全部对话历史,带 PTL(Prompt Too Long)重试。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 fullCompact。当 SessionMemoryCompact 无法有效压缩时作为兜底。
|
||||||
|
* PTL 重试策略:按 API Round(user→assistant→tool_result 为一组)逐步丢弃最旧的组。
|
||||||
|
*/
|
||||||
|
public class FullCompact {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FullCompact.class);
|
||||||
|
|
||||||
|
/** PTL 重试最大次数 */
|
||||||
|
private static final int MAX_PTL_RETRIES = 5;
|
||||||
|
|
||||||
|
/** 保留最近 N 条消息(不压缩) */
|
||||||
|
private static final int KEEP_RECENT_MESSAGES = 2;
|
||||||
|
|
||||||
|
private static final String FULL_COMPACT_PROMPT = """
|
||||||
|
Please compress the following conversation history into a thorough summary. Requirements:
|
||||||
|
1. Preserve ALL key decisions, code changes, and technical details
|
||||||
|
2. Keep file paths, function names, class names, and specific identifiers
|
||||||
|
3. Preserve user preferences, requirements, and constraints
|
||||||
|
4. Record the current state of work: what was completed, what remains, what's blocked
|
||||||
|
5. Note any errors encountered and their resolutions
|
||||||
|
6. Keep important context about the project structure and architecture
|
||||||
|
7. Output within 1000 words, using structured bullet points
|
||||||
|
|
||||||
|
Conversation history:
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ChatModel chatModel;
|
||||||
|
|
||||||
|
public FullCompact(ChatModel chatModel) {
|
||||||
|
this.chatModel = chatModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行全量压缩。
|
||||||
|
*
|
||||||
|
* @param history 当前消息历史
|
||||||
|
* @return 压缩后的新历史;如果失败返回 null
|
||||||
|
*/
|
||||||
|
public List<Message> compact(List<Message> history) {
|
||||||
|
if (history.size() <= KEEP_RECENT_MESSAGES + 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int before = history.size();
|
||||||
|
Message systemMsg = history.getFirst();
|
||||||
|
|
||||||
|
// 按 API Round 分组
|
||||||
|
List<ApiRound> rounds = groupByRounds(history);
|
||||||
|
|
||||||
|
// PTL 重试循环:逐步丢弃最旧的 round
|
||||||
|
int dropCount = 0;
|
||||||
|
while (dropCount < rounds.size() - 1 && dropCount < MAX_PTL_RETRIES) {
|
||||||
|
List<ApiRound> remaining = rounds.subList(dropCount, rounds.size());
|
||||||
|
|
||||||
|
try {
|
||||||
|
String summary = generateFullSummary(remaining);
|
||||||
|
if (summary != null && !summary.isBlank()) {
|
||||||
|
// 构建新历史
|
||||||
|
List<Message> newHistory = new ArrayList<>();
|
||||||
|
newHistory.add(systemMsg);
|
||||||
|
newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary));
|
||||||
|
|
||||||
|
// 保留最后几条消息
|
||||||
|
for (int i = Math.max(1, before - KEEP_RECENT_MESSAGES); i < before; i++) {
|
||||||
|
newHistory.add(history.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Full compact succeeded: {} → {} messages (dropped {} rounds)",
|
||||||
|
before, newHistory.size(), dropCount);
|
||||||
|
return newHistory;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Full compact attempt failed (drop={}): {}", dropCount, e.getMessage());
|
||||||
|
// PTL error — drop oldest round and retry
|
||||||
|
}
|
||||||
|
|
||||||
|
dropCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("Full compact failed after {} PTL retries", dropCount);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行全量压缩并返回 CompactionResult。
|
||||||
|
*/
|
||||||
|
public CompactionResult compactWithResult(List<Message> history) {
|
||||||
|
int before = history.size();
|
||||||
|
List<Message> result = compact(history);
|
||||||
|
if (result == null) {
|
||||||
|
return CompactionResult.failure(CompactLayer.FULL, "Full compact failed");
|
||||||
|
}
|
||||||
|
return CompactionResult.success(CompactLayer.FULL, before, result.size(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部方法 ──
|
||||||
|
|
||||||
|
/** 按 API Round 分组:一个 round = [UserMessage] + [AssistantMessage + ToolResponseMessages...] */
|
||||||
|
private List<ApiRound> groupByRounds(List<Message> history) {
|
||||||
|
List<ApiRound> rounds = new ArrayList<>();
|
||||||
|
List<Message> currentRound = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 1; i < history.size(); i++) { // 跳过系统消息
|
||||||
|
Message msg = history.get(i);
|
||||||
|
if (msg instanceof UserMessage && !currentRound.isEmpty()) {
|
||||||
|
rounds.add(new ApiRound(List.copyOf(currentRound)));
|
||||||
|
currentRound.clear();
|
||||||
|
}
|
||||||
|
currentRound.add(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentRound.isEmpty()) {
|
||||||
|
rounds.add(new ApiRound(List.copyOf(currentRound)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成全量摘要 */
|
||||||
|
private String generateFullSummary(List<ApiRound> rounds) {
|
||||||
|
StringBuilder dialogText = new StringBuilder();
|
||||||
|
|
||||||
|
for (ApiRound round : rounds) {
|
||||||
|
for (Message msg : round.messages()) {
|
||||||
|
switch (msg) {
|
||||||
|
case UserMessage um -> dialogText.append("[User] ").append(um.getText()).append("\n");
|
||||||
|
case AssistantMessage am -> {
|
||||||
|
if (am.getText() != null && !am.getText().isBlank()) {
|
||||||
|
String text = am.getText();
|
||||||
|
if (text.length() > 600) text = text.substring(0, 600) + "...";
|
||||||
|
dialogText.append("[Assistant] ").append(text).append("\n");
|
||||||
|
}
|
||||||
|
if (am.hasToolCalls()) {
|
||||||
|
for (var tc : am.getToolCalls()) {
|
||||||
|
dialogText.append("[Tool Call] ").append(tc.name()).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ToolResponseMessage trm -> {
|
||||||
|
for (var resp : trm.getResponses()) {
|
||||||
|
dialogText.append("[Tool Result: ").append(resp.name()).append("]\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogText.append("---\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogText.isEmpty()) return null;
|
||||||
|
|
||||||
|
Prompt prompt = new Prompt(List.of(new UserMessage(FULL_COMPACT_PROMPT + dialogText)));
|
||||||
|
ChatResponse response = chatModel.call(prompt);
|
||||||
|
return response.getResult().getOutput().getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API Round:一个用户请求 + AI 响应 + 工具调用的完整回合 */
|
||||||
|
private record ApiRound(List<Message> messages) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.claudecode.core.compact;
|
||||||
|
|
||||||
|
import com.claudecode.core.compact.CompactionResult.CompactLayer;
|
||||||
|
import org.springframework.ai.chat.messages.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微压缩 —— 在每次 API 调用后执行,裁剪旧的 tool_result 内容。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 microCompact。不需要额外 API 调用,纯本地操作。
|
||||||
|
* 策略:保留最近 N 轮的 tool 结果,更早的只保留摘要行 "[Tool result truncated]"。
|
||||||
|
*/
|
||||||
|
public class MicroCompact {
|
||||||
|
|
||||||
|
/** 保留最近 N 条 ToolResponseMessage 的完整内容 */
|
||||||
|
private static final int KEEP_RECENT_TOOL_RESULTS = 6;
|
||||||
|
|
||||||
|
/** 截断阈值:超过此长度的旧 tool result 才会被截断 */
|
||||||
|
private static final int TRUNCATE_THRESHOLD = 200;
|
||||||
|
|
||||||
|
/** 截断后的占位文本 */
|
||||||
|
private static final String TRUNCATED_MARKER = "[Tool result truncated — %d chars omitted]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对消息历史执行微压缩。
|
||||||
|
* 直接在原始列表上原地修改以提升性能。
|
||||||
|
*
|
||||||
|
* @param history 消息列表(直接修改)
|
||||||
|
* @return 压缩结果
|
||||||
|
*/
|
||||||
|
public CompactionResult compact(List<Message> history) {
|
||||||
|
int totalToolResponses = 0;
|
||||||
|
int truncated = 0;
|
||||||
|
|
||||||
|
// 倒序扫描,找到所有 ToolResponseMessage 的位置
|
||||||
|
int recentCount = 0;
|
||||||
|
for (int i = history.size() - 1; i >= 0; i--) {
|
||||||
|
if (history.get(i) instanceof ToolResponseMessage) {
|
||||||
|
totalToolResponses++;
|
||||||
|
recentCount++;
|
||||||
|
if (recentCount > KEEP_RECENT_TOOL_RESULTS) {
|
||||||
|
// 需要截断
|
||||||
|
ToolResponseMessage trm = (ToolResponseMessage) history.get(i);
|
||||||
|
if (shouldTruncate(trm)) {
|
||||||
|
history.set(i, truncateToolResponse(trm));
|
||||||
|
truncated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (truncated == 0) {
|
||||||
|
return CompactionResult.noAction(CompactLayer.MICRO, "No tool results to truncate");
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompactionResult.success(CompactLayer.MICRO, totalToolResponses,
|
||||||
|
totalToolResponses - truncated, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断 ToolResponseMessage 是否需要截断 */
|
||||||
|
private boolean shouldTruncate(ToolResponseMessage trm) {
|
||||||
|
var responses = trm.getResponses();
|
||||||
|
if (responses == null || responses.isEmpty()) return false;
|
||||||
|
for (var resp : responses) {
|
||||||
|
if (resp.responseData() != null && resp.responseData().toString().length() > TRUNCATE_THRESHOLD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建截断后的 ToolResponseMessage */
|
||||||
|
private ToolResponseMessage truncateToolResponse(ToolResponseMessage original) {
|
||||||
|
var responses = original.getResponses();
|
||||||
|
if (responses == null || responses.isEmpty()) return original;
|
||||||
|
|
||||||
|
var truncatedResponses = responses.stream().map(resp -> {
|
||||||
|
String data = resp.responseData() != null ? resp.responseData().toString() : "";
|
||||||
|
if (data.length() > TRUNCATE_THRESHOLD) {
|
||||||
|
String marker = String.format(TRUNCATED_MARKER, data.length());
|
||||||
|
return new ToolResponseMessage.ToolResponse(resp.id(), resp.name(), marker);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return ToolResponseMessage.builder()
|
||||||
|
.responses(truncatedResponses)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package com.claudecode.core.compact;
|
||||||
|
|
||||||
|
import com.claudecode.core.compact.CompactionResult.CompactLayer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.ai.chat.messages.*;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Memory 压缩 —— 保留近期消息段,用 AI 摘要旧消息。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 sessionMemoryCompact。这是主要的自动压缩方式。
|
||||||
|
* 算法:
|
||||||
|
* <ol>
|
||||||
|
* <li>找到上次压缩的边界(通过检测 [Conversation Summary] 标记)</li>
|
||||||
|
* <li>计算需要保留的近期消息段(至少保留 MIN_KEEP_TOKENS token 估算量 + MIN_KEEP_TEXT_MSGS 条文本消息)</li>
|
||||||
|
* <li>将边界之后、保留段之前的消息通过 AI 生成摘要</li>
|
||||||
|
* <li>用 [系统提示] + [历史摘要] + [新摘要] + [保留段] 替换历史</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public class SessionMemoryCompact {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SessionMemoryCompact.class);
|
||||||
|
|
||||||
|
/** 最少保留的文本消息数(用户 + 助手) */
|
||||||
|
private static final int MIN_KEEP_TEXT_MSGS = 5;
|
||||||
|
|
||||||
|
/** 估算最少保留的 token 数 */
|
||||||
|
private static final int MIN_KEEP_TOKENS = 10_000;
|
||||||
|
|
||||||
|
/** 估算最多保留的 token 数 */
|
||||||
|
private static final int MAX_KEEP_TOKENS = 40_000;
|
||||||
|
|
||||||
|
/** 每字符估算的 token 数(粗略近似) */
|
||||||
|
private static final double CHARS_PER_TOKEN = 4.0;
|
||||||
|
|
||||||
|
private static final String SUMMARY_PROMPT = """
|
||||||
|
Summarize the following conversation segment concisely but thoroughly.
|
||||||
|
Preserve:
|
||||||
|
- All key technical decisions and their rationale
|
||||||
|
- File paths, function names, class names, and specific code identifiers
|
||||||
|
- User requirements and preferences
|
||||||
|
- Current state of work (what was done, what remains)
|
||||||
|
- Any errors encountered and their resolutions
|
||||||
|
|
||||||
|
Keep the summary under 800 words. Use bullet points for clarity.
|
||||||
|
|
||||||
|
Conversation segment to summarize:
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ChatModel chatModel;
|
||||||
|
|
||||||
|
public SessionMemoryCompact(ChatModel chatModel) {
|
||||||
|
this.chatModel = chatModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 Session Memory 压缩。
|
||||||
|
*
|
||||||
|
* @param history 当前消息历史(不直接修改,返回新列表)
|
||||||
|
* @return 压缩结果;如果无法压缩返回 noAction
|
||||||
|
*/
|
||||||
|
public CompactionResult compact(List<Message> history) {
|
||||||
|
if (history.size() <= MIN_KEEP_TEXT_MSGS + 2) {
|
||||||
|
return CompactionResult.noAction(CompactLayer.SESSION_MEMORY,
|
||||||
|
"Too few messages to compact");
|
||||||
|
}
|
||||||
|
|
||||||
|
int before = history.size();
|
||||||
|
|
||||||
|
// 找到系统提示词(第一条)
|
||||||
|
Message systemMsg = history.getFirst();
|
||||||
|
|
||||||
|
// 找到上一次摘要的位置(如果有的话)
|
||||||
|
int lastSummaryIndex = findLastSummaryIndex(history);
|
||||||
|
|
||||||
|
// 从摘要之后开始计算可压缩区域
|
||||||
|
int compressibleStart = lastSummaryIndex + 1;
|
||||||
|
|
||||||
|
// 从末尾向前找保留段的起始位置
|
||||||
|
int keepStart = findKeepStart(history, compressibleStart);
|
||||||
|
|
||||||
|
// 如果可压缩区域太小,不值得压缩
|
||||||
|
if (keepStart - compressibleStart < 4) {
|
||||||
|
return CompactionResult.noAction(CompactLayer.SESSION_MEMORY,
|
||||||
|
"Not enough messages to compress (only " + (keepStart - compressibleStart) + " in range)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取需要压缩的消息段
|
||||||
|
List<Message> toCompress = history.subList(compressibleStart, keepStart);
|
||||||
|
|
||||||
|
// 生成摘要
|
||||||
|
String summary;
|
||||||
|
try {
|
||||||
|
summary = generateSummary(toCompress);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Session memory compression failed: {}", e.getMessage());
|
||||||
|
return CompactionResult.failure(CompactLayer.SESSION_MEMORY,
|
||||||
|
"Summary generation failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary == null || summary.isBlank()) {
|
||||||
|
return CompactionResult.failure(CompactLayer.SESSION_MEMORY,
|
||||||
|
"Empty summary generated");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建新历史
|
||||||
|
List<Message> newHistory = new ArrayList<>();
|
||||||
|
newHistory.add(systemMsg);
|
||||||
|
|
||||||
|
// 保留旧的摘要(如果有的话,合并到新摘要中)
|
||||||
|
String previousSummary = extractPreviousSummary(history, lastSummaryIndex);
|
||||||
|
if (previousSummary != null) {
|
||||||
|
summary = "=== Earlier Context ===\n" + previousSummary + "\n\n=== Recent Activity ===\n" + summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新的摘要消息
|
||||||
|
newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary));
|
||||||
|
|
||||||
|
// 添加保留段
|
||||||
|
for (int i = keepStart; i < history.size(); i++) {
|
||||||
|
newHistory.add(history.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
int after = newHistory.size();
|
||||||
|
return new CompactionResult(true, CompactLayer.SESSION_MEMORY, before, after, summary,
|
||||||
|
"Session memory compacted: " + before + " → " + after + " messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取压缩后的新历史。调用方需要先调用 compact() 确认成功,然后调用此方法获取结果。
|
||||||
|
* 为避免重复逻辑,此方法重新执行压缩并返回新历史。
|
||||||
|
*/
|
||||||
|
public List<Message> getCompactedHistory(List<Message> history) {
|
||||||
|
if (history.size() <= MIN_KEEP_TEXT_MSGS + 2) return null;
|
||||||
|
|
||||||
|
Message systemMsg = history.getFirst();
|
||||||
|
int lastSummaryIndex = findLastSummaryIndex(history);
|
||||||
|
int compressibleStart = lastSummaryIndex + 1;
|
||||||
|
int keepStart = findKeepStart(history, compressibleStart);
|
||||||
|
|
||||||
|
if (keepStart - compressibleStart < 4) return null;
|
||||||
|
|
||||||
|
List<Message> toCompress = history.subList(compressibleStart, keepStart);
|
||||||
|
String summary;
|
||||||
|
try {
|
||||||
|
summary = generateSummary(toCompress);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (summary == null || summary.isBlank()) return null;
|
||||||
|
|
||||||
|
List<Message> newHistory = new ArrayList<>();
|
||||||
|
newHistory.add(systemMsg);
|
||||||
|
|
||||||
|
String previousSummary = extractPreviousSummary(history, lastSummaryIndex);
|
||||||
|
if (previousSummary != null) {
|
||||||
|
summary = "=== Earlier Context ===\n" + previousSummary + "\n\n=== Recent Activity ===\n" + summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
newHistory.add(new SystemMessage("[Conversation Summary]\n" + summary));
|
||||||
|
for (int i = keepStart; i < history.size(); i++) {
|
||||||
|
newHistory.add(history.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return newHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部方法 ──
|
||||||
|
|
||||||
|
/** 找到历史中最后一个 [Conversation Summary] 系统消息的索引 */
|
||||||
|
private int findLastSummaryIndex(List<Message> history) {
|
||||||
|
for (int i = history.size() - 1; i >= 1; i--) {
|
||||||
|
if (history.get(i) instanceof SystemMessage sm
|
||||||
|
&& sm.getText() != null
|
||||||
|
&& sm.getText().startsWith("[Conversation Summary]")) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0; // 没有摘要,从系统提示之后开始
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从末尾向前找保留段的起始位置 */
|
||||||
|
private int findKeepStart(List<Message> history, int minStart) {
|
||||||
|
int textMsgCount = 0;
|
||||||
|
long estimatedTokens = 0;
|
||||||
|
|
||||||
|
for (int i = history.size() - 1; i >= minStart; i--) {
|
||||||
|
Message msg = history.get(i);
|
||||||
|
|
||||||
|
// 估算 token 量
|
||||||
|
long msgTokens = estimateTokens(msg);
|
||||||
|
estimatedTokens += msgTokens;
|
||||||
|
|
||||||
|
if (msg instanceof UserMessage || msg instanceof AssistantMessage) {
|
||||||
|
textMsgCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保不会拆分 tool_use / tool_result 对
|
||||||
|
// 如果当前是 ToolResponseMessage,它的 AssistantMessage(含 tool_calls)应在前面
|
||||||
|
if (msg instanceof ToolResponseMessage && i > minStart) {
|
||||||
|
continue; // 继续往前包含对应的 AssistantMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 满足最小保留条件,且已达到上限则停止
|
||||||
|
if (textMsgCount >= MIN_KEEP_TEXT_MSGS && estimatedTokens >= MIN_KEEP_TOKENS) {
|
||||||
|
// 检查是否达到 token 上限
|
||||||
|
if (estimatedTokens >= MAX_KEEP_TOKENS) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果从 minStart 开始全部都在保留范围内,返回 minStart
|
||||||
|
// 说明消息不够多,不需要压缩
|
||||||
|
return minStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 估算消息的 token 数 */
|
||||||
|
private long estimateTokens(Message msg) {
|
||||||
|
String text = switch (msg) {
|
||||||
|
case UserMessage um -> um.getText();
|
||||||
|
case AssistantMessage am -> am.getText();
|
||||||
|
case SystemMessage sm -> sm.getText();
|
||||||
|
case ToolResponseMessage trm -> {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (var resp : trm.getResponses()) {
|
||||||
|
if (resp.responseData() != null) {
|
||||||
|
sb.append(resp.responseData().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield sb.toString();
|
||||||
|
}
|
||||||
|
default -> "";
|
||||||
|
};
|
||||||
|
if (text == null || text.isEmpty()) return 10; // 最小估算
|
||||||
|
return (long) (text.length() / CHARS_PER_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取上一次的摘要文本 */
|
||||||
|
private String extractPreviousSummary(List<Message> history, int summaryIndex) {
|
||||||
|
if (summaryIndex <= 0) return null;
|
||||||
|
Message msg = history.get(summaryIndex);
|
||||||
|
if (msg instanceof SystemMessage sm && sm.getText() != null) {
|
||||||
|
String text = sm.getText();
|
||||||
|
if (text.startsWith("[Conversation Summary]\n")) {
|
||||||
|
return text.substring("[Conversation Summary]\n".length());
|
||||||
|
}
|
||||||
|
if (text.startsWith("[Conversation Summary] ")) {
|
||||||
|
return text.substring("[Conversation Summary] ".length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用 AI 生成对话段摘要 */
|
||||||
|
private String generateSummary(List<Message> segment) {
|
||||||
|
StringBuilder dialogText = new StringBuilder();
|
||||||
|
for (Message msg : segment) {
|
||||||
|
switch (msg) {
|
||||||
|
case UserMessage um -> dialogText.append("[User] ").append(um.getText()).append("\n");
|
||||||
|
case AssistantMessage am -> {
|
||||||
|
if (am.getText() != null && !am.getText().isBlank()) {
|
||||||
|
String text = am.getText();
|
||||||
|
if (text.length() > 800) text = text.substring(0, 800) + "...";
|
||||||
|
dialogText.append("[Assistant] ").append(text).append("\n");
|
||||||
|
}
|
||||||
|
if (am.hasToolCalls()) {
|
||||||
|
for (var tc : am.getToolCalls()) {
|
||||||
|
dialogText.append("[Tool Call] ").append(tc.name()).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ToolResponseMessage trm -> {
|
||||||
|
for (var resp : trm.getResponses()) {
|
||||||
|
String data = resp.responseData() != null ? resp.responseData().toString() : "";
|
||||||
|
if (data.length() > 200) data = data.substring(0, 200) + "...";
|
||||||
|
dialogText.append("[Tool Result: ").append(resp.name()).append("] ")
|
||||||
|
.append(data).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogText.isEmpty()) return null;
|
||||||
|
|
||||||
|
Prompt prompt = new Prompt(List.of(new UserMessage(SUMMARY_PROMPT + dialogText)));
|
||||||
|
ChatResponse response = chatModel.call(prompt);
|
||||||
|
return response.getResult().getOutput().getText();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.claudecode.permission;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 危险命令模式检测 —— 识别可能有害的 shell 命令。
|
||||||
|
* <p>
|
||||||
|
* 即使在 BYPASS 模式下也会对这些命令发出警告。
|
||||||
|
*/
|
||||||
|
public final class DangerousPatterns {
|
||||||
|
|
||||||
|
private DangerousPatterns() {}
|
||||||
|
|
||||||
|
/** 危险 shell 命令前缀(不区分大小写匹配) */
|
||||||
|
private static final List<String> DANGEROUS_BASH_PREFIXES = List.of(
|
||||||
|
"rm -rf /",
|
||||||
|
"rm -rf ~",
|
||||||
|
"rm -rf .",
|
||||||
|
"rm -r /",
|
||||||
|
"rmdir /s",
|
||||||
|
"del /f /s /q",
|
||||||
|
"format ",
|
||||||
|
"mkfs.",
|
||||||
|
"dd if=",
|
||||||
|
"> /dev/sda",
|
||||||
|
"chmod -R 777 /",
|
||||||
|
"chown -R",
|
||||||
|
":(){:|:&};:" // fork bomb
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 危险代码执行模式 */
|
||||||
|
private static final List<String> CODE_EXECUTION_PATTERNS = List.of(
|
||||||
|
"eval ",
|
||||||
|
"exec ",
|
||||||
|
"python -c",
|
||||||
|
"python3 -c",
|
||||||
|
"node -e",
|
||||||
|
"ruby -e",
|
||||||
|
"perl -e",
|
||||||
|
"| sh",
|
||||||
|
"| bash",
|
||||||
|
"| zsh",
|
||||||
|
"| powershell",
|
||||||
|
"| pwsh",
|
||||||
|
"curl | sh",
|
||||||
|
"wget | sh",
|
||||||
|
"Invoke-Expression",
|
||||||
|
"iex ",
|
||||||
|
"Start-Process",
|
||||||
|
"Add-Type"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 在规则匹配中应自动拒绝的工具级通配符 */
|
||||||
|
private static final Set<String> DANGEROUS_TOOL_WILDCARDS = Set.of(
|
||||||
|
"Bash", // 不应允许所有 bash 命令
|
||||||
|
"Bash(*)",
|
||||||
|
"PowerShell",
|
||||||
|
"PowerShell(*)"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测命令是否包含危险模式
|
||||||
|
*
|
||||||
|
* @param command shell 命令文本
|
||||||
|
* @return 如果危险返回原因描述,否则返回 null
|
||||||
|
*/
|
||||||
|
public static String detectDangerous(String command) {
|
||||||
|
if (command == null || command.isBlank()) return null;
|
||||||
|
String lower = command.toLowerCase().trim();
|
||||||
|
|
||||||
|
for (String prefix : DANGEROUS_BASH_PREFIXES) {
|
||||||
|
if (lower.startsWith(prefix.toLowerCase()) || lower.contains(prefix.toLowerCase())) {
|
||||||
|
return "Dangerous command detected: " + prefix.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String pattern : CODE_EXECUTION_PATTERNS) {
|
||||||
|
if (lower.contains(pattern.toLowerCase())) {
|
||||||
|
return "Code execution pattern detected: " + pattern.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否为危险的工具级通配符规则
|
||||||
|
* <p>
|
||||||
|
* 用于防止用户添加过于宽泛的 "always allow" 规则。
|
||||||
|
*/
|
||||||
|
public static boolean isDangerousWildcard(String ruleStr) {
|
||||||
|
return DANGEROUS_TOOL_WILDCARDS.contains(ruleStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取危险原因的简短描述
|
||||||
|
*/
|
||||||
|
public static String getDangerLevel(String command) {
|
||||||
|
String reason = detectDangerous(command);
|
||||||
|
if (reason == null) return "LOW";
|
||||||
|
if (reason.contains("Dangerous command")) return "HIGH";
|
||||||
|
return "MEDIUM";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package com.claudecode.permission;
|
||||||
|
|
||||||
|
import com.claudecode.permission.PermissionTypes.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限规则引擎 —— 根据规则、模式和工具属性做出权限决策。
|
||||||
|
* <p>
|
||||||
|
* 决策流程:
|
||||||
|
* <ol>
|
||||||
|
* <li>检查全局模式(BYPASS → 全部允许,DONT_ASK → 拒绝需确认的)</li>
|
||||||
|
* <li>检查 alwaysDeny 规则 → 匹配则 DENY</li>
|
||||||
|
* <li>检查 alwaysAllow 规则 → 匹配则 ALLOW</li>
|
||||||
|
* <li>只读工具 → ALLOW</li>
|
||||||
|
* <li>ACCEPT_EDITS 模式下文件操作 → ALLOW</li>
|
||||||
|
* <li>检查危险命令 → 强制 ASK</li>
|
||||||
|
* <li>默认 → ASK</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public class PermissionRuleEngine {
|
||||||
|
|
||||||
|
private static final Set<String> FILE_EDIT_TOOLS = Set.of("Write", "Edit", "NotebookEdit");
|
||||||
|
private static final Set<String> READ_ONLY_TOOLS = Set.of(
|
||||||
|
"Read", "Glob", "Grep", "ListFiles", "WebFetch", "WebSearch",
|
||||||
|
"TodoRead", "TaskGet", "TaskList", "AskUserQuestion"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final PermissionSettings settings;
|
||||||
|
|
||||||
|
public PermissionRuleEngine(PermissionSettings settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评估工具调用的权限
|
||||||
|
*
|
||||||
|
* @param toolName 工具名称
|
||||||
|
* @param input 工具参数
|
||||||
|
* @param isReadOnly 工具是否为只读
|
||||||
|
* @return 权限决策
|
||||||
|
*/
|
||||||
|
public PermissionDecision evaluate(String toolName, Map<String, Object> input, boolean isReadOnly) {
|
||||||
|
PermissionMode mode = settings.getCurrentMode();
|
||||||
|
|
||||||
|
// BYPASS 模式:全部允许
|
||||||
|
if (mode == PermissionMode.BYPASS) {
|
||||||
|
return PermissionDecision.allow("Bypass mode enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取命令内容(用于 Bash/PowerShell 的命令匹配)
|
||||||
|
String command = extractCommand(toolName, input);
|
||||||
|
|
||||||
|
// 检查所有持久化规则
|
||||||
|
List<PermissionRule> rules = settings.getAllRules();
|
||||||
|
|
||||||
|
// 1. 检查 alwaysDeny 规则
|
||||||
|
for (var rule : rules) {
|
||||||
|
if (rule.behavior() == PermissionBehavior.DENY && matchesRule(rule, toolName, command)) {
|
||||||
|
return PermissionDecision.deny("Denied by rule: " + PermissionSettings.formatRule(rule));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查 alwaysAllow 规则
|
||||||
|
for (var rule : rules) {
|
||||||
|
if (rule.behavior() == PermissionBehavior.ALLOW && matchesRule(rule, toolName, command)) {
|
||||||
|
return PermissionDecision.allow("Allowed by rule: " + PermissionSettings.formatRule(rule));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 只读工具直接放行
|
||||||
|
if (isReadOnly || READ_ONLY_TOOLS.contains(toolName)) {
|
||||||
|
return PermissionDecision.allow("Read-only tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ACCEPT_EDITS 模式:文件操作工具自动允许
|
||||||
|
if (mode == PermissionMode.ACCEPT_EDITS && FILE_EDIT_TOOLS.contains(toolName)) {
|
||||||
|
return PermissionDecision.allow("File edits auto-allowed in accept-edits mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. DONT_ASK 模式:自动拒绝
|
||||||
|
if (mode == PermissionMode.DONT_ASK) {
|
||||||
|
return PermissionDecision.deny("Auto-denied in dont-ask mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 检查危险命令(强制 ASK,附带警告)
|
||||||
|
if (command != null) {
|
||||||
|
String danger = DangerousPatterns.detectDangerous(command);
|
||||||
|
if (danger != null) {
|
||||||
|
String prefix = extractCommandPrefix(command);
|
||||||
|
return new PermissionDecision(
|
||||||
|
PermissionBehavior.ASK,
|
||||||
|
"⚠ DANGEROUS: " + danger,
|
||||||
|
toolName, prefix, List.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 默认:需要用户确认
|
||||||
|
String prefix = extractCommandPrefix(command);
|
||||||
|
return PermissionDecision.ask(toolName, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户选择应用权限变更
|
||||||
|
*/
|
||||||
|
public void applyChoice(PermissionChoice choice, String toolName, String command) {
|
||||||
|
String prefix = extractCommandPrefix(command);
|
||||||
|
switch (choice) {
|
||||||
|
case ALWAYS_ALLOW -> {
|
||||||
|
var rule = prefix != null
|
||||||
|
? PermissionRule.forCommand(toolName, prefix, PermissionBehavior.ALLOW)
|
||||||
|
: PermissionRule.forTool(toolName, PermissionBehavior.ALLOW);
|
||||||
|
// 检查是否为危险通配符
|
||||||
|
String ruleStr = PermissionSettings.formatRule(rule);
|
||||||
|
if (!DangerousPatterns.isDangerousWildcard(ruleStr)) {
|
||||||
|
settings.addUserRule(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ALWAYS_DENY -> {
|
||||||
|
var rule = prefix != null
|
||||||
|
? PermissionRule.forCommand(toolName, prefix, PermissionBehavior.DENY)
|
||||||
|
: PermissionRule.forTool(toolName, PermissionBehavior.DENY);
|
||||||
|
settings.addUserRule(rule);
|
||||||
|
}
|
||||||
|
case ALLOW_ONCE, DENY_ONCE -> {
|
||||||
|
// 单次操作,不持久化
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部匹配方法 ──
|
||||||
|
|
||||||
|
/** 检查规则是否匹配当前工具和命令 */
|
||||||
|
boolean matchesRule(PermissionRule rule, String toolName, String command) {
|
||||||
|
// 工具名不匹配直接跳过
|
||||||
|
if (!rule.toolName().equalsIgnoreCase(toolName)) return false;
|
||||||
|
|
||||||
|
String content = rule.ruleContent();
|
||||||
|
// 通配符 * 匹配所有命令
|
||||||
|
if ("*".equals(content)) return true;
|
||||||
|
|
||||||
|
// 前缀匹配模式:npm:* 匹配以 "npm" 开头的命令
|
||||||
|
if (content.endsWith(":*") && command != null) {
|
||||||
|
String prefix = content.substring(0, content.length() - 2);
|
||||||
|
return command.toLowerCase().startsWith(prefix.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 精确匹配
|
||||||
|
return content.equalsIgnoreCase(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从工具参数中提取命令文本 */
|
||||||
|
private String extractCommand(String toolName, Map<String, Object> input) {
|
||||||
|
if (input == null) return null;
|
||||||
|
return switch (toolName) {
|
||||||
|
case "Bash" -> (String) input.get("command");
|
||||||
|
case "Write" -> (String) input.get("file_path");
|
||||||
|
case "Edit" -> (String) input.get("file_path");
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取命令前缀(第一个空格前的部分) */
|
||||||
|
private String extractCommandPrefix(String command) {
|
||||||
|
if (command == null || command.isBlank()) return null;
|
||||||
|
String trimmed = command.trim();
|
||||||
|
int space = trimmed.indexOf(' ');
|
||||||
|
return space > 0 ? trimmed.substring(0, space) : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.claudecode.permission;
|
||||||
|
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionBehavior;
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionRule;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限设置持久化 —— 管理用户级和项目级权限规则文件。
|
||||||
|
* <p>
|
||||||
|
* 存储结构:
|
||||||
|
* <ul>
|
||||||
|
* <li>用户级: ~/.claude-code-java/settings.json</li>
|
||||||
|
* <li>项目级: .claude-code-java/settings.json</li>
|
||||||
|
* </ul>
|
||||||
|
* 加载优先级: 项目级 > 用户级 > 会话级
|
||||||
|
*/
|
||||||
|
public class PermissionSettings {
|
||||||
|
|
||||||
|
private static final String SETTINGS_DIR = ".claude-code-java";
|
||||||
|
private static final String SETTINGS_FILE = "settings.json";
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||||
|
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||||
|
|
||||||
|
/** 内存中的合并规则(从所有来源加载后合并) */
|
||||||
|
private final List<PermissionRule> sessionRules = new ArrayList<>();
|
||||||
|
private PermissionTypes.PermissionMode currentMode = PermissionTypes.PermissionMode.DEFAULT;
|
||||||
|
|
||||||
|
private final Path userSettingsPath;
|
||||||
|
private final Path projectSettingsPath;
|
||||||
|
|
||||||
|
private SettingsData userData = new SettingsData();
|
||||||
|
private SettingsData projectData = new SettingsData();
|
||||||
|
|
||||||
|
public PermissionSettings() {
|
||||||
|
this(Path.of(System.getProperty("user.home")),
|
||||||
|
Path.of(System.getProperty("user.dir")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PermissionSettings(Path userHome, Path projectDir) {
|
||||||
|
this.userSettingsPath = userHome.resolve(SETTINGS_DIR).resolve(SETTINGS_FILE);
|
||||||
|
this.projectSettingsPath = projectDir.resolve(SETTINGS_DIR).resolve(SETTINGS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从磁盘加载所有设置 */
|
||||||
|
public void load() {
|
||||||
|
userData = loadFromFile(userSettingsPath);
|
||||||
|
projectData = loadFromFile(projectSettingsPath);
|
||||||
|
// 项目级模式优先
|
||||||
|
if (projectData.permissions.mode != null) {
|
||||||
|
currentMode = projectData.permissions.mode;
|
||||||
|
} else if (userData.permissions.mode != null) {
|
||||||
|
currentMode = userData.permissions.mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有合并后的规则(项目级 > 用户级 > 会话级) */
|
||||||
|
public List<PermissionRule> getAllRules() {
|
||||||
|
var rules = new ArrayList<PermissionRule>();
|
||||||
|
// 项目级优先
|
||||||
|
rules.addAll(toRules(projectData.permissions.alwaysAllow, PermissionBehavior.ALLOW));
|
||||||
|
rules.addAll(toRules(projectData.permissions.alwaysDeny, PermissionBehavior.DENY));
|
||||||
|
// 用户级
|
||||||
|
rules.addAll(toRules(userData.permissions.alwaysAllow, PermissionBehavior.ALLOW));
|
||||||
|
rules.addAll(toRules(userData.permissions.alwaysDeny, PermissionBehavior.DENY));
|
||||||
|
// 会话级
|
||||||
|
rules.addAll(sessionRules);
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加规则并保存到用户级设置 */
|
||||||
|
public void addUserRule(PermissionRule rule) {
|
||||||
|
if (rule.behavior() == PermissionBehavior.ALLOW) {
|
||||||
|
userData.permissions.alwaysAllow.add(formatRule(rule));
|
||||||
|
} else if (rule.behavior() == PermissionBehavior.DENY) {
|
||||||
|
userData.permissions.alwaysDeny.add(formatRule(rule));
|
||||||
|
}
|
||||||
|
saveToFile(userSettingsPath, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加规则到会话级(不持久化) */
|
||||||
|
public void addSessionRule(PermissionRule rule) {
|
||||||
|
sessionRules.add(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除用户级规则 */
|
||||||
|
public void removeUserRule(String ruleStr) {
|
||||||
|
userData.permissions.alwaysAllow.remove(ruleStr);
|
||||||
|
userData.permissions.alwaysDeny.remove(ruleStr);
|
||||||
|
saveToFile(userSettingsPath, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除所有规则 */
|
||||||
|
public void clearAll() {
|
||||||
|
userData.permissions.alwaysAllow.clear();
|
||||||
|
userData.permissions.alwaysDeny.clear();
|
||||||
|
projectData.permissions.alwaysAllow.clear();
|
||||||
|
projectData.permissions.alwaysDeny.clear();
|
||||||
|
sessionRules.clear();
|
||||||
|
saveToFile(userSettingsPath, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PermissionTypes.PermissionMode getCurrentMode() {
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentMode(PermissionTypes.PermissionMode mode) {
|
||||||
|
this.currentMode = mode;
|
||||||
|
userData.permissions.mode = mode;
|
||||||
|
saveToFile(userSettingsPath, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有已保存规则的可读列表 */
|
||||||
|
public List<String> listRules() {
|
||||||
|
var result = new ArrayList<String>();
|
||||||
|
for (var r : userData.permissions.alwaysAllow) {
|
||||||
|
result.add("[user] ALLOW " + r);
|
||||||
|
}
|
||||||
|
for (var r : userData.permissions.alwaysDeny) {
|
||||||
|
result.add("[user] DENY " + r);
|
||||||
|
}
|
||||||
|
for (var r : projectData.permissions.alwaysAllow) {
|
||||||
|
result.add("[proj] ALLOW " + r);
|
||||||
|
}
|
||||||
|
for (var r : projectData.permissions.alwaysDeny) {
|
||||||
|
result.add("[proj] DENY " + r);
|
||||||
|
}
|
||||||
|
for (var r : sessionRules) {
|
||||||
|
result.add("[sess] " + r.behavior() + " " + formatRule(r));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部方法 ──
|
||||||
|
|
||||||
|
private SettingsData loadFromFile(Path path) {
|
||||||
|
if (!Files.exists(path)) return new SettingsData();
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(path.toFile(), SettingsData.class);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return new SettingsData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveToFile(Path path, SettingsData data) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
MAPPER.writeValue(path.toFile(), data);
|
||||||
|
} catch (IOException e) {
|
||||||
|
// 静默失败,不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PermissionRule> toRules(List<String> ruleStrings, PermissionBehavior behavior) {
|
||||||
|
return ruleStrings.stream()
|
||||||
|
.map(s -> parseRule(s, behavior))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析规则字符串,格式: "ToolName(pattern)" 或 "ToolName" */
|
||||||
|
static PermissionRule parseRule(String ruleStr, PermissionBehavior behavior) {
|
||||||
|
int parenStart = ruleStr.indexOf('(');
|
||||||
|
if (parenStart > 0 && ruleStr.endsWith(")")) {
|
||||||
|
String toolName = ruleStr.substring(0, parenStart);
|
||||||
|
String content = ruleStr.substring(parenStart + 1, ruleStr.length() - 1);
|
||||||
|
return new PermissionRule(toolName, content, behavior);
|
||||||
|
}
|
||||||
|
return PermissionRule.forTool(ruleStr, behavior);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化规则为字符串 */
|
||||||
|
static String formatRule(PermissionRule rule) {
|
||||||
|
if ("*".equals(rule.ruleContent())) {
|
||||||
|
return rule.toolName();
|
||||||
|
}
|
||||||
|
return rule.toolName() + "(" + rule.ruleContent() + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON 数据结构 ──
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
static class SettingsData {
|
||||||
|
public PermissionsBlock permissions = new PermissionsBlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
static class PermissionsBlock {
|
||||||
|
public PermissionTypes.PermissionMode mode;
|
||||||
|
public List<String> alwaysAllow = new ArrayList<>();
|
||||||
|
public List<String> alwaysDeny = new ArrayList<>();
|
||||||
|
public List<String> additionalDirectories = new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.claudecode.permission;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限管理类型定义 —— 对应 claude-code 中的 permissions.ts。
|
||||||
|
*/
|
||||||
|
public final class PermissionTypes {
|
||||||
|
|
||||||
|
private PermissionTypes() {}
|
||||||
|
|
||||||
|
/** 权限行为 */
|
||||||
|
public enum PermissionBehavior {
|
||||||
|
ALLOW, // 允许执行
|
||||||
|
DENY, // 拒绝执行
|
||||||
|
ASK // 需要用户确认
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限模式 */
|
||||||
|
public enum PermissionMode {
|
||||||
|
/** 默认模式:非只读工具需要用户确认 */
|
||||||
|
DEFAULT,
|
||||||
|
/** 自动允许文件编辑,shell 命令仍需确认 */
|
||||||
|
ACCEPT_EDITS,
|
||||||
|
/** 跳过所有权限检查(不安全) */
|
||||||
|
BYPASS,
|
||||||
|
/** 自动拒绝而非询问用户(无头模式) */
|
||||||
|
DONT_ASK
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限规则 —— 定义工具和命令模式的权限行为。
|
||||||
|
* <p>
|
||||||
|
* 示例:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code PermissionRule("Bash", "npm:*", ALLOW)} — 允许所有 npm 命令</li>
|
||||||
|
* <li>{@code PermissionRule("Bash", "rm -rf:*", DENY)} — 拒绝 rm -rf</li>
|
||||||
|
* <li>{@code PermissionRule("Write", "*", ALLOW)} — 允许所有文件写入</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param toolName 工具名称(如 Bash, Write, Edit)
|
||||||
|
* @param ruleContent 规则内容,支持通配符 *(如 "npm:*", "git:*", "*")
|
||||||
|
* @param behavior 权限行为
|
||||||
|
*/
|
||||||
|
public record PermissionRule(
|
||||||
|
String toolName,
|
||||||
|
String ruleContent,
|
||||||
|
PermissionBehavior behavior
|
||||||
|
) {
|
||||||
|
/** 匹配整个工具(无命令模式限制) */
|
||||||
|
public static PermissionRule forTool(String toolName, PermissionBehavior behavior) {
|
||||||
|
return new PermissionRule(toolName, "*", behavior);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 匹配工具的特定命令前缀 */
|
||||||
|
public static PermissionRule forCommand(String toolName, String prefix, PermissionBehavior behavior) {
|
||||||
|
return new PermissionRule(toolName, prefix + ":*", behavior);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限决策结果 */
|
||||||
|
public record PermissionDecision(
|
||||||
|
PermissionBehavior behavior,
|
||||||
|
String reason,
|
||||||
|
String toolName,
|
||||||
|
String commandPrefix,
|
||||||
|
List<PermissionRule> suggestedRules
|
||||||
|
) {
|
||||||
|
public static PermissionDecision allow(String reason) {
|
||||||
|
return new PermissionDecision(PermissionBehavior.ALLOW, reason, null, null, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PermissionDecision deny(String reason) {
|
||||||
|
return new PermissionDecision(PermissionBehavior.DENY, reason, null, null, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PermissionDecision ask(String toolName, String commandPrefix) {
|
||||||
|
// 生成建议规则供用户选择 "always allow"
|
||||||
|
var suggested = List.of(
|
||||||
|
PermissionRule.forCommand(toolName, commandPrefix, PermissionBehavior.ALLOW)
|
||||||
|
);
|
||||||
|
return new PermissionDecision(PermissionBehavior.ASK, "Requires user confirmation",
|
||||||
|
toolName, commandPrefix, suggested);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllowed() {
|
||||||
|
return behavior == PermissionBehavior.ALLOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDenied() {
|
||||||
|
return behavior == PermissionBehavior.DENY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean needsAsk() {
|
||||||
|
return behavior == PermissionBehavior.ASK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限确认选项(用户在 UI 中的选择) */
|
||||||
|
public enum PermissionChoice {
|
||||||
|
/** 允许本次执行 */
|
||||||
|
ALLOW_ONCE,
|
||||||
|
/** 始终允许此模式 */
|
||||||
|
ALWAYS_ALLOW,
|
||||||
|
/** 拒绝本次执行 */
|
||||||
|
DENY_ONCE,
|
||||||
|
/** 始终拒绝此模式 */
|
||||||
|
ALWAYS_DENY
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import com.claudecode.command.CommandRegistry;
|
|||||||
import com.claudecode.console.*;
|
import com.claudecode.console.*;
|
||||||
import com.claudecode.core.AgentLoop;
|
import com.claudecode.core.AgentLoop;
|
||||||
import com.claudecode.core.ConversationPersistence;
|
import com.claudecode.core.ConversationPersistence;
|
||||||
|
import com.claudecode.permission.DangerousPatterns;
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionChoice;
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionDecision;
|
||||||
import com.claudecode.tool.ToolRegistry;
|
import com.claudecode.tool.ToolRegistry;
|
||||||
import com.claudecode.tool.impl.AskUserQuestionTool;
|
import com.claudecode.tool.impl.AskUserQuestionTool;
|
||||||
import org.jline.reader.*;
|
import org.jline.reader.*;
|
||||||
@@ -430,11 +433,21 @@ public class ReplSession {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示权限确认提示并等待用户输入。
|
* 显示权限确认提示并等待用户输入。
|
||||||
* 用于危险操作(文件写入、bash 执行等)前的安全确认。
|
* 支持 4 种选项:Y(允许一次) / A(始终允许) / N(拒绝) / D(始终拒绝)。
|
||||||
*/
|
*/
|
||||||
private boolean promptPermission(AgentLoop.PermissionRequest request) {
|
private PermissionChoice promptPermission(AgentLoop.PermissionRequest request) {
|
||||||
out.println();
|
out.println();
|
||||||
|
|
||||||
|
// 检查是否为危险命令
|
||||||
|
PermissionDecision decision = request.decision();
|
||||||
|
boolean isDangerous = (decision != null && decision.reason() != null
|
||||||
|
&& decision.reason().startsWith("⚠ DANGEROUS"));
|
||||||
|
|
||||||
|
if (isDangerous) {
|
||||||
|
out.println(AnsiStyle.red(" ⚠ DANGEROUS Operation"));
|
||||||
|
} else {
|
||||||
out.println(AnsiStyle.yellow(" ⚠ Permission Required"));
|
out.println(AnsiStyle.yellow(" ⚠ Permission Required"));
|
||||||
|
}
|
||||||
out.println(" " + "─".repeat(50));
|
out.println(" " + "─".repeat(50));
|
||||||
out.println(" " + AnsiStyle.bold("Tool: ") + AnsiStyle.cyan(request.toolName()));
|
out.println(" " + AnsiStyle.bold("Tool: ") + AnsiStyle.cyan(request.toolName()));
|
||||||
out.println(" " + AnsiStyle.bold("Action: ") + request.activityDescription());
|
out.println(" " + AnsiStyle.bold("Action: ") + request.activityDescription());
|
||||||
@@ -448,30 +461,46 @@ public class ReplSession {
|
|||||||
out.println(" " + AnsiStyle.dim("Args: " + argsPreview));
|
out.println(" " + AnsiStyle.dim("Args: " + argsPreview));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示建议规则
|
||||||
|
String suggestedRule = null;
|
||||||
|
if (decision != null && decision.commandPrefix() != null) {
|
||||||
|
suggestedRule = request.toolName() + "(" + decision.commandPrefix() + ":*)";
|
||||||
|
}
|
||||||
|
|
||||||
out.println(" " + "─".repeat(50));
|
out.println(" " + "─".repeat(50));
|
||||||
out.print(" " + AnsiStyle.bold("Allow execution?") + AnsiStyle.dim(" [Y/n/always] ") + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "→ " + AnsiStyle.RESET);
|
out.println(" " + AnsiStyle.green("[Y]") + " Allow once");
|
||||||
|
if (suggestedRule != null && !isDangerous) {
|
||||||
|
out.println(" " + AnsiStyle.green("[A]") + " Always allow " + AnsiStyle.cyan(suggestedRule));
|
||||||
|
}
|
||||||
|
out.println(" " + AnsiStyle.red("[N]") + " Deny");
|
||||||
|
if (suggestedRule != null) {
|
||||||
|
out.println(" " + AnsiStyle.red("[D]") + " Always deny this pattern");
|
||||||
|
}
|
||||||
|
out.print(" " + AnsiStyle.bold("Choice") + AnsiStyle.dim(" [Y/a/n/d] ") + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "→ " + AnsiStyle.RESET);
|
||||||
out.flush();
|
out.flush();
|
||||||
|
|
||||||
String answer = readLineForPermission();
|
String answer = readLineForPermission();
|
||||||
if (answer == null) return false;
|
if (answer == null) return PermissionChoice.DENY_ONCE;
|
||||||
|
|
||||||
answer = answer.strip().toLowerCase();
|
answer = answer.strip().toLowerCase();
|
||||||
|
|
||||||
// "always" → 禁用后续权限确认
|
return switch (answer) {
|
||||||
if (answer.equals("always") || answer.equals("a")) {
|
case "a", "always" -> {
|
||||||
agentLoop.setOnPermissionRequest(null); // 移除权限回调
|
out.println(AnsiStyle.green(" ✓ Rule saved: always allow " +
|
||||||
out.println(AnsiStyle.green(" ✓ All subsequent operations authorized"));
|
(suggestedRule != null ? suggestedRule : request.toolName())));
|
||||||
return true;
|
yield PermissionChoice.ALWAYS_ALLOW;
|
||||||
}
|
}
|
||||||
|
case "d" -> {
|
||||||
// 空字符串或 y/yes → 允许
|
out.println(AnsiStyle.red(" ✗ Rule saved: always deny " +
|
||||||
if (answer.isEmpty() || answer.equals("y") || answer.equals("yes")) {
|
(suggestedRule != null ? suggestedRule : request.toolName())));
|
||||||
return true;
|
yield PermissionChoice.ALWAYS_DENY;
|
||||||
}
|
}
|
||||||
|
case "n", "no" -> {
|
||||||
// 其他输入 → 拒绝
|
|
||||||
out.println(AnsiStyle.red(" ✗ Operation denied"));
|
out.println(AnsiStyle.red(" ✗ Operation denied"));
|
||||||
return false;
|
yield PermissionChoice.DENY_ONCE;
|
||||||
|
}
|
||||||
|
default -> PermissionChoice.ALLOW_ONCE; // 空字符串、y、yes → 允许
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取权限确认的用户输入(兼容 JLine 和 Scanner 模式) */
|
/** 读取权限确认的用户输入(兼容 JLine 和 Scanner 模式) */
|
||||||
|
|||||||
Reference in New Issue
Block a user