diff --git a/src/main/java/com/claudecode/command/impl/ConfigCommand.java b/src/main/java/com/claudecode/command/impl/ConfigCommand.java index 88e4003..d3b1c52 100644 --- a/src/main/java/com/claudecode/command/impl/ConfigCommand.java +++ b/src/main/java/com/claudecode/command/impl/ConfigCommand.java @@ -158,13 +158,14 @@ public class ConfigCommand implements SlashCommand { case "accept-edits", "acceptedits" -> PermissionMode.ACCEPT_EDITS; case "bypass" -> PermissionMode.BYPASS; case "dont-ask", "dontask" -> PermissionMode.DONT_ASK; + case "plan" -> PermissionMode.PLAN; 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"); + + AnsiStyle.dim(" Available: default, accept-edits, bypass, dont-ask, plan"); } } diff --git a/src/main/java/com/claudecode/core/AgentLoop.java b/src/main/java/com/claudecode/core/AgentLoop.java index f841222..14f0698 100644 --- a/src/main/java/com/claudecode/core/AgentLoop.java +++ b/src/main/java/com/claudecode/core/AgentLoop.java @@ -1,6 +1,7 @@ package com.claudecode.core; import com.claudecode.core.compact.AutoCompactManager; +import com.claudecode.permission.DenialTracker; import com.claudecode.permission.PermissionRuleEngine; import com.claudecode.permission.PermissionTypes.PermissionChoice; import com.claudecode.permission.PermissionTypes.PermissionDecision; @@ -62,6 +63,9 @@ public class AgentLoop { /** 自动压缩管理器(可选) */ private AutoCompactManager autoCompactManager; + /** 拒绝追踪器 */ + private final DenialTracker denialTracker = new DenialTracker(); + /** 消息历史 —— 自行管理,不依赖 Spring AI ChatMemory */ private final List messageHistory = new ArrayList<>(); @@ -339,20 +343,32 @@ public class AgentLoop { toolName, parsedArgs, adapter.getTool().isReadOnly()); if (decision.isAllowed()) { permitted = true; + denialTracker.recordSuccess(); } else if (decision.isDenied()) { permitted = false; + denialTracker.recordDenial(); log.info("[{}] Denied by rule: {}", toolName, decision.reason()); } else if (decision.needsAsk() && onPermissionRequest != null) { + // 拒绝追踪:连续拒绝过多时强制回退到手动提示 + if (denialTracker.shouldFallbackToPrompting()) { + log.info("[{}] Denial threshold reached, forcing manual prompt", toolName); + } String activity = adapter.getTool().activityDescription(parsedArgs); PermissionRequest req = new PermissionRequest(toolName, toolArgs, activity); req.setDecision(decision); PermissionChoice choice = onPermissionRequest.apply(req); permitted = (choice == PermissionChoice.ALLOW_ONCE || choice == PermissionChoice.ALWAYS_ALLOW); + if (permitted) { + denialTracker.recordSuccess(); + } else { + denialTracker.recordDenial(); + } // 持久化用户选择 String command = parsedArgs != null ? (String) parsedArgs.get("command") : null; permissionEngine.applyChoice(choice, toolName, command); } else { permitted = false; + denialTracker.recordDenial(); } } else if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) { // 传统回调模式(向后兼容) diff --git a/src/main/java/com/claudecode/core/compact/FullCompact.java b/src/main/java/com/claudecode/core/compact/FullCompact.java index 4799aad..746fbcd 100644 --- a/src/main/java/com/claudecode/core/compact/FullCompact.java +++ b/src/main/java/com/claudecode/core/compact/FullCompact.java @@ -87,7 +87,13 @@ public class FullCompact { } } catch (Exception e) { log.warn("Full compact attempt failed (drop={}): {}", dropCount, e.getMessage()); - // PTL error — drop oldest round and retry + // 尝试解析 PTL gap 以计算需要丢弃的 round 数 + int gapDrop = parsePtlGap(e, remaining); + if (gapDrop > 1) { + dropCount += gapDrop; + log.info("PTL gap parsed: dropping {} additional rounds", gapDrop); + continue; + } } dropCount++; @@ -172,4 +178,39 @@ public class FullCompact { /** API Round:一个用户请求 + AI 响应 + 工具调用的完整回合 */ private record ApiRound(List messages) {} + + /** + * 尝试从 PTL 错误中解析 token gap,计算需要丢弃的 round 数。 + * API 错误消息格式类似: "prompt is too long: 250000 tokens > 200000 token limit" + * 返回建议丢弃的 round 数,如果无法解析返回 0。 + */ + private int parsePtlGap(Exception e, List rounds) { + String msg = e.getMessage(); + if (msg == null) return 0; + + // 尝试从错误消息中提取 token 数字 + // 格式: "NNN tokens > NNN token limit" 或类似变体 + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("(\\d+)\\s*tokens?\\s*>\\s*(\\d+)") + .matcher(msg); + if (!m.find()) return 0; + + try { + long actual = Long.parseLong(m.group(1)); + long limit = Long.parseLong(m.group(2)); + long gap = actual - limit; + if (gap <= 0) return 0; + + // 估算每个 round 的 token 数(粗略平均) + long avgTokensPerRound = actual / Math.max(rounds.size(), 1); + if (avgTokensPerRound <= 0) return 0; + + int roundsToDrop = (int) Math.ceil((double) gap / avgTokensPerRound); + // 保守:丢弃 ~20% 的 round(与 TS 一致的回退策略) + int fallbackDrop = Math.max(1, (int) Math.floor(rounds.size() * 0.2)); + return Math.min(roundsToDrop, fallbackDrop); + } catch (NumberFormatException ex) { + return 0; + } + } } diff --git a/src/main/java/com/claudecode/core/compact/MicroCompact.java b/src/main/java/com/claudecode/core/compact/MicroCompact.java index 56818c5..72ce459 100644 --- a/src/main/java/com/claudecode/core/compact/MicroCompact.java +++ b/src/main/java/com/claudecode/core/compact/MicroCompact.java @@ -3,13 +3,18 @@ package com.claudecode.core.compact; import com.claudecode.core.compact.CompactionResult.CompactLayer; import org.springframework.ai.chat.messages.*; +import java.time.Instant; import java.util.List; /** * 微压缩 —— 在每次 API 调用后执行,裁剪旧的 tool_result 内容。 *

* 对应 claude-code 的 microCompact。不需要额外 API 调用,纯本地操作。 - * 策略:保留最近 N 轮的 tool 结果,更早的只保留摘要行 "[Tool result truncated]"。 + * 策略: + *

    + *
  • 保留最近 N 轮的 tool 结果,更早的只保留摘要行
  • + *
  • 时间感知:空闲超过 gapThresholdMinutes 后主动清理
  • + *
*/ public class MicroCompact { @@ -22,6 +27,20 @@ public class MicroCompact { /** 截断后的占位文本 */ private static final String TRUNCATED_MARKER = "[Tool result truncated — %d chars omitted]"; + /** 时间感知:空闲超过此分钟数后减少保留数量 */ + private static final int GAP_THRESHOLD_MINUTES = 10; + + /** 空闲时保留的 tool result 数量(更激进的清理) */ + private static final int KEEP_RECENT_AFTER_GAP = 2; + + /** 上次活跃时间 */ + private Instant lastActivityTime = Instant.now(); + + /** 更新活跃时间(每次 API 调用后调用) */ + public void recordActivity() { + lastActivityTime = Instant.now(); + } + /** * 对消息历史执行微压缩。 * 直接在原始列表上原地修改以提升性能。 @@ -33,13 +52,19 @@ public class MicroCompact { int totalToolResponses = 0; int truncated = 0; + // 时间感知:空闲超时后使用更激进的保留策略 + long minutesSinceLastActivity = java.time.Duration.between(lastActivityTime, Instant.now()).toMinutes(); + int keepRecent = minutesSinceLastActivity >= GAP_THRESHOLD_MINUTES + ? KEEP_RECENT_AFTER_GAP + : KEEP_RECENT_TOOL_RESULTS; + // 倒序扫描,找到所有 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) { + if (recentCount > keepRecent) { // 需要截断 ToolResponseMessage trm = (ToolResponseMessage) history.get(i); if (shouldTruncate(trm)) { diff --git a/src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java b/src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java index f7673d2..85a41de 100644 --- a/src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java +++ b/src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java @@ -39,6 +39,9 @@ public class SessionMemoryCompact { /** 每字符估算的 token 数(粗略近似) */ private static final double CHARS_PER_TOKEN = 4.0; + /** token 估算安全系数(偏保守,对应 TS 的 4/3 乘数) */ + private static final double ESTIMATION_SAFETY_FACTOR = 4.0 / 3.0; + private static final String SUMMARY_PROMPT = """ Summarize the following conversation segment concisely but thoroughly. Preserve: @@ -239,7 +242,7 @@ public class SessionMemoryCompact { default -> ""; }; if (text == null || text.isEmpty()) return 10; // 最小估算 - return (long) (text.length() / CHARS_PER_TOKEN); + return (long) (text.length() / CHARS_PER_TOKEN * ESTIMATION_SAFETY_FACTOR); } /** 提取上一次的摘要文本 */ diff --git a/src/main/java/com/claudecode/permission/DenialTracker.java b/src/main/java/com/claudecode/permission/DenialTracker.java new file mode 100644 index 0000000..c10c727 --- /dev/null +++ b/src/main/java/com/claudecode/permission/DenialTracker.java @@ -0,0 +1,63 @@ +package com.claudecode.permission; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 拒绝追踪器 —— 跟踪连续和总计的权限拒绝次数。 + *

+ * 对应 claude-code 的 denialTracking.ts。 + * 当连续拒绝达到阈值(3 次)或总计拒绝达到阈值(20 次)时, + * 建议回退到手动提示模式,避免 auto/plan 模式下的无限拒绝循环。 + */ +public class DenialTracker { + + private static final Logger log = LoggerFactory.getLogger(DenialTracker.class); + + /** 连续拒绝阈值 —— 超过后建议回退 */ + public static final int MAX_CONSECUTIVE_DENIALS = 3; + + /** 总计拒绝阈值 —— 超过后建议回退 */ + public static final int MAX_TOTAL_DENIALS = 20; + + private int consecutiveDenials = 0; + private int totalDenials = 0; + + /** 记录一次拒绝 */ + public void recordDenial() { + consecutiveDenials++; + totalDenials++; + if (shouldFallbackToPrompting()) { + log.warn("Denial threshold reached: {} consecutive, {} total — consider switching to manual mode", + consecutiveDenials, totalDenials); + } + } + + /** 记录一次成功(重置连续计数,但不重置总计) */ + public void recordSuccess() { + consecutiveDenials = 0; + } + + /** + * 是否应回退到手动提示模式。 + * 当连续拒绝 >= 3 或总计拒绝 >= 20 时返回 true。 + */ + public boolean shouldFallbackToPrompting() { + return consecutiveDenials >= MAX_CONSECUTIVE_DENIALS + || totalDenials >= MAX_TOTAL_DENIALS; + } + + /** 完全重置计数器 */ + public void reset() { + consecutiveDenials = 0; + totalDenials = 0; + } + + public int getConsecutiveDenials() { + return consecutiveDenials; + } + + public int getTotalDenials() { + return totalDenials; + } +} diff --git a/src/main/java/com/claudecode/permission/PermissionRuleEngine.java b/src/main/java/com/claudecode/permission/PermissionRuleEngine.java index 832771d..8554c27 100644 --- a/src/main/java/com/claudecode/permission/PermissionRuleEngine.java +++ b/src/main/java/com/claudecode/permission/PermissionRuleEngine.java @@ -50,6 +50,14 @@ public class PermissionRuleEngine { return PermissionDecision.allow("Bypass mode enabled"); } + // PLAN 模式:仅允许只读工具 + if (mode == PermissionMode.PLAN) { + if (isReadOnly || READ_ONLY_TOOLS.contains(toolName)) { + return PermissionDecision.allow("Read-only tool allowed in plan mode"); + } + return PermissionDecision.deny("Plan mode: execution disabled (analysis only)"); + } + // 获取命令内容(用于 Bash/PowerShell 的命令匹配) String command = extractCommand(toolName, input); diff --git a/src/main/java/com/claudecode/permission/PermissionTypes.java b/src/main/java/com/claudecode/permission/PermissionTypes.java index 6fe472c..67acbe9 100644 --- a/src/main/java/com/claudecode/permission/PermissionTypes.java +++ b/src/main/java/com/claudecode/permission/PermissionTypes.java @@ -25,7 +25,9 @@ public final class PermissionTypes { /** 跳过所有权限检查(不安全) */ BYPASS, /** 自动拒绝而非询问用户(无头模式) */ - DONT_ASK + DONT_ASK, + /** 计划模式:仅分析不执行(拒绝所有非只读工具) */ + PLAN } /**