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