feat: 压缩和权限gap功能增强 - PTL gap解析, 时间感知微压缩, token估算安全系数, PLAN模式, 拒绝追踪

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
liuzh 1 month ago
parent 730551cb3f
commit fa565d8c07
  1. 3
      src/main/java/com/claudecode/command/impl/ConfigCommand.java
  2. 16
      src/main/java/com/claudecode/core/AgentLoop.java
  3. 43
      src/main/java/com/claudecode/core/compact/FullCompact.java
  4. 29
      src/main/java/com/claudecode/core/compact/MicroCompact.java
  5. 5
      src/main/java/com/claudecode/core/compact/SessionMemoryCompact.java
  6. 63
      src/main/java/com/claudecode/permission/DenialTracker.java
  7. 8
      src/main/java/com/claudecode/permission/PermissionRuleEngine.java
  8. 4
      src/main/java/com/claudecode/permission/PermissionTypes.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");
}
}

@ -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<Message> 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) {
// 传统回调模式(向后兼容)

@ -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<Message> messages) {}
/**
* 尝试从 PTL 错误中解析 token gap计算需要丢弃的 round
* API 错误消息格式类似: "prompt is too long: 250000 tokens > 200000 token limit"
* 返回建议丢弃的 round 如果无法解析返回 0
*/
private int parsePtlGap(Exception e, List<ApiRound> 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;
}
}
}

@ -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 内容
* <p>
* 对应 claude-code microCompact不需要额外 API 调用纯本地操作
* 策略保留最近 N 轮的 tool 结果更早的只保留摘要行 "[Tool result truncated]"
* 策略
* <ul>
* <li>保留最近 N 轮的 tool 结果更早的只保留摘要行</li>
* <li>时间感知空闲超过 gapThresholdMinutes 后主动清理</li>
* </ul>
*/
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)) {

@ -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);
}
/** 提取上一次的摘要文本 */

@ -0,0 +1,63 @@
package com.claudecode.permission;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 拒绝追踪器 跟踪连续和总计的权限拒绝次数
* <p>
* 对应 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;
}
}

@ -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);

@ -25,7 +25,9 @@ public final class PermissionTypes {
/** 跳过所有权限检查(不安全) */
BYPASS,
/** 自动拒绝而非询问用户(无头模式) */
DONT_ASK
DONT_ASK,
/** 计划模式:仅分析不执行(拒绝所有非只读工具) */
PLAN
}
/**

Loading…
Cancel
Save