feat: 压缩和权限gap功能增强 - PTL gap解析, 时间感知微压缩, token估算安全系数, PLAN模式, 拒绝追踪
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/** 提取上一次的摘要文本 */
|
||||
|
||||
Reference in New Issue
Block a user