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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
liuzh
2026-04-02 23:17:29 +08:00
co-authored by Copilot
parent 730551cb3f
commit fa565d8c07
8 changed files with 165 additions and 6 deletions
@@ -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);
}
/** 提取上一次的摘要文本 */