mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
refactor(pi-compaction): 改为压力触发的观察遮蔽与空闲阈值整段摘要
原实现对所有工具结果(含 read)在下一次请求即替换为约 4K 的首尾存根, 不看上下文压力,导致模型看不到大文件中段;后台逐条语义摘要又反复改写 历史、打碎 prompt 缓存。参照 Anthropic 上下文编辑、Manus 与 JetBrains 观察遮蔽研究重构为: - L2:上下文达到阈值时在 turn_end 批量提交 Pi 原生 context_edit,把最近 keepRecentTurns 轮之外的旧 toolResult 换成确定性占位符;trigger/target 回差与 minClearTokens 保证少触发、一次清够;原始条目保留,可经 context_tree_query 取回 - L3:空闲 agent_settled 且超过绝对阈值时调用一次 ctx.compact - 阈值按绝对 token 计并受窗口比例约束,默认针对 1M 窗口(DeepSeek V4.1 Flash):遮蔽 200K→100K、整段摘要 400K - 删除逐条存根、语义 worker、soft/hard/target 比例与自行取消 Pi 压缩的门控; Pi 原生阈值压缩保留为最终安全网 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
a254d4fd68
commit
a6d8416c76
@@ -1,16 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { estimateTokens } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
COMPACTION_CUSTOM_TYPE,
|
||||
COMPACTION_VERSION,
|
||||
type PiCompactionConfig,
|
||||
type ProjectionSnapshot,
|
||||
type SegmentSummaryRecord,
|
||||
type ToolHistoryRecord,
|
||||
type UsageBaseline,
|
||||
} from "./types.js";
|
||||
import type { ProjectionSnapshot, UsageBaseline } from "./types.js";
|
||||
|
||||
function timestampOf(value: unknown): number {
|
||||
export function timestampOf(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value);
|
||||
@@ -19,10 +11,6 @@ function timestampOf(value: unknown): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sourceKey(toolCallId: string, timestamp: unknown): string {
|
||||
return `${toolCallId}@${timestampOf(timestamp)}`;
|
||||
}
|
||||
|
||||
export function contentText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
@@ -37,42 +25,6 @@ function hash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 20);
|
||||
}
|
||||
|
||||
function json(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
function boundedEvidence(text: string, maxChars: number): string {
|
||||
const normalized = text.replace(/\u001b\[[0-9;]*m/g, "").trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
const marker = `\n… ${normalized.length - maxChars} chars omitted …\n`;
|
||||
const available = Math.max(0, maxChars - marker.length);
|
||||
const head = Math.ceil(available * 0.7);
|
||||
const tail = Math.max(0, available - head);
|
||||
return `${normalized.slice(0, head)}${marker}${tail ? normalized.slice(-tail) : ""}`;
|
||||
}
|
||||
|
||||
export function deterministicSummary(params: {
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
rawText: string;
|
||||
isError: boolean;
|
||||
maxStubTokens: number;
|
||||
}): string {
|
||||
const maxChars = params.maxStubTokens * 4;
|
||||
const argsText = boundedEvidence(json(params.args), Math.min(600, Math.floor(maxChars * 0.25)));
|
||||
const evidenceBudget = Math.max(128, maxChars - argsText.length - 120);
|
||||
const evidence = boundedEvidence(params.rawText || "(no output)", evidenceBudget);
|
||||
return [
|
||||
`[pi-compaction tool=${params.toolName} status=${params.isError ? "error" : "ok"}]`,
|
||||
`Args: ${argsText}`,
|
||||
`Evidence:\n${evidence}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function messageKey(message: any): string {
|
||||
const role = String(message?.role ?? "unknown");
|
||||
const timestamp = timestampOf(message?.timestamp);
|
||||
@@ -85,24 +37,14 @@ function messageKey(message: any): string {
|
||||
return `${role}:${timestamp}:${typeof message?.content === "string" ? message.content.length : (message?.content?.length ?? 0)}`;
|
||||
}
|
||||
|
||||
function isSummaryRecord(value: unknown): value is SegmentSummaryRecord {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Partial<SegmentSummaryRecord>;
|
||||
return record.version === COMPACTION_VERSION
|
||||
&& record.kind === "segment-summary"
|
||||
&& typeof record.sourceKey === "string"
|
||||
&& typeof record.toolCallId === "string"
|
||||
&& typeof record.resultTimestamp === "number"
|
||||
&& typeof record.toolName === "string"
|
||||
&& typeof record.sourceHash === "string"
|
||||
&& typeof record.deterministicSummary === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental context projection: tracks the branch's message list, estimates
|
||||
* tokens, and maintains the usage-backed effective-token baseline. Projection
|
||||
* is identity — pi-compaction never rewrites messages on this path; context
|
||||
* pressure reduction happens through append-only context_edit entries.
|
||||
*/
|
||||
export class SegmentIndex {
|
||||
private readonly records = new Map<string, ToolHistoryRecord>();
|
||||
private readonly ids = new Map<string, string[]>();
|
||||
private generation = 0;
|
||||
private semanticJobs = 0;
|
||||
private usageBaseline?: UsageBaseline;
|
||||
private sourceMessages: any[] = [];
|
||||
private sourceKeys: string[] = [];
|
||||
@@ -117,67 +59,13 @@ export class SegmentIndex {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
hydrate(branch: readonly any[], config: PiCompactionConfig): void {
|
||||
hydrate(): void {
|
||||
this.reset();
|
||||
if (!config.enabled) return;
|
||||
const summaries = new Map<string, SegmentSummaryRecord>();
|
||||
for (const entry of branch) {
|
||||
if (entry?.type === "custom" && entry.customType === COMPACTION_CUSTOM_TYPE && isSummaryRecord(entry.data)) {
|
||||
summaries.set(entry.data.sourceKey, entry.data);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCalls = new Map<string, { toolName: string; args: unknown }>();
|
||||
for (const entry of branch) {
|
||||
if (entry?.type !== "message") continue;
|
||||
const message = entry.message;
|
||||
if (message?.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block?.type === "toolCall" && typeof block.id === "string") {
|
||||
pendingCalls.set(block.id, {
|
||||
toolName: String(block.name ?? "unknown"),
|
||||
args: block.input ?? block.arguments ?? block.args ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
|
||||
const call = pendingCalls.get(message.toolCallId) ?? {
|
||||
toolName: String(message.toolName ?? "unknown"),
|
||||
args: {},
|
||||
};
|
||||
const key = sourceKey(message.toolCallId, message.timestamp);
|
||||
const rawText = contentText(message.content);
|
||||
const persisted = summaries.get(key);
|
||||
const record: ToolHistoryRecord = {
|
||||
sourceKey: key,
|
||||
toolCallId: message.toolCallId,
|
||||
resultTimestamp: timestampOf(message.timestamp),
|
||||
toolName: call.toolName,
|
||||
args: call.args,
|
||||
isError: Boolean(message.isError),
|
||||
rawText,
|
||||
sourceHash: hash(`${call.toolName}\0${json(call.args)}\0${rawText}`),
|
||||
deterministicSummary: persisted?.deterministicSummary ?? deterministicSummary({
|
||||
toolName: call.toolName,
|
||||
args: call.args,
|
||||
rawText,
|
||||
isError: Boolean(message.isError),
|
||||
maxStubTokens: config.deterministic.maxStubTokens,
|
||||
}),
|
||||
semanticSummary: persisted?.semanticSummary,
|
||||
};
|
||||
this.put(record);
|
||||
pendingCalls.delete(message.toolCallId);
|
||||
}
|
||||
}
|
||||
this.generation++;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.records.clear();
|
||||
this.ids.clear();
|
||||
this.generation = 0;
|
||||
this.semanticJobs = 0;
|
||||
this.usageBaseline = undefined;
|
||||
this.sourceMessages = [];
|
||||
this.sourceKeys = [];
|
||||
@@ -187,86 +75,6 @@ export class SegmentIndex {
|
||||
this.cached = undefined;
|
||||
}
|
||||
|
||||
private put(record: ToolHistoryRecord): void {
|
||||
this.records.set(record.sourceKey, record);
|
||||
const keys = this.ids.get(record.toolCallId) ?? [];
|
||||
if (!keys.includes(record.sourceKey)) keys.push(record.sourceKey);
|
||||
this.ids.set(record.toolCallId, keys);
|
||||
}
|
||||
|
||||
captureTurn(message: any, toolResults: any[], config: PiCompactionConfig): ToolHistoryRecord[] {
|
||||
if (!config.enabled || !config.deterministic.enabled || !Array.isArray(message?.content)) return [];
|
||||
const calls = new Map<string, any>();
|
||||
for (const block of message.content) {
|
||||
if (block?.type === "toolCall" && typeof block.id === "string") calls.set(block.id, block);
|
||||
}
|
||||
const captured: ToolHistoryRecord[] = [];
|
||||
for (const result of toolResults ?? []) {
|
||||
if (!result || typeof result.toolCallId !== "string") continue;
|
||||
const call = calls.get(result.toolCallId);
|
||||
if (!call) continue;
|
||||
const rawText = contentText(result.content);
|
||||
const toolName = String(call.name ?? result.toolName ?? "unknown");
|
||||
const args = call.input ?? call.arguments ?? call.args ?? {};
|
||||
const key = sourceKey(result.toolCallId, result.timestamp);
|
||||
const record: ToolHistoryRecord = {
|
||||
sourceKey: key,
|
||||
toolCallId: result.toolCallId,
|
||||
resultTimestamp: timestampOf(result.timestamp),
|
||||
toolName,
|
||||
args,
|
||||
isError: Boolean(result.isError),
|
||||
rawText,
|
||||
sourceHash: hash(`${toolName}\0${json(args)}\0${rawText}`),
|
||||
deterministicSummary: deterministicSummary({
|
||||
toolName,
|
||||
args,
|
||||
rawText,
|
||||
isError: Boolean(result.isError),
|
||||
maxStubTokens: config.deterministic.maxStubTokens,
|
||||
}),
|
||||
};
|
||||
this.put(record);
|
||||
captured.push(record);
|
||||
}
|
||||
if (captured.length) {
|
||||
this.generation++;
|
||||
this.cached = undefined;
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
applySemantic(sourceKeyValue: string, sourceHash: string, summary: string): SegmentSummaryRecord | undefined {
|
||||
const record = this.records.get(sourceKeyValue);
|
||||
if (!record || record.sourceHash !== sourceHash || !summary.trim()) return undefined;
|
||||
record.semanticSummary = summary.trim();
|
||||
this.generation++;
|
||||
this.cached = undefined;
|
||||
return this.toPersisted(record);
|
||||
}
|
||||
|
||||
toPersisted(record: ToolHistoryRecord): SegmentSummaryRecord {
|
||||
const effective = record.semanticSummary ?? record.deterministicSummary;
|
||||
return {
|
||||
version: COMPACTION_VERSION,
|
||||
kind: "segment-summary",
|
||||
sourceKey: record.sourceKey,
|
||||
toolCallId: record.toolCallId,
|
||||
resultTimestamp: record.resultTimestamp,
|
||||
toolName: record.toolName,
|
||||
sourceHash: record.sourceHash,
|
||||
deterministicSummary: record.deterministicSummary,
|
||||
...(record.semanticSummary ? { semanticSummary: record.semanticSummary } : {}),
|
||||
rawChars: record.rawText.length,
|
||||
effectiveChars: effective.length,
|
||||
};
|
||||
}
|
||||
|
||||
setSemanticJobs(count: number): void {
|
||||
this.semanticJobs = Math.max(0, count);
|
||||
if (this.cached) this.cached = { ...this.cached, semanticJobs: this.semanticJobs };
|
||||
}
|
||||
|
||||
recordUsage(tokens: number, messageEstimate: number, environmentHash: string): void {
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) return;
|
||||
this.usageBaseline = { tokens, messageEstimate, environmentHash };
|
||||
@@ -277,11 +85,27 @@ export class SegmentIndex {
|
||||
environmentHash: string;
|
||||
environmentTokens: number;
|
||||
contextWindow: number;
|
||||
maskedResults: number;
|
||||
reclaimedTokens: number;
|
||||
}): ProjectionSnapshot {
|
||||
const sameTail = messages.length === this.sourceMessages.length
|
||||
&& messages.length > 0
|
||||
&& messageKey(messages[messages.length - 1]) === this.sourceKeys[this.sourceKeys.length - 1];
|
||||
if (this.cached && sameTail && this.cached.environmentHash === params.environmentHash) return this.cached;
|
||||
if (this.cached && sameTail && this.cached.environmentHash === params.environmentHash) {
|
||||
if (this.cached.maskedResults === params.maskedResults
|
||||
&& this.cached.reclaimedTokens === params.reclaimedTokens) {
|
||||
return this.cached;
|
||||
}
|
||||
// Masking stats changed but the message list did not: publish a new
|
||||
// immutable snapshot object instead of re-projecting or mutating.
|
||||
this.cached = {
|
||||
...this.cached,
|
||||
maskedResults: params.maskedResults,
|
||||
reclaimedTokens: params.reclaimedTokens,
|
||||
rawTokens: this.cached.effectiveTokens + params.reclaimedTokens,
|
||||
};
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
const appendOnly = this.sourceMessages.length > 0
|
||||
&& messages.length >= this.sourceMessages.length
|
||||
@@ -301,17 +125,14 @@ export class SegmentIndex {
|
||||
|
||||
for (let index = start; index < messages.length; index++) {
|
||||
const message = messages[index];
|
||||
const projected = this.projectMessage(message);
|
||||
this.sourceMessages.push(message);
|
||||
this.sourceKeys.push(messageKey(message));
|
||||
this.projectedMessages.push(projected);
|
||||
this.projectedMessages.push(message);
|
||||
this.rawEstimates.push(estimateTokens(message as any));
|
||||
this.effectiveEstimates.push(estimateTokens(projected as any));
|
||||
this.effectiveEstimates.push(estimateTokens(message as any));
|
||||
}
|
||||
|
||||
const rawMessageTokens = this.rawEstimates.reduce((sum, value) => sum + value, 0);
|
||||
const effectiveMessageTokens = this.effectiveEstimates.reduce((sum, value) => sum + value, 0);
|
||||
const rawTokens = rawMessageTokens + params.environmentTokens;
|
||||
const baseline = this.usageBaseline;
|
||||
const usageBacked = baseline?.environmentHash === params.environmentHash;
|
||||
const effectiveTokens = usageBacked
|
||||
@@ -329,55 +150,19 @@ export class SegmentIndex {
|
||||
environmentHash: params.environmentHash,
|
||||
messages: this.projectedMessages,
|
||||
messageEstimate: effectiveMessageTokens,
|
||||
rawTokens,
|
||||
rawTokens: effectiveTokens + params.reclaimedTokens,
|
||||
effectiveTokens,
|
||||
reclaimedTokens: Math.max(0, rawTokens - effectiveTokens),
|
||||
reclaimedTokens: params.reclaimedTokens,
|
||||
contextWindow: params.contextWindow,
|
||||
confidence,
|
||||
lowerBound,
|
||||
upperBound,
|
||||
segmentCount: this.sourceMessages.length,
|
||||
semanticJobs: this.semanticJobs,
|
||||
maskedResults: params.maskedResults,
|
||||
};
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
private projectMessage(message: any): any {
|
||||
if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") return message;
|
||||
const record = this.records.get(sourceKey(message.toolCallId, message.timestamp));
|
||||
if (!record) return message;
|
||||
const original = contentText(message.content);
|
||||
const summary = record.semanticSummary ?? record.deterministicSummary;
|
||||
const projected = `${summary}\nSource: ${record.sourceKey}; recover with context_tree_query.`;
|
||||
if (projected.length >= original.length) return message;
|
||||
return {
|
||||
...message,
|
||||
content: [{ type: "text", text: projected }],
|
||||
};
|
||||
}
|
||||
|
||||
query(ids: string[]): ToolHistoryRecord[] {
|
||||
const found: ToolHistoryRecord[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of ids) {
|
||||
const direct = this.records.get(id);
|
||||
const keys = direct ? [id] : this.ids.get(id) ?? [];
|
||||
for (const key of keys) {
|
||||
if (seen.has(key)) continue;
|
||||
const record = this.records.get(key);
|
||||
if (record) {
|
||||
found.push(record);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
allRecords(): ToolHistoryRecord[] {
|
||||
return [...this.records.values()];
|
||||
}
|
||||
|
||||
getSnapshot(): ProjectionSnapshot | undefined {
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user